chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
# Never let signing material enter a Docker build context / image.
|
||||
# The test image (test-infra/Dockerfile.test) uses selective COPY today, but
|
||||
# this is defense-in-depth against a future `COPY . .`.
|
||||
test-infra/signing/
|
||||
*.pem
|
||||
@@ -0,0 +1,3 @@
|
||||
|
||||
# Use bd merge for beads JSONL files
|
||||
.beads/issues.jsonl merge=beads
|
||||
@@ -0,0 +1 @@
|
||||
ko_fi: cloakhq
|
||||
@@ -0,0 +1,29 @@
|
||||
---
|
||||
name: Bug Report
|
||||
about: Report a bug or detection issue
|
||||
labels: bug
|
||||
---
|
||||
|
||||
Description: <!-- What happened? What did you expect? -->
|
||||
|
||||
CloakBrowser version: <!-- pip show cloakbrowser / npm list cloakbrowser -->
|
||||
|
||||
Wrapper: <!-- Python or JavaScript -->
|
||||
|
||||
Environment: <!-- OS, Docker y/n, base image, architecture -->
|
||||
|
||||
Launch options:
|
||||
|
||||
|
||||
Tested with a different IP or proxy? <!-- Yes (same result) / Yes (works with different IP) / No -->
|
||||
|
||||
Works outside Docker / on host machine? <!-- Yes / No / Not using Docker -->
|
||||
|
||||
Steps to reproduce:
|
||||
|
||||
|
||||
Error output / screenshots:
|
||||
|
||||
Dockerfile (if applicable):
|
||||
|
||||
Additional notes:
|
||||
@@ -0,0 +1 @@
|
||||
blank_issues_enabled: true
|
||||
@@ -0,0 +1,28 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
groups:
|
||||
actions:
|
||||
patterns:
|
||||
- "*"
|
||||
|
||||
- package-ecosystem: "pip"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
groups:
|
||||
python:
|
||||
patterns:
|
||||
- "*"
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/js"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
groups:
|
||||
javascript:
|
||||
patterns:
|
||||
- "*"
|
||||
@@ -0,0 +1,29 @@
|
||||
name: Attest Release Binary
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: 'Release tag (e.g. chromium-v145.0.7632.159.2)'
|
||||
required: true
|
||||
|
||||
jobs:
|
||||
attest:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
id-token: write # Sigstore OIDC
|
||||
attestations: write # GitHub attestation API
|
||||
contents: write # Download release assets
|
||||
steps:
|
||||
- name: Download release binaries
|
||||
run: gh release download "$RELEASE_TAG" --repo CloakHQ/cloakbrowser --pattern "cloakbrowser-*.tar.gz" --pattern "cloakbrowser-*.zip"
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
RELEASE_TAG: ${{ github.event.inputs.tag }}
|
||||
|
||||
- name: Attest build provenance
|
||||
uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1
|
||||
with:
|
||||
subject-path: |
|
||||
cloakbrowser-*.tar.gz
|
||||
cloakbrowser-*.zip
|
||||
@@ -0,0 +1,46 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
python:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
- name: Install dependencies
|
||||
run: pip install -e ".[dev]" pytest pytest-asyncio
|
||||
- name: Run tests
|
||||
run: pytest tests/ -v -m "not slow"
|
||||
|
||||
javascript:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: 20
|
||||
- name: Install and build
|
||||
run: cd js && npm install && npm run build
|
||||
- name: Typecheck
|
||||
run: cd js && npm run typecheck
|
||||
- name: Run tests
|
||||
run: cd js && npm test
|
||||
|
||||
dotnet:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0
|
||||
with:
|
||||
dotnet-version: '8.0.x'
|
||||
- name: Build
|
||||
run: dotnet build dotnet/CloakBrowser.sln -c Release
|
||||
- name: Run tests
|
||||
run: dotnet test dotnet/CloakBrowser.sln -c Release --no-build
|
||||
@@ -0,0 +1,163 @@
|
||||
name: Publish
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
job:
|
||||
description: 'Job to run (leave empty to run all)'
|
||||
required: false
|
||||
type: choice
|
||||
options:
|
||||
- ''
|
||||
- publish-pypi
|
||||
- publish-npm
|
||||
- publish-nuget
|
||||
- publish-docker
|
||||
|
||||
concurrency:
|
||||
group: publish
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
- name: Python tests
|
||||
run: |
|
||||
pip install -e ".[dev]" pytest pytest-asyncio
|
||||
pytest tests/ -v -m "not slow"
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: 22
|
||||
- name: JavaScript tests
|
||||
run: cd js && npm ci && npm run build && npm test
|
||||
- uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0
|
||||
with:
|
||||
dotnet-version: '8.0.x'
|
||||
- name: .NET tests
|
||||
run: dotnet test dotnet/CloakBrowser.sln -c Release
|
||||
|
||||
validate-version:
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
- name: Check tag matches package versions
|
||||
run: |
|
||||
TAG="${GITHUB_REF_NAME#v}"
|
||||
PY=$(python -c 'import re; print(re.search(r"__version__\s*=\s*[\"'\'']([^\"'\'']+)", open("cloakbrowser/_version.py").read()).group(1))')
|
||||
JS=$(python -c 'import json; print(json.load(open("js/package.json"))["version"])')
|
||||
NET=$(python -c 'import re; print(re.search(r"<Version>([^<]+)", open("dotnet/src/CloakBrowser/CloakBrowser.csproj").read()).group(1))')
|
||||
echo "Tag: $TAG | Python: $PY | npm: $JS | .NET: $NET"
|
||||
[ "$TAG" = "$PY" ] || { echo "ERROR: tag v$TAG != _version.py $PY"; exit 1; }
|
||||
[ "$TAG" = "$JS" ] || { echo "ERROR: tag v$TAG != package.json $JS"; exit 1; }
|
||||
[ "$TAG" = "$NET" ] || { echo "ERROR: tag v$TAG != CloakBrowser.csproj $NET"; exit 1; }
|
||||
|
||||
publish-pypi:
|
||||
needs: [test, validate-version]
|
||||
if: always() && needs.test.result == 'success' && (needs.validate-version.result == 'success' || needs.validate-version.result == 'skipped') && (github.event.inputs.job == '' || github.event.inputs.job == 'publish-pypi')
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
id-token: write # OIDC trusted publishing — no PYPI_TOKEN needed
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
- name: Build
|
||||
run: |
|
||||
pip install build
|
||||
python -m build
|
||||
- name: Publish to PyPI
|
||||
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1
|
||||
|
||||
publish-npm:
|
||||
needs: [test, validate-version]
|
||||
if: always() && needs.test.result == 'success' && (needs.validate-version.result == 'success' || needs.validate-version.result == 'skipped') && (github.event.inputs.job == '' || github.event.inputs.job == 'publish-npm')
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
id-token: write # OIDC trusted publishing + provenance — no NPM_TOKEN needed
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: 24 # npm 11.11.0 native — no upgrade needed (Node 22.22.2 has broken npm)
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
- name: Build
|
||||
run: cd js && npm ci && npm run build
|
||||
- name: Publish to npm
|
||||
run: cd js && npm publish --provenance --access public
|
||||
|
||||
publish-nuget:
|
||||
needs: [test, validate-version]
|
||||
if: always() && needs.test.result == 'success' && (needs.validate-version.result == 'success' || needs.validate-version.result == 'skipped') && (github.event.inputs.job == '' || github.event.inputs.job == 'publish-nuget')
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
id-token: write # OIDC trusted publishing — no NUGET_API_KEY secret needed
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0
|
||||
with:
|
||||
dotnet-version: '8.0.x'
|
||||
- name: NuGet login (OIDC trusted publishing)
|
||||
id: nuget-login
|
||||
uses: NuGet/login@8d196754b4036150537f80ac539e15c2f1028841 # v1.2.0
|
||||
with:
|
||||
user: CloakHQ
|
||||
- name: Pack
|
||||
run: dotnet pack dotnet/src/CloakBrowser/CloakBrowser.csproj -c Release -o out
|
||||
- name: Publish to NuGet
|
||||
run: dotnet nuget push out/*.nupkg --api-key ${{ steps.nuget-login.outputs.NUGET_API_KEY }} --source https://api.nuget.org/v3/index.json --skip-duplicate
|
||||
|
||||
publish-docker:
|
||||
needs: [test, validate-version]
|
||||
if: always() && needs.test.result == 'success' && (needs.validate-version.result == 'success' || needs.validate-version.result == 'skipped') && (github.event.inputs.job == '' || github.event.inputs.job == 'publish-docker')
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
id-token: write # Cosign keyless signing + attestations
|
||||
contents: read
|
||||
attestations: write
|
||||
packages: write
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- name: Extract version
|
||||
run: |
|
||||
VERSION=$(python -c 'import re; print(re.search(r"__version__\s*=\s*[\"'\'']([^\"'\'']+)", open("cloakbrowser/_version.py").read()).group(1))')
|
||||
echo "VERSION=$VERSION" >> $GITHUB_ENV
|
||||
- uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 # v4.1.0
|
||||
- uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||
- uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USER }}
|
||||
password: ${{ secrets.DOCKER_PAT }}
|
||||
- name: Build and push
|
||||
id: build
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: |
|
||||
cloakhq/cloakbrowser:${{ env.VERSION }}
|
||||
cloakhq/cloakbrowser:latest
|
||||
provenance: true
|
||||
sbom: true
|
||||
- uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
|
||||
- name: Sign image
|
||||
run: cosign sign --yes cloakhq/cloakbrowser@${{ steps.build.outputs.digest }}
|
||||
- name: Attest build provenance
|
||||
uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1
|
||||
with:
|
||||
subject-name: index.docker.io/cloakhq/cloakbrowser
|
||||
subject-digest: ${{ steps.build.outputs.digest }}
|
||||
push-to-registry: true
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
*.egg
|
||||
.eggs/
|
||||
|
||||
# Virtual environment
|
||||
.venv/
|
||||
venv/
|
||||
env/
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Testing
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
htmlcov/
|
||||
|
||||
# Binary cache (downloaded chromium)
|
||||
.cloakbrowser/
|
||||
|
||||
# Claude Code (private project context)
|
||||
CLAUDE.md
|
||||
.claude/
|
||||
|
||||
# JavaScript / Node.js
|
||||
js/node_modules/
|
||||
js/dist/
|
||||
|
||||
# Distribution
|
||||
*.tar.gz
|
||||
*.whl
|
||||
AGENTS.md
|
||||
.beads
|
||||
result
|
||||
|
||||
# Private docs (launch posts, strategy)
|
||||
docs/
|
||||
|
||||
# Internal test infrastructure (Docker, VPS-specific)
|
||||
test-infra/
|
||||
|
||||
# Website (deployed separately)
|
||||
site/
|
||||
|
||||
# Browser profile manager (deployed separately)
|
||||
manager/
|
||||
|
||||
# Release scripts
|
||||
publish.sh
|
||||
deploy.sh
|
||||
.env
|
||||
debug
|
||||
publish-docker.sh
|
||||
captures
|
||||
20[0-9][0-9]-[0-9][0-9]-[0-9][0-9]-*.txt
|
||||
|
||||
# Beads / Dolt files (added by bd init)
|
||||
.dolt/
|
||||
*.db
|
||||
.beads-credential-key
|
||||
.antigravitycli
|
||||
.plans
|
||||
.pi
|
||||
@@ -0,0 +1,172 @@
|
||||
# CloakBrowser Binary License
|
||||
|
||||
**Version 1.2 — July 2026**
|
||||
|
||||
Copyright (c) 2026 CloakHQ. All rights reserved.
|
||||
|
||||
This license applies to the compiled CloakBrowser Chromium browser binary ("Binary") distributed by CloakHQ through official CloakHQ distribution channels, including GitHub Releases and cloakbrowser.dev.
|
||||
|
||||
It does **not** apply to the wrapper source code in this repository, which is licensed under the [MIT License](LICENSE).
|
||||
|
||||
By downloading, installing, or using the Binary, you agree to be bound by the terms of this license.
|
||||
|
||||
## Intellectual Property
|
||||
|
||||
The Binary is built on Chromium, which is open-source software by The Chromium Authors under the BSD 3-Clause License, and incorporates components from the open-source ungoogled-chromium project.
|
||||
|
||||
CloakHQ's build configuration, patches, compiled releases, and the Binary as distributed by CloakHQ are the proprietary property of CloakHQ. This license governs only the Binary as distributed by CloakHQ and does not restrict any rights granted by upstream open-source licenses to their respective components.
|
||||
|
||||
## Grant of Use
|
||||
|
||||
You are granted a non-exclusive, non-transferable, non-sublicensable license to use the Binary for personal, commercial, and internal business purposes, subject to the Version-Specific Terms, Restrictions, and other limitations in this license.
|
||||
|
||||
## Version-Specific Terms
|
||||
|
||||
Starting with the version currently listed on cloakbrowser.dev, downloading and using the **latest major** Binary version requires an active paid subscription at the tier designated by CloakHQ for such access.
|
||||
|
||||
Binary versions that CloakHQ makes available for use without an active paid subscription, or older Binary versions that you lawfully obtained from official CloakHQ distribution channels, remain subject to this License in full.
|
||||
|
||||
All terms, restrictions, and limitations in this License apply equally to such versions, regardless of whether they require a paid subscription, the specific version number, or the amount of time that has passed since their release.
|
||||
|
||||
Current subscription tiers, details, and pricing: [https://cloakbrowser.dev](https://cloakbrowser.dev)
|
||||
|
||||
### Changes to Version-Specific Terms
|
||||
|
||||
CloakHQ may update these Version-Specific Terms in future releases, including which major Chromium versions require an active paid subscription. Existing Binary License holders will receive at least thirty (30) days' notice of any change that materially affects their current access tier, provided through the channels described in the Modifications to Pricing and Terms section below. Continued use of the Binary after the effective date of such a change constitutes acceptance of the updated terms.
|
||||
|
||||
## Modifications to Pricing and Terms
|
||||
|
||||
CloakHQ reserves the right to modify, add, rename, or discontinue features, pricing, usage limits, or other terms of any subscription plan or tier offered by CloakHQ, whether now existing or introduced in the future, at any time, in its sole discretion.
|
||||
|
||||
**Notice period.** CloakHQ will provide at least thirty (30) days' advance notice of any material price increase or reduction in features for active, paying subscribers, via email to the account's registered address and/or a notice displayed in the account dashboard.
|
||||
|
||||
**Effective date.** Price and term changes take effect at the start of the next billing cycle following the notice period. Changes do not apply retroactively to periods already paid for.
|
||||
|
||||
**Your options.** If you do not agree to a modified price or term, you may cancel your subscription before the change takes effect. Continued use of the Binary or subscription after the effective date constitutes acceptance of the new pricing and terms.
|
||||
|
||||
**No refunds on cancellation.** If you cancel mid-billing-cycle, whether in response to a pricing change or otherwise, no refund will be issued for the remaining, unused portion of that cycle.
|
||||
|
||||
**Grandfathering.** At CloakHQ's discretion, existing subscribers may retain current pricing for a stated period (a "grandfather period") before transitioning to new pricing. Any such grandfather period will be communicated in the notice referenced above and is not a guarantee of future pricing.
|
||||
|
||||
**Non-material changes.** Minor changes that do not increase price or materially reduce functionality, including, without limitation, UI updates, non-breaking API changes, or documentation updates, may be made without advance notice.
|
||||
|
||||
## License Keys and Subscription Access
|
||||
|
||||
Any license key, account access, or subscription entitlement provided by CloakHQ may be used only by the person or organization to whom it was issued and only within the applicable subscription terms.
|
||||
|
||||
You may not share, sell, lease, transfer, publish, or otherwise make available any license key, account access, or subscription entitlement to any third party, or use it to circumvent license validation, session limits, or subscription requirements, and CloakHQ may immediately suspend or revoke access for any violation.
|
||||
|
||||
## Restrictions
|
||||
|
||||
You may NOT:
|
||||
|
||||
1. **Redistribute** the Binary, in whole or in part, whether modified or unmodified.
|
||||
2. **Resell, sublicense, or repackage** the Binary, or include it in any product or service distributed to third parties, except under a separate license.
|
||||
3. **Reverse engineer, decompile, or disassemble** the Binary, or attempt to derive source code from it, except to the extent permitted by applicable law.
|
||||
4. **Modify** the Binary or create derivative works based on it.
|
||||
5. **Remove or alter** any copyright notices, license files, or attribution included with the Binary.
|
||||
|
||||
Normal use of the Binary with command-line flags, browser extensions, managed policies, custom profiles, or user data directories does not constitute modification or creation of derivative works.
|
||||
|
||||
## Cloud, Container, OEM/SaaS License & Integration Use
|
||||
|
||||
Using CloakBrowser for your own internal browsing, web scraping, data extraction, automation, research, or business workflows does not require an OEM/SaaS license, regardless of company size or revenue, as long as you control the browsing process and do not give third parties control over the browser itself. Version-specific subscription requirements still apply to the latest major version.
|
||||
|
||||
**Internal use** — You may store and run the unmodified CloakBrowser Binary within your internal infrastructure, including, without limitation, Docker images, CI runners, and internal artifact repositories, solely for your organization's internal operational purposes.
|
||||
|
||||
Internal operational purposes include, without limitation, web scraping, data extraction, research, and similar workflows performed for your own business.
|
||||
|
||||
A third-party customer may request a business deliverable, including, without limitation, a report, dataset, analysis, or similar output, and the use may still be considered internal use if your organization controls the browsing process, execution environment, and method of performing the work.
|
||||
|
||||
This internal use allowance applies to both the older version of the CloakBrowser Binary and any paid subscription tier, subject to the limitations and restrictions defined in this License.
|
||||
|
||||
**Paid subscription** — An active paid subscription at the applicable tier grants access to the latest major CloakBrowser Binary version and eligible updates included with that subscription. No subscription tier, regardless of name, grants redistribution, resale, sublicensing, bundling, embedding, hosted-service, white-label, OEM, or SaaS rights unless expressly stated in a separate agreement.
|
||||
|
||||
**Dependency listing** — Listing CloakBrowser as a dependency in your project, including, without limitation, in package files, build scripts, or documentation, does not constitute redistribution, as end users download the CloakBrowser Binary directly from official CloakHQ channels. No separate OEM/SaaS license is required for this.
|
||||
|
||||
**OEM/SaaS license required** — Except for permitted internal use as described above, a separate OEM/SaaS license is required when the CloakBrowser Binary is bundled, embedded, exposed through an API, or used to provide browser functionality to third-party customers, including, without limitation, as part of a product, hosted service, browser-as-a-service offering, or customer-facing workflow.
|
||||
|
||||
An OEM/SaaS license is also required if third-party customers are given technical access to, or control over, the browser capability itself. For purposes of this License, "control" means the ability to operate, configure, manage, or influence the CloakBrowser Binary, its browsing sessions, execution settings, or similar technical aspects. Requests for specific data, targets, reports, or other business outputs do not constitute control if the licensee retains full authority over how the browsing process is executed.
|
||||
|
||||
Running the CloakBrowser Binary on your own infrastructure does not make a use internal if the Binary is being used to provide browser functionality, hosted browser execution, browser capacity, or customer-controlled browser workflows to third-party customers.
|
||||
|
||||
Contact [info@cloakbrowser.dev](mailto:info@cloakbrowser.dev) for OEM/SaaS licensing.
|
||||
|
||||
## Official Distribution
|
||||
|
||||
The Binary must originally be obtained from official CloakHQ distribution channels, including GitHub Releases and cloakbrowser.dev. Internal organizational mirrors permitted under the Cloud, Container, OEM/SaaS License & Integration Use section are not considered unauthorized sources.
|
||||
|
||||
## Trademark Notice
|
||||
|
||||
This license does not grant you any right to use the CloakHQ or CloakBrowser name, logo, or trademarks, except for nominative use reasonably necessary to refer to CloakHQ or CloakBrowser.
|
||||
|
||||
## Attribution
|
||||
|
||||
Attribution is appreciated but not required. If you'd like to credit CloakBrowser, a "Powered by CloakBrowser" notice with a link to https://github.com/CloakHQ/CloakBrowser in your documentation, README, or about page is welcome.
|
||||
|
||||
## Acceptable Use
|
||||
|
||||
You are solely responsible for how you use the Binary. You agree NOT to use the Binary for any activity that violates applicable laws or regulations in your jurisdiction. CloakHQ does not endorse, encourage, or support any illegal use.
|
||||
|
||||
Without limiting the above, the following uses are expressly prohibited:
|
||||
|
||||
* Unauthorized access to financial, banking, healthcare, or government authentication systems
|
||||
* Credential stuffing, brute-force login attempts, or automated account creation
|
||||
* Circumventing authentication on systems you do not own or have authorization to test
|
||||
* Any activity that constitutes fraud, identity theft, or unauthorized data collection
|
||||
|
||||
## Indemnification
|
||||
|
||||
You agree to indemnify and hold harmless CloakHQ, its contributors, officers, employees, and affiliates from any claims, damages, losses, liabilities, and expenses, including reasonable legal fees, arising from your use of the Binary, your violation of this License, or your violation of any applicable law or third-party rights.
|
||||
|
||||
## Disclaimer
|
||||
|
||||
THE BINARY IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, AND NONINFRINGEMENT. IN NO EVENT SHALL CLOAKHQ, ITS CONTRIBUTORS, OFFICERS, EMPLOYEES, AFFILIATES, LICENSORS, AUTHORS, OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES, LOSSES, OR OTHER LIABILITY, WHETHER IN CONTRACT, TORT, OR OTHERWISE, ARISING OUT OF OR RELATING TO THE BINARY, ITS USE, OR ANY OTHER DEALINGS WITH THE BINARY.
|
||||
|
||||
## Limitation of Liability
|
||||
|
||||
IN NO EVENT SHALL CLOAKHQ, ITS CONTRIBUTORS, OFFICERS, EMPLOYEES, AFFILIATES, LICENSORS, AUTHORS, OR COPYRIGHT HOLDERS BE LIABLE FOR ANY INDIRECT, INCIDENTAL, SPECIAL, CONSEQUENTIAL, EXEMPLARY, OR PUNITIVE DAMAGES, INCLUDING BUT NOT LIMITED TO LOSS OF PROFITS, DATA, BUSINESS OPPORTUNITIES, GOODWILL, OR REVENUE, ARISING OUT OF OR RELATING TO THE BINARY, ITS USE, OR ANY OTHER DEALINGS WITH THE BINARY, REGARDLESS OF THE THEORY OF LIABILITY.
|
||||
|
||||
CLOAKHQ'S TOTAL AGGREGATE LIABILITY SHALL NOT EXCEED THE LESSER OF ONE HUNDRED FIFTY US DOLLARS (US $150) OR THE AMOUNT ACTUALLY PAID BY YOU TO CLOAKHQ FOR THE BINARY DURING THE THREE (3) MONTHS PRECEDING THE CLAIM.
|
||||
|
||||
## Data Collection
|
||||
|
||||
CloakHQ does not intentionally include telemetry, analytics, or tracking mechanisms in the Binary, except for operational communications required for license validation and monitoring the number of concurrently open sessions.
|
||||
|
||||
The Binary is built on ungoogled-chromium, which removes Google-specific services and telemetry. Any network activity may result from normal browser operation, Chromium subsystems, user configuration, extensions, license validation, session monitoring, or the web pages and services you access, and not from any telemetry or analytics service operated by CloakHQ.
|
||||
|
||||
## Updates
|
||||
|
||||
CloakHQ may provide updates, patches, new versions, or support for the Binary at its discretion, subject to any applicable subscription terms. Any updates provided are subject to this License unless CloakHQ states otherwise.
|
||||
|
||||
## Termination
|
||||
|
||||
This License terminates automatically if you violate any of its terms. Upon termination, you must stop using the Binary and destroy all copies of the Binary in your possession or control. The Intellectual Property, Restrictions, Trademark Notice, Acceptable Use, Indemnification, Disclaimer, Limitation of Liability, Governing Law, Reservation of Rights, Entire Agreement, No Waiver, Assignment, and Severability sections survive termination.
|
||||
|
||||
## Governing Law
|
||||
|
||||
This License is governed by the laws of the jurisdiction in which CloakHQ, or its applicable legal entity or successor, is established. Any disputes arising under or relating to this License shall be subject to the exclusive jurisdiction of the courts in that jurisdiction.
|
||||
|
||||
## Reservation of Rights
|
||||
|
||||
All rights not expressly granted under this License are reserved by CloakHQ.
|
||||
|
||||
## Entire Agreement
|
||||
|
||||
This License constitutes the entire agreement between you and CloakHQ regarding the Binary and supersedes any prior or contemporaneous understandings relating to the Binary.
|
||||
|
||||
## No Waiver
|
||||
|
||||
Failure by CloakHQ to enforce any provision of this License does not constitute a waiver of that provision or any other provision.
|
||||
|
||||
## Assignment
|
||||
|
||||
You may not assign or transfer this License or any rights under it without prior written consent from CloakHQ.
|
||||
|
||||
## Severability
|
||||
|
||||
If any provision of this License is held to be unenforceable or invalid, that provision shall be modified to the minimum extent necessary to make it enforceable, and all remaining provisions shall continue in full force and effect.
|
||||
|
||||
## Contact
|
||||
|
||||
For licensing inquiries, including separate licenses for redistribution, OEM, or SaaS use, contact [info@cloakbrowser.dev](mailto:info@cloakbrowser.dev).
|
||||
+461
@@ -0,0 +1,461 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to CloakBrowser — wrapper and binary — are documented here.
|
||||
|
||||
Changes are tagged: **[wrapper]** for Python/JS wrapper, **[binary]** for Chromium patches.
|
||||
|
||||
---
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
- **[wrapper]** Authenticated HTTP/HTTPS proxies now use the browser's native proxy authentication on every platform whose binary supports it — resolved per platform and binary version — and fall back to the standard proxy path on older binaries that don't. Fixes credentialed HTTP/HTTPS proxies on macOS and ARM, which previously could not use the native path. Python, JavaScript, Puppeteer, and .NET.
|
||||
|
||||
## [0.4.10] — 2026-07-09
|
||||
|
||||
- **[wrapper]** Fix JS CLI silently exiting with no output when run via `npx`/`node_modules/.bin` (#427). The entry-point guard compared `import.meta.url` to an unresolved `process.argv[1]`; symlinked bin installs (npm/pnpm/npx) never matched, so no subcommand ran. Now realpath-resolves the invoked path before comparing.
|
||||
- **[wrapper]** Fix `humanize=True` breaking interactions with elements **inside iframes** (#428). `frame.locator("#btn").click()` / `fill()` / `hover()` / etc. (and the frame-level `frame.click(...)` equivalents) now resolve the selector in the owning sub-frame's own document instead of misrouting to the top page, which previously raised `ElementNotAttachedError`. Humanized mouse motion is preserved inside iframes. Python only (the JS and .NET wrappers were already frame-correct).
|
||||
|
||||
## [0.4.9] — 2026-07-09
|
||||
|
||||
- **[wrapper]** Free-tier launch banner and `cloakbrowser info` upgrade hint now surface the **7-day free Pro trial** (Chromium 148). Python, JS, and .NET.
|
||||
- **[wrapper]** `info`/`update` never diverge from the Pro build that actually launches — `info` now shows both the cached build that will launch and the server's latest Pro version so the two can no longer silently disagree. Unpinned launches prefer the server's latest when it's newer than (or replaces a missing) cached build; `update` is license-aware (a valid Pro key updates the Pro binary, everyone else updates free); the background Pro update check is now a rate-limited foreground check (one call/hour, honors `CLOAKBROWSER_AUTO_UPDATE=false`); a valid Pro license never silently falls back to free; binary verification failures now surface verbatim instead of falling back to a cached build. Python, JS, and .NET.
|
||||
- **[wrapper]** `info`/`doctor` Windows-font check now covers the full 8-font set (adds the two monospace fonts) and reports a strict N/8 count, with the launch-time warning firing on any incomplete set rather than only a fully-missing one. Adds a separate Office-font group (10 fonts) reported as informational N/10 with no install nudge. Python, JS, and .NET.
|
||||
|
||||
## [0.4.8] — 2026-07-05
|
||||
|
||||
- **[binary]** Chromium **148.0.7778.215.5** (Pro, Windows + Linux; macOS follows) — the biggest fingerprint update yet. Pro license required; v146 stays free.
|
||||
- **More common, more coherent hardware identities** — each `--fingerprint` seed is built from the most common real-world hardware values (screen, GPU, RAM, CPU cores, color depth, fonts, audio), chosen together so they form a popular, self-consistent device with no internal contradictions.
|
||||
- Deeper, more consistent **Windows persona** — expanded and localized font populations, tighter graphics/display alignment, and seed-coherent screen size, color depth, and audio across browser- and OS-level signals.
|
||||
- Expanded **NVIDIA GPU profiles**, each matched to a plausible CPU, RAM, and screen combination.
|
||||
- **Window and screen geometry coherence** in both headed and headless modes (pairs with the wrapper's new maximized-window default).
|
||||
- Greater **WebGL and WebGPU realism**, including consistent behavior under timing inspection.
|
||||
- **Cleaner proxy behavior** — better proxy-auth handling and less proxy-specific reload and cache leakage.
|
||||
- New **pass-through debug mode** (`--fingerprint=off`) and a **third-party-cookie compatibility flag** (`--fingerprint-allow-3p-cookies`).
|
||||
- **Runtime Pro license sessions** — privacy-preserving groundwork for future session-limit enforcement (`--license-through-proxy` opts these calls onto your proxy, Linux only for now).
|
||||
- **[wrapper]** `geoip=True` now works **without a proxy** — on a direct connection it resolves the machine's own public IP for timezone, locale, and the WebRTC exit IP (previously it no-oped without a proxy, leaving UTC + `en-US` in bare Docker/cloud launches). Locale coverage expanded from 50 to **132 countries**. Python, JS, and .NET.
|
||||
- **[wrapper]** Headed and headless launches now open **maximized** on the newest Pro binaries (version-gated, same threshold as the headless no-viewport default), so the window fills the spoofed screen and window/screen geometry stays self-consistent. Suppressed when you set an explicit window size/position or viewport; older and free binaries are unchanged. Python, JS, and .NET.
|
||||
- **[docs]** Documented the Chromium 148+ pass-through and compatibility flags you can pass via `args`: `--fingerprint=off` (native pass-through debug mode), `--fingerprint-allow-3p-cookies` (third-party-cookie compatibility for reCAPTCHA v3 / SSO / payment challenges), and `--license-through-proxy` (route license/session calls through your proxy, Linux only for now).
|
||||
|
||||
## [0.4.7] — 2026-07-02
|
||||
|
||||
- **[docker]** Update the public `cloaktest` suite to use free-tier-stable bot-detection checks while Pro continues to verify reCAPTCHA v3 at 0.9. The Docker smoke test now runs Sannysoft, Incolumitas, Rebrowser, deviceandbrowserinfo, BrowserScan, and CreepJS lies/noise=false; FingerprintJS and reCAPTCHA v3 are no longer hard-pass checks for the free v146 image.
|
||||
|
||||
## [0.4.6] — 2026-07-02
|
||||
|
||||
- **[wrapper]** The resolved Pro license key is now passed to the browser process at launch (via `CLOAKBROWSER_LICENSE_KEY`) so the Pro binary can authenticate itself, including when you supply a custom `env`. Fixes launch failures on the newest Pro binaries. Python, JS, and .NET.
|
||||
- **[wrapper]** Headless launches on the newest Pro binaries now track the real screen surface instead of a fixed emulated viewport, keeping window geometry self-consistent (version-gated — older and free binaries keep the deterministic headless viewport). Passing an explicit `viewport=`/`no_viewport` (Python) or `viewport`/`defaultViewport` (JS) still overrides. Python, JS, and .NET.
|
||||
|
||||
## [0.4.5] — 2026-06-29
|
||||
|
||||
- **[wrapper]** **`info` is now a full diagnostics command** — it reports the binary that will actually launch given your license, runs a launch test (`chrome --version`, plus a missing-shared-library probe on Linux), validates the license key to show the real tier (`free`/`solo`/`team`/`business`, or `invalid`), and checks Windows-font availability (Linux), the GeoIP database, and optional dependencies. `--quick` skips the launch test; `--json` emits machine-readable output. Python, JS, and .NET.
|
||||
- **[wrapper]** One-time startup notice when spoofing the Windows platform on a Linux host without Windows fonts installed, with guidance to install them for best results. Best-effort and silent on error; suppress with `CLOAKBROWSER_SUPPRESS_FONT_WARNING=1`. Python, JS, and .NET.
|
||||
- **[wrapper]** The first-launch banner now re-shows to free users every 3 days (Pro users still see it once). Python, JS, and .NET.
|
||||
|
||||
## [0.4.4] — 2026-06-27
|
||||
|
||||
- **[wrapper]** **Exact version pinning / rollback** — pin a specific Chromium build with `browser_version=` (`browserVersion` in JS, `BrowserVersion` in .NET) or the `CLOAKBROWSER_VERSION` environment variable, e.g. `launch(browser_version="148.0.7778.215.2")`. Works for both Free and Pro binaries and lets you roll back if a new build regresses on your site. A pin never overwrites the cached "latest" marker, so a later unpinned launch returns to the newest build. Python, JS, and .NET.
|
||||
- **[wrapper]** The Pro version check now sends the client platform so each OS resolves its own latest build independently — a new release for one platform no longer affects the others. Python, JS, and .NET.
|
||||
- **[binary]** Chromium **148.0.7778.215.3** (Pro) — fingerprint fixes and alignment across Linux, Windows, and macOS (Apple Silicon + Intel). Pro license required; v146 stays free.
|
||||
|
||||
## [0.4.3] — 2026-06-24
|
||||
|
||||
- **[wrapper]** **.NET 8 / C# client** — CloakBrowser now ships as a NuGet package (`CloakBrowser`), mirroring the Python and JS wrappers. Contributed by [@evelaa123](https://github.com/evelaa123) in PR #385.
|
||||
- **[wrapper]** **macOS Pro is now available** — the latest binary (Chromium 148.0.7778.215.2) downloads on macOS (Apple Silicon + Intel) with a Pro license, the same as Linux and Windows. v146 stays free.
|
||||
- **[wrapper]** A valid Pro license now hard-fails on a Pro download error on *every* platform instead of silently downgrading to the free binary. The temporary macOS-only free fallback added in 0.4.2 is removed now that the macOS Pro build ships. Python, JS, and .NET.
|
||||
|
||||
## [0.4.2] — 2026-06-23
|
||||
|
||||
- **[wrapper]** macOS Pro licenses now fall back to the free binary (macOS Pro build coming) instead of failing to launch. Transient and signature-verification failures still hard-fail. Python and JS.
|
||||
|
||||
## [0.4.1] — 2026-06-23
|
||||
|
||||
- **[wrapper]** Humanize: fix "Viewport size not available" crash on headed launches. Headed mode defaults to `no_viewport` (since 0.4.0), so `page.viewport_size` is `None` — human scroll now falls back to the live `window.innerWidth`/`window.innerHeight`. Covers Playwright (Python sync/async, JS) and Puppeteer.
|
||||
- **[wrapper]** **[docker]** Widevine: opt-in CDM auto-fetch for persistent contexts. Set `CLOAKBROWSER_FETCH_WIDEVINE` and the Docker entrypoint pulls the Widevine CDM from Google's component server (arch-aware, SHA-256 verified, atomic, cached), so DRM playback works without a local Chrome to copy from. Off by default; bare-metal Linux users can run `bin/fetch-widevine.py` directly.
|
||||
- **[meta]** First-launch banner now promotes the Pro tier (header badge dropped); refreshed README test-results stamp to Jun 2026 / Chromium 148.
|
||||
|
||||
## [0.4.0] — 2026-06-22
|
||||
|
||||
- **[wrapper]** **CloakBrowser Pro**: all launch functions now accept a `license_key` parameter (`licenseKey` in JS); a key can also be supplied via the `CLOAKBROWSER_LICENSE_KEY` environment variable or a `~/.cloakbrowser/license.key` file. With a valid key the latest binary is downloaded from cloakbrowser.dev; without one, the free binary continues to download from GitHub Releases exactly as before. License validation is cached locally for 24h, and the Pro binary is authenticated with the same pinned Ed25519 signature as the free binary. A valid key whose Pro download or signature check fails surfaces a clear error rather than silently downgrading to the free binary. Adds `validate_license`/`LicenseInfo` exports and a `tier` field on `binary_info()`. Details: https://cloakbrowser.dev
|
||||
- **[wrapper]** **Security**: downloaded binaries are now verified against a pinned Ed25519 signature on the published `SHA256SUMS` (a detached `SHA256SUMS.sig`), so a compromised download mirror can no longer certify a tampered binary — the previous same-origin checksum proved integrity but not authenticity (#308). The signed manifest also binds the release version, rejecting a forced downgrade to an older signed build. Verification is mandatory on the official download path; silent auto-update is preserved for everyone because only a constant public key is pinned, not per-version hashes. Older installed wrappers are unaffected.
|
||||
- **[wrapper]** Headed launches no longer apply a fixed emulated viewport on top of the real browser window — the page now tracks the actual window so window-geometry stays self-consistent. Headless keeps a deterministic viewport (unchanged). Applies across `launch`, `launch_context`, `launch_persistent_context` (+ async) and the JS Playwright/Puppeteer wrappers. Passing an explicit `viewport=`/`no_viewport` (Python) or `viewport`/`defaultViewport` (JS) still works exactly as before.
|
||||
- **[wrapper]** **Breaking**: removed the optional `patchright` backend. The `backend` parameter and `CLOAKBROWSER_BACKEND` environment variable no longer exist, and the `cloakbrowser[patchright]` extra is gone. Stock Playwright is now the only backend. The stealth binary handles automation-signal suppression at the C++ level — patchright added no measurable benefit on top of it (identical reCAPTCHA v3 score to plain Playwright) while breaking proxy auth and `add_init_script` (#27). Callers passing `backend=...` will get a `TypeError`; remove the argument.
|
||||
- **[binary]** **CloakBrowser Pro — first Pro build**: Chromium `148.0.7778.215.2` for linux-x64, linux-arm64, and windows-x64 — 59 source-level fingerprint patches (up from 58 on 146). macOS builds to follow. Available to Pro subscribers at cloakbrowser.dev; v146 remains free on GitHub Releases.
|
||||
- **[binary]** Rebased the full patch set across two Chromium major versions — 146 → 147 → 148 — re-applying and adapting every patch to current Chromium internals
|
||||
- **[binary]** Cross-API fingerprint consistency improvements for Chromium 148 profiles
|
||||
- **[binary]** WebRTC fingerprint hardening — network signals matched to real Chrome
|
||||
- **[binary]** Font metric alignment for Windows profiles via the opt-in `--fingerprint-windows-font-metrics` flag — requires Windows fonts installed (see README "Font Setup on Linux")
|
||||
|
||||
## [0.3.32] — 2026-06-20
|
||||
|
||||
- **[wrapper]** **Security**: Windows binary extraction — pass archive/destination paths to PowerShell via env vars instead of interpolating into the `-Command` string, closing a code-injection shape on paths containing single quotes (e.g. `C:\Users\O'Brien`)
|
||||
- **[wrapper]** Widevine: auto-seed CDM hint file for persistent contexts on Linux, so DRM playback works without manual pre-seeding
|
||||
- **[wrapper]** `cloakserve`: rewrite CDP WebSocket URLs so clients connect through the proxy correctly (thanks [@honor2030](https://github.com/honor2030), #234)
|
||||
- **[wrapper]** `cloakserve`: add idle cleanup for seeded profiles (thanks [@Kumario1](https://github.com/Kumario1), #352)
|
||||
- **[meta]** Fix `recaptcha_score.py` example — wait for the reCAPTCHA score to render before screenshot (thanks [@igo](https://github.com/igo) for the report, #374)
|
||||
- **[meta]** Bump GitHub Actions in the actions group (#358)
|
||||
|
||||
## [0.3.31] — 2026-05-26
|
||||
|
||||
- **[wrapper]** Route HTTP proxy credentials through `--proxy-server` flag, removing the need for Playwright's proxy auth handler on HTTP proxies
|
||||
- **[wrapper]** JS: export `buildContextOptions` helper for custom context creation (thanks [@honor2030](https://github.com/honor2030), #262)
|
||||
- **[wrapper]** Humanize: fix iframe coordinate offset in pointer-events check (thanks [@eofreternal](https://github.com/eofreternal), #303)
|
||||
- **[wrapper]** Humanize: use shared deadline for timeout budget in frame and ElementHandle methods (#307)
|
||||
- **[docker]** Clean up stale Xvfb lock so container survives restarts (thanks [@sparanoid](https://github.com/sparanoid), #284)
|
||||
- **[meta]** Add pip and npm ecosystems to Dependabot, bump GitHub Actions (#309)
|
||||
|
||||
## [0.3.30] — 2026-05-21
|
||||
|
||||
- **[binary]** New build 146.0.7680.177.5 for Linux x64 + Windows x64 — 58 source-level fingerprint patches (up from 57)
|
||||
- **[binary]** Rendering consistency improvements across Linux and Windows — corrected GPU, display, and graphics parameters to match stock Chrome 146 profiles
|
||||
- **[binary]** Windows: native GPU/rendering values now pass through directly instead of being spoofed, matching real hardware behavior
|
||||
- **[binary]** Storage normalization fix for Windows
|
||||
- **[binary]** HTTP proxy inline credential support at the network layer
|
||||
- **[wrapper]** Update `PLATFORM_CHROMIUM_VERSIONS` for linux-x64 and windows-x64 to 146.0.7680.177.5
|
||||
|
||||
## [0.3.29] — 2026-05-20
|
||||
|
||||
- **[wrapper]** **Security**: `cloakserve` — guard WebSocket origins to prevent browser-origin CSRF via CDP proxy (thanks [@0xlally](https://github.com/0xlally) for the report, [@honor2030](https://github.com/honor2030) for the fix, #239, #240)
|
||||
- **[wrapper]** **Security**: Lambda example — add URL scheme validation, SSRF protection, post-navigation re-validation, remove unsafe caller-controlled options (#233)
|
||||
- **[wrapper]** **Security**: CI — isolate `workflow_dispatch` input to avoid shell injection in attest-release (thanks [@aaronjmars](https://github.com/aaronjmars), #223)
|
||||
- **[wrapper]** **Security**: JS — bump tar + transitive deps via npm audit fix (thanks [@aaronjmars](https://github.com/aaronjmars), #222)
|
||||
- **[wrapper]** Add `extension_paths` parameter for loading Chrome extensions in all launch functions (thanks [@zackycodes](https://github.com/zackycodes), #210)
|
||||
- **[wrapper]** Humanize: add Playwright-style actionability checks — auto-wait for visible, enabled, stable elements before humanized actions (#228)
|
||||
- **[wrapper]** JS: export composable launch helpers — `buildLaunchOptions()` and `humanizeBrowser()` for custom Playwright integrations (thanks [@honor2030](https://github.com/honor2030), #244)
|
||||
- **[wrapper]** JS: add `launchPersistentContext()` to Puppeteer wrapper (#261)
|
||||
- **[wrapper]** Add `flake.nix` for Nix/NixOS (thanks [@Seryiza](https://github.com/Seryiza), #220)
|
||||
- **[meta]** JS: sync package-lock metadata (thanks [@245678000000](https://github.com/245678000000), #219)
|
||||
|
||||
## [0.3.28] — 2026-05-11
|
||||
|
||||
- **[wrapper]** **Security**: `cloakserve` — sanitize fingerprint seed to prevent path traversal, bind to `127.0.0.1` on bare metal, detect Podman containers (#217)
|
||||
- **[wrapper]** Fix GeoIP resolution hanging indefinitely — bounded with 10s timeout so `launch()` cannot stall (thanks [@manaskarra](https://github.com/manaskarra), #213)
|
||||
- **[wrapper]** JS: preserve iframe scope in humanized frame actions — `check()`, `uncheck()`, `selectOption()` now execute in the correct frame (thanks [@manaskarra](https://github.com/manaskarra), #201)
|
||||
- **[wrapper]** JS: add TypeScript types to humanized method options — `HumanActionOptions` type for `human_config` and `timeout` overrides (thanks [@eofreternal](https://github.com/eofreternal), #205)
|
||||
- **[wrapper]** Log when SOCKS5 credential auto-encoding rewrites a proxy URL (thanks [@Youhai020616](https://github.com/Youhai020616), #209)
|
||||
- **[wrapper]** JS: bump `playwright-core` peer dependency minimum to >=1.53.0 (#200)
|
||||
- **[meta]** Bump sigstore/cosign-installer in CI (#214)
|
||||
|
||||
## [0.3.27] — 2026-05-06
|
||||
|
||||
- **[wrapper]** Per-call `human_config` override — pass `human_config={...}` to individual humanized methods to override global HumanConfig on a per-action basis (#183)
|
||||
- **[wrapper]** Humanized `scrollIntoViewIfNeeded` — auto-scrolls with human-like behavior when `humanize=True` (#183)
|
||||
- **[wrapper]** Forward `timeout` parameter through humanized Playwright methods (#183)
|
||||
- **[wrapper]** Fix humanize timeout default to align with Playwright's 30s auto-retry instead of custom 2s (#172)
|
||||
|
||||
## [0.3.26] — 2026-04-28
|
||||
|
||||
- **[binary]** Windows x64 upgraded to Chromium 146.0.7680.177.4 — 57 source-level fingerprint patches (up from 33 on 145.0.7632.159.7), now matches Linux. Includes all binary improvements from 0.3.18–0.3.25: native SOCKS5 proxy with UDP ASSOCIATE (QUIC/HTTP3), WebRTC IP spoofing, proxy signal removal, CDP input stealth, storage quota normalization, WebAuthn/AAC/window position patches, WebGL and canvas consistency fixes, expanded GPU model database
|
||||
- **[wrapper]** Auto URL-encode SOCKS5 credentials containing special characters in string URLs (#157)
|
||||
- **[wrapper]** AWS Lambda integration example with cold-start hardening and handler-side retry orchestration (#177, thanks [@AlexTech314](https://github.com/AlexTech314))
|
||||
- **[docker]** Add emoji and extended font packages to resolve Kasada/Akamai canvas fingerprint blocks (#179)
|
||||
- **[docs]** Add Font Setup on Linux section to README (#179)
|
||||
- **[docs]** Add Deployment Integrations section to README (#177)
|
||||
- **[meta]** Bump GitHub Actions dependencies (#178)
|
||||
|
||||
## [0.3.25] — 2026-04-16
|
||||
|
||||
- **[wrapper]** Python: add `launch_context_async()` — async counterpart to `launch_context()`. Returns a BrowserContext with all kwargs forwarded to `browser.new_context()`, enabling `storage_state`, `permissions`, `extra_http_headers`, etc. without a persistent profile folder. Closes #141.
|
||||
- **[wrapper]** JS: `launchContext()` and `launchPersistentContext()` silently dropped unknown options (including `storageState`). New `contextOptions` escape hatch forwards arbitrary options to Playwright's `newContext()`.
|
||||
- **[wrapper]** Fix `humanConfig` TypeScript typing (#151).
|
||||
- **[binary]** New build 146.0.7680.177.3 for Linux x64 + arm64 — 57 source-level fingerprint patches (up from 49): WebAuthn capabilities, AAC audio encoder, and window position spoofing; WebGL and canvas format consistency fixes; SOCKS5 warm connection pool auth fix for credentialed proxies.
|
||||
- **[docs]** Add recommended anti-bot config and SOCKS5 tips to troubleshooting.
|
||||
|
||||
## [0.3.24] — 2026-04-10
|
||||
|
||||
- **[wrapper]** Native SOCKS5 proxy support — pass `proxy="socks5://user:pass@host:port"` directly. Credentials handled natively by Chrome. Works across all launch functions, Python + JS.
|
||||
- **[wrapper]** Add Playwright ElementHandle humanize support — `element_handle.click()`, `.fill()`, `.type()` now use human-like behavior when `humanize=True` (thanks [@evelaa123](https://github.com/evelaa123), #133)
|
||||
- **[binary]** Upgrade Linux arm64 to Chromium 146.0.7680.177.2 (49 patches) — now matches Linux x64
|
||||
- **[binary]** New build 146.0.7680.177.2 for both Linux platforms: native SOCKS5 proxy with UDP ASSOCIATE (QUIC/HTTP3 over SOCKS5)
|
||||
- **[docs]** Clarify humanize requires wrapper import over CDP (#126)
|
||||
|
||||
## [0.3.23] — 2026-04-09
|
||||
|
||||
- **[wrapper]** Add full Puppeteer humanize support — human-like mouse, keyboard, and scroll behavior for `puppeteer-core` users (thanks [@evelaa123](https://github.com/evelaa123), #129)
|
||||
- **[wrapper]** Fix Playwright humanize gaps — `pressSequentially`, `tap`, `clear` on pages and frames now use human-like behavior (#129)
|
||||
- **[wrapper]** Expose humanize module for CDP-connected browsers — `import from 'cloakbrowser/human'` for manual patching of external Playwright instances (#126)
|
||||
- **[docker]** Fix `cloakserve` locale/timezone mismatch — CLI args now route through `build_args()` so the companion `--lang` flag is added automatically (#130)
|
||||
- **[meta]** Use Node 24 in CI publish workflow to work around broken npm in Node 22.22.2
|
||||
|
||||
## [0.3.22] — 2026-04-09
|
||||
|
||||
- **[binary]** Upgrade Linux x64 build to Chromium 146.0.7680.177.1 — 49 source-level C++ patches (up from 48), rebased from 145.0.7632.x
|
||||
|
||||
## [0.3.21] — 2026-04-07
|
||||
|
||||
- **[wrapper]** Remove dead `--disable-blink-features=AutomationControlled` flag -- binary patch 009 already handles `navigator.webdriver` at source level
|
||||
- **[wrapper]** Remove hardcoded GPU vendor/renderer flags -- binary auto-generates diverse, realistic GPU profiles from the fingerprint seed. Each seed gets a unique GPU instead of every user sharing the same one
|
||||
- **[wrapper]** Allow `viewport=None` to disable viewport emulation in both Python and JS wrappers (thanks [@kitiho](https://github.com/kitiho), #107)
|
||||
- **[wrapper]** Enable `geoip=True` in stealth test example to fix FingerprintJS detection
|
||||
- **[meta]** Remove npm self-upgrade step in CI -- Node 22 ships with compatible npm
|
||||
- **[docker]** Install `geoip2` in Docker image for GeoIP auto-detection support
|
||||
|
||||
## [0.3.20] — 2026-04-06
|
||||
|
||||
- **[binary]** Upgrade Linux x64 build to 145.0.7632.159.9 — 48 source-level C++ patches (up from 42)
|
||||
- **[binary]** 6 new patches: WebRTC IP spoofing, proxy signal removal, network timing normalization, WebGL accuracy improvements
|
||||
- **[binary]** New `--fingerprint-webrtc-ip` flag — spoof WebRTC ICE candidate IPs to match your proxy exit IP
|
||||
- **[binary]** Proxy detection signals eliminated — timing, headers, and network metadata normalized when proxy is active
|
||||
- **[binary]** WebGL rendering accuracy improvements for headed mode
|
||||
- **[wrapper]** Auto-inject `--fingerprint-webrtc-ip` when `geoip=True` — uses resolved exit IP from GeoIP lookup
|
||||
- **[wrapper]** Rewrite `cloakserve` as CDP multiplexer with per-connection fingerprint seeds and connection tracking
|
||||
- **[wrapper]** Humanize keyboard improvements — better behavioral stealth for typing interactions (thanks [@evelaa123](https://github.com/evelaa123))
|
||||
- **[meta]** Bump GitHub Actions dependencies
|
||||
|
||||
## [0.3.19] — 2026-03-30
|
||||
|
||||
- **[binary]** Upgrade Linux x64 build to 145.0.7632.159.8 — 42 source-level C++ patches (up from 33)
|
||||
- **[binary]** 9 new fingerprint patches covering additional browser APIs and cross-platform consistency
|
||||
- **[binary]** New `--fingerprint-noise` flag — disable noise injection while keeping deterministic fingerprint seed active
|
||||
- **[binary]** Improved fingerprint noise reliability and determinism across all patched APIs
|
||||
- **[binary]** Expanded platform-aware fingerprint spoofing for more realistic cross-platform profiles
|
||||
- **[binary]** Font rendering and detection accuracy improvements for Windows profiles
|
||||
- **[binary]** Removed experimental patches that caused compatibility issues with certain anti-bot systems
|
||||
- **[binary]** Docker/VNC environment compatibility improvements
|
||||
- **[wrapper]** Fix Playwright cleanup — `pw.stop()` now runs even if `browser.close()` raises or is cancelled (fixes #60, thanks [@dgtlmoon](https://github.com/dgtlmoon))
|
||||
- **[meta]** Pin GitHub Actions to commit SHAs, add Dependabot for automated dependency updates
|
||||
|
||||
## [0.3.18] — 2026-03-15
|
||||
|
||||
- **[wrapper]** Fix welcome banner printing to stdout — now writes to stderr so it won't corrupt JSON output in programmatic usage (fixes #59)
|
||||
- **[wrapper]** Fix `cloakserve` Docker WebGL by adding `--ignore-gpu-blocklist` flag
|
||||
- **[docs]** Add Crawlee integration example
|
||||
- **[meta]** Add GitHub issue template for bug reports
|
||||
|
||||
## [0.3.17] — 2026-03-15
|
||||
|
||||
- **[binary]** Windows x64 build upgraded to 145.0.7632.159.7 — 33 source-level C++ patches, matching Linux
|
||||
- **[wrapper]** Auto-inject GPU blocklist bypass for headed mode and Windows — fixes WebGL/WebGPU on software GPUs in Docker/VNC (fixes #56)
|
||||
- **[wrapper]** Add 8 framework integration examples (Scrapy, Crawlee, BrowserBase, etc.) and README integrations section
|
||||
|
||||
## [0.3.16] — 2026-03-14
|
||||
|
||||
- **[binary]** Linux arm64 build available — Raspberry Pi, AWS Graviton, Oracle Ampere now supported
|
||||
- **[wrapper]** Add donate link to first-launch welcome banner
|
||||
|
||||
## [0.3.15] — 2026-03-13
|
||||
|
||||
- **[binary]** Upgrade Linux build to 145.0.7632.159.7 — 33 source-level C++ patches
|
||||
- **[binary]** StorageBuckets API quota normalization — closes the last storage-based incognito detection vector
|
||||
- **[wrapper]** Fix non-ASCII character support in humanized typing — Cyrillic, CJK, and emoji now type correctly (thanks [@evelaa123](https://github.com/evelaa123))
|
||||
|
||||
## [0.3.14] — 2026-03-12
|
||||
|
||||
- **[binary]** Upgrade Linux build to 145.0.7632.159.6 — fix persistent context detection by FingerprintJS
|
||||
- **[binary]** Storage quota normalization for persistent context profiles
|
||||
- **[binary]** Fix outerHeight calculation for non-incognito contexts
|
||||
- **[wrapper]** Add CLI for binary management — `python -m cloakbrowser install` / `npx cloakbrowser install` with visible download progress (closes #43)
|
||||
|
||||
## [0.3.13] — 2026-03-10
|
||||
|
||||
- **[wrapper]** Suppress Playwright's `--enable-unsafe-swiftshader` default arg — eliminates SwiftShader software renderer detection signal, letting the binary's GPU spoofing work cleanly
|
||||
- **[binary]** Upgrade Linux build to 145.0.7632.159.5 — fix WebGPU adapter limits and features for NVIDIA profiles
|
||||
|
||||
## [0.3.12] — 2026-03-10
|
||||
|
||||
- **[binary]** Upgrade Linux build to 145.0.7632.159.4
|
||||
- **[binary]** Native locale spoofing — new C++ patch replaces detectable CDP-level locale emulation
|
||||
- **[binary]** WebGPU fingerprint hardening — spoof adapter features, limits, device ID, and subgroup sizes for cross-API consistency
|
||||
- **[binary]** Restore WebGPU blocklist bypass auto-injection (safe now with full adapter spoofing)
|
||||
- **[binary]** Fix WebGL renderer suffix — remove driver version string flagged by BrowserLeaks
|
||||
- **[wrapper]** Use binary flags for timezone/locale instead of CDP emulation — eliminates a detection vector
|
||||
- **[wrapper]** Support bare proxy format (`user:pass@host:port`) without scheme prefix
|
||||
- **[wrapper]** Use ANGLE-wrapped GPU strings in default stealth args for realistic WebGL fingerprint
|
||||
|
||||
## [0.3.11] — 2026-03-08
|
||||
|
||||
- **[wrapper]** `humanize=True` — human-like mouse (Bézier curves, overshoot), keyboard (per-character timing, thinking pauses), scroll (accelerate/cruise/decelerate), and click behavior. Two presets: `default` and `careful`. Works in Python and JS. (thanks [@evelaa123](https://github.com/evelaa123))
|
||||
- **[binary]** CDP input stealth — 4 new source-level C++ patches removing automation signals from input events
|
||||
- **[binary]** Support `--remote-debugging-address` flag for CDP bind address — eliminates the socat workaround in `cloakserve` Docker mode
|
||||
- **[wrapper]** `cloakserve` updated to use `--remote-debugging-address=0.0.0.0` directly — socat dependency removed from Docker image
|
||||
- **[binary]** GPU fingerprint accuracy improvements — renderer suffix strings now match real Chrome output across Windows and Linux profiles
|
||||
- **[binary]** GPU capability accuracy fix for NVIDIA profiles — spoofed values now reflect actual hardware limits
|
||||
- **[binary]** macOS GPU accuracy fix — GPU model database reference corrected for Apple Silicon profiles
|
||||
- **[binary]** Fix CDP input synthesis — a guard condition prevented the patch from activating; now fires correctly on all input events
|
||||
- **[binary]** Code quality hardening across patches — correctness and reliability fixes
|
||||
|
||||
## [0.3.10] — 2026-03-07
|
||||
|
||||
- **[binary]** Upgrade Linux build to 145.0.7632.159.2
|
||||
- **[binary]** Fix detection regression caused by unnecessary browser flag (fixes #16)
|
||||
- **[binary]** Fix fingerprint consistency in offline audio rendering
|
||||
- **[wrapper]** Add `cloakserve` CDP server mode for Docker — exposes Chrome DevTools Protocol on `0.0.0.0:9222` for external tool integration
|
||||
- **[wrapper]** Add wrapper regression tests: page.goto timing with stealth init (#9), add_init_script compatibility with proxy auth (#27)
|
||||
|
||||
## [0.3.9] — 2026-03-05
|
||||
|
||||
- **[binary]** Upgrade Chromium base to 145.0.7632.159 (Linux x64). macOS and Windows remain on 145.0.7632.109.2
|
||||
- **[binary]** WebGPU adapter spoofing for headless/Docker, timezone multi-context fix, stealth audit phase 2 (6 detection vector fixes), font auto-hide for cross-platform fingerprints
|
||||
- **[wrapper]** Default Playwright backend switched from `patchright` to stock `playwright`. Patchright broke proxy auth and `add_init_script` (#27) and is redundant since the binary handles stealth at C++ level. Opt in with `launch(backend="patchright")` or `CLOAKBROWSER_BACKEND=patchright` env var. Install: `pip install cloakbrowser[patchright]`
|
||||
- **[wrapper]** Deduplicate CLI flags when user args overlap with stealth defaults — user values win cleanly instead of passing both to Chromium
|
||||
- **[wrapper]** Extract shared `buildArgs` into `js/src/args.ts` (JS DRY fix), guard debug logging behind `DEBUG=cloakbrowser` env var
|
||||
|
||||
## [0.3.7] — 2026-03-05
|
||||
|
||||
- **[wrapper]** Unify timezone parameter: rename `timezone_id` to `timezone` in `launch_context()`, `launch_persistent_context()`, and `launch_persistent_context_async()` (Python). Old `timezone_id` still works with a deprecation warning. JS: deprecate `timezoneId` on `LaunchContextOptions` — use `timezone` (inherited from `LaunchOptions`)
|
||||
- **[wrapper]** Docker Hub image (`cloakhq/cloakbrowser`) — pre-built with Python + JS wrappers, Xvfb for headed mode, and `cloaktest` CLI shortcut. One-liner: `docker run --rm cloakhq/cloakbrowser cloaktest`
|
||||
- **[wrapper]** Add "Launching stealth browser..." feedback to all examples for better UX in Docker/CI
|
||||
- **[wrapper]** Comprehensive unit tests: 169 Python + 88 JS (up from 59 + 47)
|
||||
- **[docs]** Streamline READMEs for launch — reorder for conversion, collapse fingerprint flags, update Docker section
|
||||
|
||||
## [0.3.6] — 2026-03-04
|
||||
|
||||
- **[wrapper]** `proxy` parameter now accepts a Playwright proxy dict (`{server, bypass, username, password}`) in addition to URL strings — enables bypass lists and separate auth fields (PR #24). **TS note:** type changed from `string` to `string | object` — code that assumed `proxy` is always a string may need a `typeof` narrowing check
|
||||
|
||||
## [0.3.5] — 2026-03-04
|
||||
|
||||
- **[wrapper]** Add `launch_persistent_context()` and `launch_persistent_context_async()` (Python) — persistent browser profiles with cookie/localStorage persistence across sessions, avoids incognito detection (thanks [@evelaa123](https://github.com/evelaa123), [@yahooguntu](https://github.com/yahooguntu) — PRs #22, #17)
|
||||
- **[wrapper]** Add `launchPersistentContext()` (JS/TS) — same feature for JavaScript with full type support
|
||||
- **[wrapper]** Fix Windows zip extraction failure when primary download server is down — file handle leak caused `ERROR_SHARING_VIOLATION` on fallback download (thanks [@evelaa123](https://github.com/evelaa123) — PR #23)
|
||||
|
||||
## [0.3.4] — 2026-03-04
|
||||
|
||||
Binary v14: auto-spoof restored with seed, wrapper simplified to match.
|
||||
|
||||
- **[binary]** Restore full auto-spoof when `--fingerprint=seed` is set — all randomized properties now derive from the seed consistently
|
||||
- **[binary]** Auto-inject random fingerprint seed at startup if none provided. Binary is stealthy with zero flags
|
||||
- **[binary]** 26 source-level C++ patches (up from 25)
|
||||
- **[wrapper]** Simplify default stealth args — remove flags the binary now auto-generates. Wrapper still sets platform profile on Linux and `--no-sandbox`
|
||||
- **[wrapper]** Fix timezone in `launch_context()` — use Playwright's per-context timezone instead of binary flag, fixing mismatch when creating new browser contexts with geoip
|
||||
- **[wrapper]** Clarify README platform detection behavior
|
||||
|
||||
## [0.3.3] — 2026-03-03
|
||||
|
||||
All platforms now run Chromium 145 v2 with 25 patches. Windows x64 added.
|
||||
|
||||
- **[binary]** Auto-spoof by default — binary is stealthy with zero flags. Random fingerprint seed auto-generated at startup, no wrapper or configuration required
|
||||
- **[binary]** Platform-aware auto-detection — GPU, screen dimensions, and User-Agent automatically match the real OS (macOS, Linux, Windows) without explicit flags
|
||||
- **[binary]** Expanded GPU model database for realistic per-session diversity
|
||||
- **[binary]** First macOS v145 builds (arm64 + x64) — 25 patches, up from 16 on v142
|
||||
- **[binary]** First Windows x64 v145 build — 25 patches
|
||||
- **[wrapper]** Add Windows x64 platform support — auto-download, binary path resolution, and platform detection
|
||||
- **[wrapper]** Upgrade macOS (arm64 + x64) from Chromium 142 to 145 — all platforms now ship the same 25-patch build
|
||||
- **[wrapper]** Add explicit Mac GPU flags (`Apple M3 Metal` renderer) to default stealth args for consistent WebGL fingerprints
|
||||
- **[wrapper]** Improve reCAPTCHA stealth test — wait for score element instead of blind sleep
|
||||
- **[wrapper]** JS: add `win32-x64` platform mapping, Windows binary path (`chrome.exe`)
|
||||
|
||||
## [0.3.1] — 2026-03-03
|
||||
|
||||
- **[wrapper]** Auto-check for wrapper updates on startup (PyPI/npm). Notifies users when a newer wrapper version is available. Runs once per process, respects `CLOAKBROWSER_AUTO_UPDATE=false`.
|
||||
|
||||
---
|
||||
|
||||
## [0.3.0] — 2026-03-02
|
||||
|
||||
Chromium v145 upgrade. 25 fingerprint patches (up from 16). New download verification and fallback system. macOS v145 binary builds pending.
|
||||
|
||||
### Breaking
|
||||
|
||||
- **[wrapper]** Python dependency changed from `playwright` to `patchright` (CDP stealth fork). Patchright is API-compatible, but if you import `playwright` directly elsewhere, add it as a separate dependency. Replace `from playwright.sync_api` with `from patchright.sync_api` (or keep using `cloakbrowser.launch()` which handles this automatically).
|
||||
- **[wrapper]** `launch_context()` / `launchContext()` now defaults viewport to 1920×947 (realistic maximized Chrome on 1080p Windows with 48px taskbar) instead of Playwright's default 1280×720. Pass `viewport={"width": 1280, "height": 720}` explicitly to restore old behavior.
|
||||
|
||||
### 2026-03-02
|
||||
|
||||
- **[binary]** Full stealth audit — multiple detection vectors eliminated, improved cross-API consistency
|
||||
- **[binary]** Platform-aware fingerprint defaults: screen dimensions, taskbar, and layout auto-adjust per spoofed platform
|
||||
- **[binary]** Stability and performance improvements across fingerprint patches
|
||||
- **[binary]** New optional flags: `--fingerprint-fonts-dir`, `--fingerprint-taskbar-height`
|
||||
- **[wrapper]** Sync wrapper with latest binary changes: updated flag names, viewport, and defaults
|
||||
- **[wrapper]** Per-platform Chromium versioning — Linux and macOS can track different binary versions independently
|
||||
- **[wrapper]** Improved SHA-256 checksum verification and version marker migration
|
||||
|
||||
### 2026-03-01
|
||||
|
||||
- **[wrapper]** Upgrade wrapper to Chromium v145.0.7632.109
|
||||
- **[wrapper]** Add GitHub Releases fallback when primary download mirror is unavailable
|
||||
- **[wrapper]** Add SHA-256 checksum verification for binary downloads
|
||||
- **[wrapper]** Wire timezone and locale params to Chromium binary flags
|
||||
- **[wrapper]** Add device memory to default stealth args
|
||||
- **[wrapper]** JS: add `colorScheme` support, guard download fallback against partial failures
|
||||
|
||||
### 2026-02-28
|
||||
|
||||
- **[binary]** Enforce strict flag discipline — patches only activate when explicitly configured via command-line flags
|
||||
- **[binary]** Improved fingerprint consistency across multiple browser APIs
|
||||
- **[binary]** 3 new fingerprint patches + bug fixes in existing patches
|
||||
- **[binary]** New command-line flag for device memory spoofing
|
||||
- **[infra]** Automated test matrix: 8 groups, 41+ tests across core stealth, fingerprint noise, bot detection, reCAPTCHA, TLS, Turnstile, residential proxy, and enterprise reCAPTCHA
|
||||
- **[infra]** Docker-based test runner with subprocess isolation per test group
|
||||
|
||||
### 2026-02-25
|
||||
|
||||
- **[binary]** Reduced automation markers visible to detection scripts
|
||||
- **[binary]** Added browser API support at build time
|
||||
- **[binary]** Improved screen property consistency
|
||||
|
||||
### 2026-02-24
|
||||
|
||||
- **[binary]** Comprehensive fingerprint audit and hardening pass
|
||||
- **[binary]** Fixed font rendering edge case on cross-platform spoofing
|
||||
- **[binary]** 4 new fingerprint patches
|
||||
|
||||
### 2026-02-22
|
||||
|
||||
- **[binary]** Start Chromium v145 build (v145.0.7632.109)
|
||||
- **[binary]** 24 fingerprint patches ported and adapted
|
||||
|
||||
---
|
||||
|
||||
## [0.2.2] — 2026-03-01
|
||||
|
||||
### 2026-03-01
|
||||
|
||||
- **[wrapper]** Fix: replace `page.wait_for_timeout()` with `time.sleep()` to avoid timing leak
|
||||
- **[wrapper]** Add auto-detect timezone and locale from proxy IP via GeoIP lookup
|
||||
- **[binary]** CDP detection vector audit and hardening
|
||||
|
||||
---
|
||||
|
||||
## [0.2.0] — 2026-02-27
|
||||
|
||||
macOS platform release. JavaScript/TypeScript wrapper. Self-hosted binary mirror.
|
||||
|
||||
### 2026-02-27
|
||||
|
||||
- **[wrapper]** Add macOS support: Apple Silicon (arm64) and Intel (x64) binary downloads
|
||||
- **[wrapper]** Add GPG-signed release workflow via GitHub Actions
|
||||
- **[wrapper]** Fix macOS binary download: preserve `.app` symlinks, remove quarantine xattrs
|
||||
- **[wrapper]** Add real bot detection assertions to stealth tests
|
||||
- **[wrapper]** Bump version to 0.2.0
|
||||
|
||||
### 2026-02-26
|
||||
|
||||
- **[wrapper]** Switch binary downloads to self-hosted mirror (`cloakbrowser.dev`) as GitHub backup
|
||||
- **[wrapper]** Set up GitLab mirror at `gitlab.com/CloakHQ/cloakbrowser`
|
||||
|
||||
### 2026-02-25
|
||||
|
||||
- **[wrapper]** Move binary releases from separate repo to wrapper repo
|
||||
- **[wrapper]** Add auto-update check on launch
|
||||
- **[infra]** Initial Docker test infrastructure + matrix test runner
|
||||
|
||||
### 2026-02-24
|
||||
|
||||
- **[wrapper]** Add JavaScript/TypeScript wrapper with Playwright + Puppeteer support (`npm install cloakbrowser`)
|
||||
- **[wrapper]** Fix proxy authentication credentials support in URL (closes #4)
|
||||
|
||||
---
|
||||
|
||||
## [0.1.4] — 2026-02-23
|
||||
|
||||
### 2026-02-23
|
||||
|
||||
- **[wrapper]** Stealth hardening: additional launch args and detection evasion improvements
|
||||
- **[wrapper]** Full test suite rewrite with real detection site assertions
|
||||
- **[wrapper]** Add Docker support with Dockerfile and compose config
|
||||
- **[wrapper]** Add headed mode documentation
|
||||
|
||||
---
|
||||
|
||||
## [0.1.0] — 2026-02-22
|
||||
|
||||
Initial release. Chromium v142 with 16 fingerprint patches.
|
||||
|
||||
### 2026-02-22
|
||||
|
||||
- **[binary]** Chromium v142.0.7444.175 with 16 source-level fingerprint patches
|
||||
- **[binary]** Fix browser brand string to match Chrome 142 format
|
||||
- **[wrapper]** `launch()` and `launch_async()` — drop-in Playwright replacements
|
||||
- **[wrapper]** Auto-download binary from GitHub Releases, cached in `~/.cloakbrowser/`
|
||||
- **[wrapper]** Linux x64 platform support
|
||||
- **[wrapper]** Passes 14/14 bot detection tests
|
||||
- **[wrapper]** reCAPTCHA v3: 0.9 (server-verified), Cloudflare Turnstile: pass
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
# Chromium system deps + Node.js
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libnss3 libnspr4 libatk1.0-0 libatk-bridge2.0-0 libcups2 \
|
||||
libdbus-1-3 libdrm2 libxkbcommon0 libatspi2.0-0 libxcomposite1 \
|
||||
libxdamage1 libxfixes3 libxrandr2 libgbm1 libpango-1.0-0 \
|
||||
libcairo2 libasound2 libx11-xcb1 libfontconfig1 libx11-6 \
|
||||
libxcb1 libxext6 libxshmfence1 \
|
||||
libglib2.0-0 libgtk-3-0 libpangocairo-1.0-0 libcairo-gobject2 \
|
||||
libgdk-pixbuf-2.0-0 libxss1 libxtst6 fonts-liberation \
|
||||
fonts-noto-color-emoji fonts-unifont fonts-freefont-ttf \
|
||||
fonts-ipafont-gothic fonts-wqy-zenhei fonts-tlwg-loma-otf \
|
||||
xvfb xdotool openbox \
|
||||
curl ca-certificates \
|
||||
&& curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
|
||||
&& apt-get install -y --no-install-recommends nodejs \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Python wrapper
|
||||
COPY pyproject.toml README.md LICENSE BINARY-LICENSE.md CHANGELOG.md ./
|
||||
COPY cloakbrowser/ cloakbrowser/
|
||||
RUN pip install --no-cache-dir ".[serve,geoip]"
|
||||
|
||||
# JS wrapper
|
||||
COPY js/ js/
|
||||
RUN cd js && npm install && npm run build
|
||||
|
||||
# Examples
|
||||
COPY examples/ examples/
|
||||
|
||||
# Pre-download stealth Chromium binary during build (not at runtime)
|
||||
# Remove welcome marker so users see it on first container run
|
||||
RUN python -c "from cloakbrowser import ensure_binary; ensure_binary()" \
|
||||
&& rm -f ~/.cloakbrowser/.welcome_shown
|
||||
|
||||
# CLI shortcuts
|
||||
COPY bin/cloaktest /usr/local/bin/cloaktest
|
||||
COPY bin/cloakserve /usr/local/bin/cloakserve
|
||||
COPY bin/fetch-widevine.py /usr/local/bin/fetch-widevine.py
|
||||
RUN chmod +x /usr/local/bin/cloaktest /usr/local/bin/cloakserve /usr/local/bin/fetch-widevine.py
|
||||
|
||||
EXPOSE 9222
|
||||
|
||||
# Xvfb entrypoint for headed mode support
|
||||
COPY bin/docker-entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
|
||||
ENV DISPLAY=:99
|
||||
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
CMD ["python"]
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 CloakHQ
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,7 @@
|
||||
# WeHub 来源说明
|
||||
|
||||
- 原始项目:`CloakHQ/CloakBrowser`
|
||||
- 原始仓库:https://github.com/CloakHQ/CloakBrowser
|
||||
- 导入方式:上游默认分支的最新快照
|
||||
- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准
|
||||
- 本文件仅用于记录来源,不代表 WeHub 是原项目作者
|
||||
Executable
+883
@@ -0,0 +1,883 @@
|
||||
#!/usr/bin/env python3
|
||||
"""CDP multiplexer — per-connection fingerprint seeds for stealth Chromium.
|
||||
|
||||
Spawns a separate Chrome process per unique fingerprint seed, routing CDP
|
||||
connections through a single port. Each seed gets its own browser identity.
|
||||
|
||||
Usage:
|
||||
cloakserve # default, backward compat
|
||||
cloakserve --port=9222 # custom port
|
||||
|
||||
Client:
|
||||
browser = pw.chromium.connect_over_cdp("http://host:9222?fingerprint=12345")
|
||||
browser = pw.chromium.connect_over_cdp(
|
||||
"http://host:9222?fingerprint=12345&timezone=America/New_York&locale=en-US"
|
||||
)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import ipaddress
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import random
|
||||
import re
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import aiohttp
|
||||
import websockets
|
||||
from aiohttp import web
|
||||
|
||||
from cloakbrowser.browser import build_args, maybe_resolve_geoip, _resolve_webrtc_args, _normalize_socks_string_url
|
||||
from cloakbrowser.download import ensure_binary
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s %(levelname)s %(message)s",
|
||||
datefmt="%H:%M:%S",
|
||||
)
|
||||
logger = logging.getLogger("cloakserve")
|
||||
|
||||
# Args for running Chrome directly (outside Playwright).
|
||||
# Playwright normally adds its own version of these.
|
||||
BASE_CHROME_ARGS = [
|
||||
"--no-first-run",
|
||||
"--no-default-browser-check",
|
||||
"--disable-dev-shm-usage",
|
||||
"--disable-extensions",
|
||||
"--disable-popup-blocking",
|
||||
"--disable-background-networking",
|
||||
"--metrics-recording-only",
|
||||
"--ignore-gpu-blocklist",
|
||||
]
|
||||
|
||||
BASE_CDP_PORT = 5100
|
||||
|
||||
SAFE_SEED_RE = re.compile(r"^[A-Za-z0-9_-]{1,128}$")
|
||||
RESERVED_SEEDS = {"__default__"}
|
||||
TRUSTED_WS_ORIGINS = {"devtools://devtools", "chrome-devtools://devtools"}
|
||||
|
||||
|
||||
def _host_port_from_netloc(netloc: str, default_port: int) -> tuple[str, int] | None:
|
||||
"""Return a normalized (host, port) pair for an Origin/Host netloc."""
|
||||
if "," in netloc:
|
||||
return None
|
||||
try:
|
||||
parsed = urlparse(f"//{netloc.strip()}")
|
||||
authority = parsed.netloc.rsplit("@", 1)[-1]
|
||||
if (
|
||||
not parsed.hostname
|
||||
or parsed.username is not None
|
||||
or parsed.password is not None
|
||||
or authority.endswith(":")
|
||||
or parsed.path
|
||||
or parsed.params
|
||||
or parsed.query
|
||||
or parsed.fragment
|
||||
):
|
||||
return None
|
||||
return (parsed.hostname.lower(), parsed.port if parsed.port is not None else default_port)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _is_loopback_host(hostname: str) -> bool:
|
||||
"""Return True for localhost and loopback IP literals."""
|
||||
hostname = hostname.strip("[]").rstrip(".").lower()
|
||||
if hostname == "localhost":
|
||||
return True
|
||||
try:
|
||||
return ipaddress.ip_address(hostname).is_loopback
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def _origin_is_allowed(
|
||||
origin: str | None,
|
||||
host: str | None,
|
||||
request_scheme: str = "http",
|
||||
) -> bool:
|
||||
"""Return True when a WebSocket Origin is safe to proxy to local CDP."""
|
||||
if origin is None:
|
||||
# Playwright/Puppeteer and other non-browser CDP clients commonly omit
|
||||
# Origin. Keep those clients working while rejecting browser-origin CSRF.
|
||||
return True
|
||||
|
||||
origin = origin.strip()
|
||||
if not origin or origin.lower() == "null":
|
||||
return False
|
||||
if origin in TRUSTED_WS_ORIGINS:
|
||||
return True
|
||||
|
||||
try:
|
||||
parsed = urlparse(origin)
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
if parsed.scheme not in ("http", "https"):
|
||||
return False
|
||||
if parsed.path or parsed.params or parsed.query or parsed.fragment:
|
||||
return False
|
||||
|
||||
origin_default_port = 443 if parsed.scheme == "https" else 80
|
||||
request_scheme = request_scheme.split(",", 1)[0].strip().lower()
|
||||
request_default_port = 443 if request_scheme in ("https", "wss") else 80
|
||||
origin_host = _host_port_from_netloc(parsed.netloc, origin_default_port)
|
||||
request_host = _host_port_from_netloc(host or "", request_default_port)
|
||||
if origin_host is None or request_host is None:
|
||||
return False
|
||||
if not _is_loopback_host(request_host[0]):
|
||||
return False
|
||||
return origin_host == request_host
|
||||
|
||||
|
||||
def _reject_untrusted_origin(request: web.Request) -> web.Response | None:
|
||||
"""Reject browser-origin WebSocket upgrades that would expose local CDP."""
|
||||
origin = request.headers.get("Origin")
|
||||
host = request.headers.get("Host")
|
||||
scheme = request.headers.get("X-Forwarded-Proto", getattr(request, "scheme", "http"))
|
||||
if _origin_is_allowed(origin, host, request_scheme=scheme):
|
||||
return None
|
||||
logger.warning("Rejected CDP WebSocket from untrusted Origin %r for Host %r", origin, host)
|
||||
return web.Response(status=403, text="Forbidden: untrusted WebSocket origin\n")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ChromeProcess — one running Chrome instance
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@dataclass
|
||||
class ChromeProcess:
|
||||
seed: str
|
||||
process: subprocess.Popen
|
||||
cdp_port: int
|
||||
user_data_dir: str
|
||||
timezone: str | None = None
|
||||
locale: str | None = None
|
||||
proxy: str | None = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ChromePool — manages multiple Chrome processes keyed by seed
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class ChromePool:
|
||||
def __init__(
|
||||
self,
|
||||
binary: str,
|
||||
global_args: list[str],
|
||||
headless: bool,
|
||||
data_dir: str = "/tmp/cloakserve",
|
||||
default_seed: str | None = None,
|
||||
default_locale: str | None = None,
|
||||
default_timezone: str | None = None,
|
||||
idle_timeout: float = 0.0,
|
||||
):
|
||||
self._binary = binary
|
||||
self._global_args = global_args
|
||||
self._headless = headless
|
||||
self._data_dir = data_dir
|
||||
self._default_seed = default_seed
|
||||
self._default_locale = default_locale
|
||||
self._default_timezone = default_timezone
|
||||
self._idle_timeout = idle_timeout
|
||||
self._processes: dict[str, ChromeProcess] = {}
|
||||
self._default: ChromeProcess | None = None
|
||||
self._locks: dict[str, asyncio.Lock] = {}
|
||||
self._next_port = BASE_CDP_PORT
|
||||
# Connection refcounting for status reporting
|
||||
self._connections: dict[str, int] = {}
|
||||
self._idle_tasks: dict[str, asyncio.Task] = {}
|
||||
|
||||
def _get_lock(self, seed: str) -> asyncio.Lock:
|
||||
if seed not in self._locks:
|
||||
self._locks[seed] = asyncio.Lock()
|
||||
return self._locks[seed]
|
||||
|
||||
def _safe_rmtree(self, path: str) -> None:
|
||||
resolved = Path(path).resolve()
|
||||
data_resolved = Path(self._data_dir).resolve()
|
||||
if resolved == data_resolved or not resolved.is_relative_to(data_resolved):
|
||||
logger.error("Refusing to delete path outside data_dir: %s", resolved)
|
||||
return
|
||||
shutil.rmtree(path, True)
|
||||
|
||||
def _allocate_port(self) -> int:
|
||||
"""Find a free port starting from _next_port."""
|
||||
for _ in range(100):
|
||||
port = self._next_port
|
||||
self._next_port += 1
|
||||
try:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||
s.bind(("127.0.0.1", port))
|
||||
return port
|
||||
except OSError:
|
||||
continue
|
||||
raise RuntimeError("No free ports available for Chrome CDP")
|
||||
|
||||
def connect(self, seed_key: str) -> None:
|
||||
"""Increment connection refcount for a seed."""
|
||||
self._cancel_idle_cleanup(seed_key)
|
||||
self._connections[seed_key] = self._connections.get(seed_key, 0) + 1
|
||||
|
||||
def disconnect(self, seed_key: str) -> None:
|
||||
"""Decrement connection refcount for a seed."""
|
||||
count = self._connections.get(seed_key, 0) - 1
|
||||
if count <= 0:
|
||||
self._connections.pop(seed_key, None)
|
||||
self._schedule_idle_cleanup(seed_key)
|
||||
else:
|
||||
self._connections[seed_key] = count
|
||||
|
||||
def _cancel_idle_cleanup(self, seed_key: str) -> None:
|
||||
task = self._idle_tasks.pop(seed_key, None)
|
||||
if task is None or task.done():
|
||||
return
|
||||
try:
|
||||
current_task = asyncio.current_task()
|
||||
except RuntimeError:
|
||||
current_task = None
|
||||
if task is not current_task:
|
||||
task.cancel()
|
||||
|
||||
def _discard_idle_task(self, seed_key: str, task: asyncio.Task) -> None:
|
||||
if self._idle_tasks.get(seed_key) is task:
|
||||
self._idle_tasks.pop(seed_key, None)
|
||||
|
||||
def _schedule_idle_cleanup(self, seed_key: str) -> None:
|
||||
if self._idle_timeout <= 0 or seed_key not in self._processes:
|
||||
return
|
||||
|
||||
self._cancel_idle_cleanup(seed_key)
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
return
|
||||
|
||||
task = loop.create_task(
|
||||
self._cleanup_after_idle(seed_key, self._idle_timeout),
|
||||
name=f"cloakserve-idle-cleanup-{seed_key}",
|
||||
)
|
||||
self._idle_tasks[seed_key] = task
|
||||
task.add_done_callback(lambda done_task: self._discard_idle_task(seed_key, done_task))
|
||||
|
||||
async def _cleanup_after_idle(self, seed_key: str, timeout: float) -> None:
|
||||
try:
|
||||
await asyncio.sleep(timeout)
|
||||
if self._connections.get(seed_key, 0) > 0 or seed_key not in self._processes:
|
||||
return
|
||||
logger.info("Cleaning up idle Chrome process (seed=%s)", seed_key)
|
||||
await self._cleanup_process(seed_key)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception:
|
||||
logger.exception("Idle cleanup failed for seed=%s", seed_key)
|
||||
|
||||
async def get_or_launch(
|
||||
self,
|
||||
seed: str | None,
|
||||
extra_args: list[str] | None = None,
|
||||
timezone: str | None = None,
|
||||
locale: str | None = None,
|
||||
proxy: str | None = None,
|
||||
geoip: bool = False,
|
||||
) -> ChromeProcess:
|
||||
"""Get existing or launch new Chrome process for a seed."""
|
||||
# Apply CLI defaults when query params don't provide values
|
||||
if seed is None and self._default_seed:
|
||||
seed = self._default_seed
|
||||
if locale is None:
|
||||
locale = self._default_locale
|
||||
if timezone is None:
|
||||
timezone = self._default_timezone
|
||||
|
||||
# No seed = default shared process
|
||||
if seed is None:
|
||||
seed_key = "__default__"
|
||||
actual_seed = str(random.randint(10000, 99999))
|
||||
else:
|
||||
if not SAFE_SEED_RE.match(seed) or seed in RESERVED_SEEDS:
|
||||
raise web.HTTPBadRequest(
|
||||
text=json.dumps({"error": "Invalid fingerprint seed"}),
|
||||
content_type="application/json",
|
||||
)
|
||||
seed_key = seed
|
||||
actual_seed = seed
|
||||
|
||||
lock = self._get_lock(seed_key)
|
||||
async with lock:
|
||||
# Check if already running (including default fast-path)
|
||||
if seed_key in self._processes:
|
||||
proc = self._processes[seed_key]
|
||||
if proc.process.poll() is None:
|
||||
if seed_key in self._idle_tasks:
|
||||
self._schedule_idle_cleanup(seed_key)
|
||||
if any([extra_args, timezone, locale, proxy, geoip]):
|
||||
logger.warning(
|
||||
"Seed %s already running (port %d, tz=%s, locale=%s, proxy=%s) — "
|
||||
"ignoring new params (first-launch wins)",
|
||||
seed_key, proc.cdp_port,
|
||||
proc.timezone, proc.locale, proc.proxy,
|
||||
)
|
||||
return proc
|
||||
# Dead — clean up
|
||||
await self._cleanup_process(seed_key)
|
||||
|
||||
# Resolve geoip if requested
|
||||
exit_ip = None
|
||||
if geoip and proxy:
|
||||
timezone, locale, exit_ip = maybe_resolve_geoip(True, proxy, timezone, locale)
|
||||
|
||||
# Build Chrome args via shared logic
|
||||
fp_extra = [f"--fingerprint={actual_seed}"]
|
||||
if extra_args:
|
||||
fp_extra.extend(extra_args)
|
||||
if proxy:
|
||||
fp_extra.append(f"--proxy-server={_normalize_socks_string_url(proxy)}")
|
||||
|
||||
# WebRTC IP spoofing: resolve auto, inject geoip exit IP
|
||||
fp_extra = _resolve_webrtc_args(fp_extra, proxy)
|
||||
if exit_ip and not any(a.startswith("--fingerprint-webrtc-ip") for a in (fp_extra or [])):
|
||||
fp_extra = list(fp_extra or [])
|
||||
fp_extra.append(f"--fingerprint-webrtc-ip={exit_ip}")
|
||||
|
||||
chrome_args = build_args(
|
||||
stealth_args=True,
|
||||
extra_args=fp_extra,
|
||||
timezone=timezone,
|
||||
locale=locale,
|
||||
headless=self._headless,
|
||||
)
|
||||
|
||||
# Allocate port and user data dir
|
||||
port = self._allocate_port()
|
||||
user_data_dir = os.path.join(self._data_dir, seed_key)
|
||||
os.makedirs(user_data_dir, exist_ok=True)
|
||||
|
||||
full_args = (
|
||||
[self._binary]
|
||||
+ BASE_CHROME_ARGS
|
||||
+ chrome_args
|
||||
+ self._global_args
|
||||
+ [
|
||||
f"--remote-debugging-port={port}",
|
||||
"--remote-debugging-address=127.0.0.1",
|
||||
f"--user-data-dir={user_data_dir}",
|
||||
]
|
||||
)
|
||||
|
||||
logger.info("Launching Chrome (seed=%s, port=%d)", actual_seed, port)
|
||||
process = subprocess.Popen(
|
||||
full_args,
|
||||
stdout=subprocess.DEVNULL,
|
||||
)
|
||||
|
||||
# Wait for CDP to be ready
|
||||
if not await self._wait_for_cdp(port):
|
||||
process.kill()
|
||||
await asyncio.to_thread(process.wait, timeout=5)
|
||||
await asyncio.to_thread(self._safe_rmtree, user_data_dir)
|
||||
raise web.HTTPBadGateway(
|
||||
text=json.dumps({"error": "Chrome failed to start"}),
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
cp = ChromeProcess(
|
||||
seed=actual_seed,
|
||||
process=process,
|
||||
cdp_port=port,
|
||||
user_data_dir=user_data_dir,
|
||||
timezone=timezone,
|
||||
locale=locale,
|
||||
proxy=proxy,
|
||||
)
|
||||
self._processes[seed_key] = cp
|
||||
|
||||
if seed is None:
|
||||
self._default = cp
|
||||
|
||||
logger.info("Chrome ready (seed=%s, port=%d, pid=%d)", actual_seed, port, process.pid)
|
||||
return cp
|
||||
|
||||
async def _cleanup_process(self, key: str) -> None:
|
||||
"""Terminate a Chrome process and clean up."""
|
||||
self._cancel_idle_cleanup(key)
|
||||
proc = self._processes.pop(key, None)
|
||||
if not proc:
|
||||
return
|
||||
if proc.process.poll() is None:
|
||||
proc.process.terminate()
|
||||
try:
|
||||
await asyncio.to_thread(proc.process.wait, timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.process.kill()
|
||||
await asyncio.to_thread(self._safe_rmtree, proc.user_data_dir)
|
||||
if self._default is proc:
|
||||
self._default = None
|
||||
self._locks.pop(key, None)
|
||||
self._connections.pop(key, None)
|
||||
|
||||
async def shutdown(self) -> None:
|
||||
"""Terminate all Chrome processes."""
|
||||
idle_tasks = list(self._idle_tasks.values())
|
||||
self._idle_tasks.clear()
|
||||
for task in idle_tasks:
|
||||
if not task.done():
|
||||
task.cancel()
|
||||
if idle_tasks:
|
||||
await asyncio.gather(*idle_tasks, return_exceptions=True)
|
||||
for key in list(self._processes.keys()):
|
||||
await self._cleanup_process(key)
|
||||
logger.info("All Chrome processes terminated")
|
||||
|
||||
@staticmethod
|
||||
async def _wait_for_cdp(port: int, timeout: float = 10.0) -> bool:
|
||||
"""Poll Chrome's /json/version until ready."""
|
||||
deadline = time.monotonic() + timeout
|
||||
delay = 0.1
|
||||
session = aiohttp.ClientSession(
|
||||
timeout=aiohttp.ClientTimeout(total=1)
|
||||
)
|
||||
try:
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
async with session.get(
|
||||
f"http://127.0.0.1:{port}/json/version"
|
||||
) as resp:
|
||||
if resp.status == 200:
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
await asyncio.sleep(delay)
|
||||
delay = min(delay * 2, 1.0)
|
||||
return False
|
||||
finally:
|
||||
await session.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Query param parsing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Params that need special handling (not simple --fingerprint-{name}= mapping)
|
||||
SPECIAL_PARAMS = {"fingerprint", "proxy", "geoip", "locale", "timezone"}
|
||||
|
||||
|
||||
def parse_connection_params(query_string: str) -> dict:
|
||||
"""Parse query params into connection config."""
|
||||
qs = parse_qs(query_string, keep_blank_values=False)
|
||||
|
||||
result: dict = {
|
||||
"seed": None,
|
||||
"timezone": None,
|
||||
"locale": None,
|
||||
"proxy": None,
|
||||
"geoip": False,
|
||||
"extra_args": [],
|
||||
}
|
||||
|
||||
for key, values in qs.items():
|
||||
val = values[0]
|
||||
if key == "fingerprint":
|
||||
result["seed"] = val
|
||||
elif key == "timezone":
|
||||
result["timezone"] = val
|
||||
elif key == "locale":
|
||||
result["locale"] = val
|
||||
elif key == "proxy":
|
||||
result["proxy"] = val
|
||||
elif key == "geoip":
|
||||
result["geoip"] = val.lower() in ("true", "1", "yes")
|
||||
elif key not in SPECIAL_PARAMS:
|
||||
# Generic fingerprint param: map to --fingerprint-{key}={val}
|
||||
result["extra_args"].append(f"--fingerprint-{key}={val}")
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HTTP handlers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _ws_scheme(request: web.Request) -> str:
|
||||
"""Return 'wss' if client connected via HTTPS (e.g. TLS-terminating proxy), else 'ws'."""
|
||||
proto = request.headers.get("X-Forwarded-Proto", request.scheme)
|
||||
proto = proto.split(",", 1)[0].strip().lower()
|
||||
return "wss" if proto == "https" else "ws"
|
||||
|
||||
|
||||
def _external_host(request: web.Request) -> str:
|
||||
"""Return the public host to use in rewritten CDP WebSocket URLs."""
|
||||
fallback_host = request.headers.get("Host") or f"localhost:{request.app['port']}"
|
||||
forwarded_host = request.headers.get("X-Forwarded-Host")
|
||||
if forwarded_host:
|
||||
public_host = forwarded_host.split(",", 1)[0].strip()
|
||||
if public_host:
|
||||
return public_host
|
||||
return fallback_host
|
||||
|
||||
|
||||
async def handle_root(request: web.Request) -> web.Response:
|
||||
"""Health check / process status."""
|
||||
pool: ChromePool = request.app["pool"]
|
||||
processes = {}
|
||||
for key, proc in pool._processes.items():
|
||||
if proc.process.poll() is None:
|
||||
processes[key] = {
|
||||
"pid": proc.process.pid,
|
||||
"port": proc.cdp_port,
|
||||
"seed": proc.seed,
|
||||
"connections": pool._connections.get(key, 0),
|
||||
"idle_cleanup_pending": key in pool._idle_tasks,
|
||||
"timezone": proc.timezone,
|
||||
"locale": proc.locale,
|
||||
"proxy": proc.proxy,
|
||||
}
|
||||
return web.json_response({
|
||||
"status": "ok",
|
||||
"active": len(processes),
|
||||
"idle_timeout": pool._idle_timeout,
|
||||
"processes": processes,
|
||||
})
|
||||
|
||||
|
||||
async def handle_json_version(request: web.Request) -> web.Response:
|
||||
"""Proxy /json/version with optional per-seed routing."""
|
||||
pool: ChromePool = request.app["pool"]
|
||||
params = parse_connection_params(request.query_string)
|
||||
|
||||
cp = await pool.get_or_launch(
|
||||
seed=params["seed"],
|
||||
extra_args=params["extra_args"] or None,
|
||||
timezone=params["timezone"],
|
||||
locale=params["locale"],
|
||||
proxy=params["proxy"],
|
||||
geoip=params["geoip"],
|
||||
)
|
||||
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(
|
||||
f"http://127.0.0.1:{cp.cdp_port}/json/version",
|
||||
timeout=aiohttp.ClientTimeout(total=5),
|
||||
) as resp:
|
||||
data = await resp.json()
|
||||
except Exception as exc:
|
||||
logger.error("Failed to reach Chrome CDP (port %d): %s", cp.cdp_port, exc)
|
||||
return web.json_response({"error": "CDP endpoint unreachable"}, status=502)
|
||||
|
||||
# Rewrite webSocketDebuggerUrl to route through our multiplexer
|
||||
host = _external_host(request)
|
||||
seed_key = params["seed"]
|
||||
if seed_key:
|
||||
ws_path = f"fingerprint/{seed_key}/devtools/browser"
|
||||
else:
|
||||
ws_path = "devtools/browser"
|
||||
|
||||
# Extract the browser GUID from Chrome's original URL
|
||||
orig_ws = data.get("webSocketDebuggerUrl", "")
|
||||
guid = orig_ws.rsplit("/", 1)[-1] if "/devtools/" in orig_ws else ""
|
||||
|
||||
scheme = _ws_scheme(request)
|
||||
data["webSocketDebuggerUrl"] = f"{scheme}://{host}/{ws_path}/{guid}"
|
||||
return web.json_response(data)
|
||||
|
||||
|
||||
async def handle_json_list(request: web.Request) -> web.Response:
|
||||
"""Proxy /json/list with per-seed routing. Rewrites all entries."""
|
||||
pool: ChromePool = request.app["pool"]
|
||||
params = parse_connection_params(request.query_string)
|
||||
|
||||
cp = await pool.get_or_launch(
|
||||
seed=params["seed"],
|
||||
extra_args=params["extra_args"] or None,
|
||||
timezone=params["timezone"],
|
||||
locale=params["locale"],
|
||||
proxy=params["proxy"],
|
||||
geoip=params["geoip"],
|
||||
)
|
||||
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(
|
||||
f"http://127.0.0.1:{cp.cdp_port}/json/list",
|
||||
timeout=aiohttp.ClientTimeout(total=5),
|
||||
) as resp:
|
||||
data = await resp.json()
|
||||
except Exception as exc:
|
||||
logger.error("Failed to reach Chrome CDP (port %d): %s", cp.cdp_port, exc)
|
||||
return web.json_response({"error": "CDP endpoint unreachable"}, status=502)
|
||||
|
||||
host = _external_host(request)
|
||||
scheme = _ws_scheme(request)
|
||||
seed_key = params["seed"]
|
||||
|
||||
for entry in data:
|
||||
if "webSocketDebuggerUrl" in entry:
|
||||
ws_tail = entry["webSocketDebuggerUrl"].split("/devtools/")[-1]
|
||||
if seed_key:
|
||||
entry["webSocketDebuggerUrl"] = (
|
||||
f"{scheme}://{host}/fingerprint/{seed_key}/devtools/{ws_tail}"
|
||||
)
|
||||
else:
|
||||
entry["webSocketDebuggerUrl"] = f"{scheme}://{host}/devtools/{ws_tail}"
|
||||
|
||||
return web.json_response(data)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# WebSocket proxy
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def proxy_cdp_websocket(
|
||||
client_ws: web.WebSocketResponse,
|
||||
target_url: str,
|
||||
label: str,
|
||||
) -> None:
|
||||
"""Bidirectional WebSocket proxy between client and Chrome CDP."""
|
||||
try:
|
||||
async with websockets.connect(
|
||||
target_url, max_size=None, ping_interval=None, ping_timeout=None,
|
||||
) as cdp_ws:
|
||||
logger.info("%s: connected to %s", label, target_url)
|
||||
|
||||
async def client_to_cdp():
|
||||
try:
|
||||
async for msg in client_ws:
|
||||
if msg.type == aiohttp.WSMsgType.TEXT:
|
||||
await cdp_ws.send(msg.data)
|
||||
elif msg.type == aiohttp.WSMsgType.BINARY:
|
||||
await cdp_ws.send(msg.data)
|
||||
elif msg.type in (aiohttp.WSMsgType.CLOSE, aiohttp.WSMsgType.CLOSING, aiohttp.WSMsgType.CLOSED):
|
||||
break
|
||||
except Exception as exc:
|
||||
logger.debug("%s [c->cdp]: %s", label, exc)
|
||||
|
||||
async def cdp_to_client():
|
||||
try:
|
||||
async for msg in cdp_ws:
|
||||
if isinstance(msg, str):
|
||||
await client_ws.send_str(msg)
|
||||
else:
|
||||
await client_ws.send_bytes(msg)
|
||||
except Exception as exc:
|
||||
logger.debug("%s [cdp->c]: %s", label, exc)
|
||||
|
||||
c2d = asyncio.create_task(client_to_cdp(), name="c2d")
|
||||
d2c = asyncio.create_task(cdp_to_client(), name="d2c")
|
||||
done, pending = await asyncio.wait(
|
||||
[c2d, d2c], return_when=asyncio.FIRST_COMPLETED,
|
||||
)
|
||||
for task in pending:
|
||||
task.cancel()
|
||||
logger.info("%s: disconnected", label)
|
||||
|
||||
except Exception as exc:
|
||||
logger.error("%s error: %s", label, exc)
|
||||
|
||||
|
||||
async def handle_ws_default(request: web.Request) -> web.StreamResponse:
|
||||
"""WebSocket proxy for default (no-seed) Chrome: /devtools/{type}/{guid}"""
|
||||
rejected = _reject_untrusted_origin(request)
|
||||
if rejected is not None:
|
||||
return rejected
|
||||
|
||||
pool: ChromePool = request.app["pool"]
|
||||
path = request.match_info.get("path", "")
|
||||
|
||||
cp = await pool.get_or_launch(seed=None)
|
||||
|
||||
ws = web.WebSocketResponse()
|
||||
await ws.prepare(request)
|
||||
|
||||
pool.connect("__default__")
|
||||
try:
|
||||
target_url = f"ws://127.0.0.1:{cp.cdp_port}/devtools/{path}"
|
||||
await proxy_cdp_websocket(ws, target_url, f"CDP default [{path}]")
|
||||
finally:
|
||||
pool.disconnect("__default__")
|
||||
return ws
|
||||
|
||||
|
||||
async def handle_ws_seed(request: web.Request) -> web.StreamResponse:
|
||||
"""WebSocket proxy for seed-specific Chrome: /fingerprint/{seed}/devtools/{type}/{guid}"""
|
||||
rejected = _reject_untrusted_origin(request)
|
||||
if rejected is not None:
|
||||
return rejected
|
||||
|
||||
pool: ChromePool = request.app["pool"]
|
||||
seed = request.match_info["seed"]
|
||||
path = request.match_info.get("path", "")
|
||||
|
||||
cp = await pool.get_or_launch(seed=seed)
|
||||
|
||||
ws = web.WebSocketResponse()
|
||||
await ws.prepare(request)
|
||||
|
||||
pool.connect(seed)
|
||||
try:
|
||||
target_url = f"ws://127.0.0.1:{cp.cdp_port}/devtools/{path}"
|
||||
await proxy_cdp_websocket(ws, target_url, f"CDP seed={seed} [{path}]")
|
||||
finally:
|
||||
pool.disconnect(seed)
|
||||
return ws
|
||||
|
||||
|
||||
async def on_shutdown(app: web.Application) -> None:
|
||||
await app["pool"].shutdown()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI arg parsing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _default_data_dir() -> str:
|
||||
"""Smart default: container → /tmp/cloakserve, bare metal → ~/.cloakbrowser/cloakserve."""
|
||||
if os.path.exists("/.dockerenv") or os.path.exists("/run/.containerenv"):
|
||||
return "/tmp/cloakserve"
|
||||
return str(Path.home() / ".cloakbrowser" / "cloakserve")
|
||||
|
||||
|
||||
def _parse_idle_timeout(value: str) -> float:
|
||||
value = value.strip()
|
||||
if value.lower() in {"0", "false", "off", "none", "disabled"}:
|
||||
return 0.0
|
||||
timeout = float(value)
|
||||
if timeout < 0:
|
||||
raise ValueError("--idle-timeout must be greater than or equal to 0")
|
||||
return timeout
|
||||
|
||||
|
||||
def _default_idle_timeout() -> float:
|
||||
value = os.environ.get("CLOAKSERVE_IDLE_TIMEOUT")
|
||||
if value is None:
|
||||
return 0.0
|
||||
return _parse_idle_timeout(value)
|
||||
|
||||
|
||||
def parse_cli_args(argv: list[str]) -> tuple[dict, list[str]]:
|
||||
"""Parse cloakserve-specific args, return (config, passthrough_args).
|
||||
|
||||
--fingerprint, --fingerprint-locale, and --fingerprint-timezone are
|
||||
extracted into config defaults so they route through build_args()
|
||||
(e.g. locale needs both --lang and --fingerprint-locale).
|
||||
Query-string params override these defaults per-connection.
|
||||
"""
|
||||
config: dict = {
|
||||
"port": 9222,
|
||||
"headless": True,
|
||||
"data_dir": None,
|
||||
"default_seed": None,
|
||||
"default_locale": None,
|
||||
"default_timezone": None,
|
||||
"idle_timeout": _default_idle_timeout(),
|
||||
}
|
||||
passthrough = []
|
||||
# Flags consumed by cloakserve (not passed to Chrome)
|
||||
consumed_prefixes = (
|
||||
"--port=",
|
||||
"--data-dir=",
|
||||
"--idle-timeout=",
|
||||
"--remote-debugging-port=",
|
||||
"--remote-debugging-address=",
|
||||
)
|
||||
|
||||
for arg in argv:
|
||||
if arg.startswith("--port="):
|
||||
config["port"] = int(arg.split("=", 1)[1])
|
||||
elif arg.startswith("--data-dir="):
|
||||
config["data_dir"] = arg.split("=", 1)[1]
|
||||
elif arg.startswith("--idle-timeout="):
|
||||
config["idle_timeout"] = _parse_idle_timeout(arg.split("=", 1)[1])
|
||||
elif arg == "--headless=false" or arg == "--headless=False":
|
||||
config["headless"] = False
|
||||
passthrough.append(arg)
|
||||
elif arg.startswith(consumed_prefixes):
|
||||
pass # Strip these silently
|
||||
# Route through build_args() so companion flags are set correctly
|
||||
elif arg.startswith("--fingerprint-locale="):
|
||||
config["default_locale"] = arg.split("=", 1)[1]
|
||||
elif arg.startswith("--fingerprint-timezone="):
|
||||
config["default_timezone"] = arg.split("=", 1)[1]
|
||||
elif arg.startswith("--fingerprint="):
|
||||
config["default_seed"] = arg.split("=", 1)[1]
|
||||
else:
|
||||
passthrough.append(arg)
|
||||
|
||||
if config["data_dir"] is None:
|
||||
config["data_dir"] = _default_data_dir()
|
||||
|
||||
return config, passthrough
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def main() -> None:
|
||||
binary = ensure_binary()
|
||||
config, global_args = parse_cli_args(sys.argv[1:])
|
||||
|
||||
if config["default_seed"] and (
|
||||
not SAFE_SEED_RE.match(config["default_seed"])
|
||||
or config["default_seed"] in RESERVED_SEEDS
|
||||
):
|
||||
logger.error("Invalid --fingerprint seed: %s", config["default_seed"])
|
||||
sys.exit(1)
|
||||
|
||||
pool = ChromePool(
|
||||
binary=binary,
|
||||
global_args=global_args,
|
||||
headless=config["headless"],
|
||||
data_dir=config["data_dir"],
|
||||
default_seed=config["default_seed"],
|
||||
default_locale=config["default_locale"],
|
||||
default_timezone=config["default_timezone"],
|
||||
idle_timeout=config["idle_timeout"],
|
||||
)
|
||||
|
||||
app = web.Application()
|
||||
app["pool"] = pool
|
||||
app["port"] = config["port"]
|
||||
|
||||
# Routes
|
||||
app.router.add_get("/", handle_root)
|
||||
app.router.add_get("/json/version", handle_json_version)
|
||||
app.router.add_get("/json/version/", handle_json_version)
|
||||
app.router.add_get("/json/list", handle_json_list)
|
||||
app.router.add_get("/json/list/", handle_json_list)
|
||||
app.router.add_get("/json", handle_json_list)
|
||||
app.router.add_get("/json/", handle_json_list)
|
||||
|
||||
# WebSocket routes — seed-specific (must be before default to match first)
|
||||
app.router.add_get("/fingerprint/{seed}/devtools/{path:.+}", handle_ws_seed)
|
||||
# WebSocket routes — default (no seed)
|
||||
app.router.add_get("/devtools/{path:.+}", handle_ws_default)
|
||||
|
||||
app.on_shutdown.append(on_shutdown)
|
||||
|
||||
port = config["port"]
|
||||
logger.info("CloakBrowser CDP multiplexer starting on port %d", port)
|
||||
logger.info(
|
||||
"Connect: playwright.chromium.connect_over_cdp("
|
||||
"\"http://localhost:%d?fingerprint=<seed>\")",
|
||||
port,
|
||||
)
|
||||
|
||||
in_container = os.path.exists("/.dockerenv") or os.path.exists("/run/.containerenv")
|
||||
host = "0.0.0.0" if in_container else "127.0.0.1"
|
||||
web.run_app(app, host=host, port=port, print=None)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+3
@@ -0,0 +1,3 @@
|
||||
#!/bin/bash
|
||||
# Run CloakBrowser stealth test suite
|
||||
exec python -u /app/examples/stealth_test.py --no-screenshots "$@"
|
||||
@@ -0,0 +1,51 @@
|
||||
#!/bin/bash
|
||||
# Clean up any stale Xvfb lock left behind by a previous container instance.
|
||||
# `/tmp` is not a tmpfs in this image, so on `docker restart` the previous
|
||||
# container's `/tmp/.X99-lock` survives, and Xvfb refuses to start with an
|
||||
# existing lock — leaving the container with no X server, every Chrome
|
||||
# launch dying with "Missing X server or $DISPLAY", and `cloakserve`
|
||||
# returning 502 forever. See CloakHQ/CloakBrowser#283.
|
||||
rm -f /tmp/.X99-lock /tmp/.X11-unix/X99
|
||||
|
||||
# Start Xvfb for headed mode (Turnstile, CAPTCHAs), then run user command
|
||||
Xvfb :99 -screen 0 1920x1080x24 -nolisten tcp &
|
||||
|
||||
# Wait for the X server to actually accept connections before starting the WM.
|
||||
# A blind `sleep 1` races under a CPU-starved start: openbox can come up before
|
||||
# X is ready, fail to connect, and never retry — leaving --start-maximized a
|
||||
# silent no-op for the container's whole life. Poll instead (xdotool is already
|
||||
# installed and needs a live X server to answer). Bounded to ~10s.
|
||||
for _ in $(seq 1 50); do
|
||||
DISPLAY=:99 xdotool getdisplaygeometry >/dev/null 2>&1 && break
|
||||
sleep 0.2
|
||||
done
|
||||
|
||||
# Window manager so headed --start-maximized is honored (bare Xvfb has no WM;
|
||||
# without one the flag is a silent no-op and the window stays un-maximized).
|
||||
DISPLAY=:99 openbox &
|
||||
|
||||
# Opt-in: fetch the Widevine CDM so persistent contexts present as a real
|
||||
# Chrome (a DRM/EME probe is used by some bot detectors). Off by default — only
|
||||
# runs when CLOAKBROWSER_FETCH_WIDEVINE is set, and never if the user already
|
||||
# pointed at a CDM or disabled seeding. The CDM is fetched per-container from
|
||||
# Google's component server (the same source Chrome uses), cached in the
|
||||
# ~/.cloakbrowser volume, and is best-effort: a failure must never block launch.
|
||||
_fetch_widevine="${CLOAKBROWSER_FETCH_WIDEVINE:-}"
|
||||
case "${_fetch_widevine,,}" in
|
||||
1|true|yes|on)
|
||||
# printf (not echo) so a value like `-n` isn't swallowed as a flag.
|
||||
if [ -z "${CLOAKBROWSER_WIDEVINE_CDM:-}" ] && \
|
||||
! printf '%s' "${CLOAKBROWSER_WIDEVINE:-}" | grep -qiE '^(0|false|off|no)$'; then
|
||||
# Fetch to the default location (the version-independent cache root,
|
||||
# ~/.cloakbrowser/WidevineCdm). The wrapper's auto-detection
|
||||
# (cloakbrowser/widevine.py, js/src/widevine.ts) falls back to this path
|
||||
# after the per-binary dir, so the CDM is discoverable by ANY process (CMD
|
||||
# or `docker exec`) and ANY binary (free or Pro, any version) with no env
|
||||
# var. Best-effort: a failure must never block launch.
|
||||
python /usr/local/bin/fetch-widevine.py --quiet \
|
||||
|| echo "[cloakbrowser] Widevine fetch failed; continuing without it" >&2
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
exec "$@"
|
||||
@@ -0,0 +1,301 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Fetch the Widevine CDM from Google's component-update server (Linux).
|
||||
|
||||
The CloakBrowser binary is built with Widevine support, but the CDM itself is a
|
||||
proprietary Google component we don't redistribute. This pulls it at runtime from
|
||||
the same component server Chrome uses, then drops it where the wrapper's
|
||||
``CLOAKBROWSER_WIDEVINE_CDM`` resolution (cloakbrowser/widevine.py) expects it:
|
||||
|
||||
<out>/manifest.json
|
||||
<out>/_platform_specific/linux_<arch>/libwidevinecdm.so
|
||||
|
||||
No curl/jq/unzip needed. Linux x86-64 only (Google doesn't publish the CDM for
|
||||
linux arm64). The Docker entrypoint runs this when CLOAKBROWSER_FETCH_WIDEVINE is
|
||||
set; bare-metal Linux users can run it directly.
|
||||
|
||||
Integrity: the download is checked against the server-provided SHA-256 (over TLS).
|
||||
When `cryptography` is importable (it is in any pip/Docker install of cloakbrowser),
|
||||
the CRX3 publisher signature is additionally verified and bound to the expected
|
||||
Widevine app id — same trust root Chrome's component updater uses. Standalone runs
|
||||
without `cryptography` fall back to TLS + SHA-256.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import shutil
|
||||
import struct
|
||||
import sys
|
||||
import tempfile
|
||||
import urllib.request
|
||||
import zipfile
|
||||
|
||||
# Widevine CDM component id in Chromium's component updater.
|
||||
APP_ID = "oimompecagnajdejgnnjijobebaeigek"
|
||||
UPDATE_URL = "https://update.googleapis.com/service/update2/json"
|
||||
# Deliberately-low installed version so the server always reports an update.
|
||||
INSTALLED_VERSION = "1.4.9.1088"
|
||||
XSSI_PREFIX = ")]}'"
|
||||
|
||||
|
||||
def _arch():
|
||||
"""Map the host machine to the Widevine platform suffix (x86-64 only).
|
||||
|
||||
Google's component server publishes the Linux Widevine CDM for x86-64 only —
|
||||
arm64/aarch64 return no update (verified: the server either reports noupdate
|
||||
or hands back the x86-64 binary), so reject them with a clear message rather
|
||||
than letting the request reach the misleading "no update available" path.
|
||||
"""
|
||||
m = platform.machine().lower()
|
||||
if m in ("x86_64", "amd64", "x64"):
|
||||
return "x64"
|
||||
if m in ("aarch64", "arm64", "arm"):
|
||||
raise SystemExit("the Widevine CDM is not published for linux arm64 (x86-64 only)")
|
||||
raise SystemExit(f"unsupported architecture for Widevine: {platform.machine()!r}")
|
||||
|
||||
|
||||
def _read_varint(b, i):
|
||||
shift = result = 0
|
||||
while True:
|
||||
if i >= len(b):
|
||||
raise ValueError("truncated varint")
|
||||
byte = b[i]; i += 1
|
||||
result |= (byte & 0x7F) << shift
|
||||
if not byte & 0x80:
|
||||
return result, i
|
||||
shift += 7
|
||||
if shift > 63:
|
||||
raise ValueError("varint too long")
|
||||
|
||||
|
||||
def _parse_pb(b):
|
||||
"""Minimal protobuf reader → {field_num: [length-delimited bytes, ...]}."""
|
||||
out, i, n = {}, 0, len(b)
|
||||
while i < n:
|
||||
tag, i = _read_varint(b, i)
|
||||
field, wire = tag >> 3, tag & 7
|
||||
if wire == 2:
|
||||
ln, i = _read_varint(b, i)
|
||||
out.setdefault(field, []).append(b[i:i + ln]); i += ln
|
||||
elif wire == 0:
|
||||
_, i = _read_varint(b, i)
|
||||
elif wire == 1:
|
||||
i += 8
|
||||
elif wire == 5:
|
||||
i += 4
|
||||
else:
|
||||
raise ValueError(f"unsupported protobuf wire type {wire}")
|
||||
return out
|
||||
|
||||
|
||||
def _crx_appid(pubkey_der):
|
||||
"""CRX app id = first 16 bytes of SHA-256(pubkey), each nibble mapped a–p."""
|
||||
digest = hashlib.sha256(pubkey_der).digest()[:16]
|
||||
return "".join(chr(0x61 + (byte >> 4)) + chr(0x61 + (byte & 0xF)) for byte in digest), digest
|
||||
|
||||
|
||||
def _verify_crx3(crx_bytes):
|
||||
"""Verify the CRX3 RSA publisher signature and bind it to APP_ID.
|
||||
|
||||
Returns True if verified, False if `cryptography` is unavailable (caller then
|
||||
relies on TLS + the server SHA-256). Raises SystemExit on a real failure.
|
||||
We verify the RSASSA-PKCS1-v1_5 / SHA-256 proof (CRX3 field 2), which is what
|
||||
Google signs the Widevine component with; ECDSA proofs (field 3) are not
|
||||
relied on. The app id is derived from the signing key — the same trust root
|
||||
Chrome verifies — so a non-Widevine publisher key can't satisfy the check.
|
||||
"""
|
||||
try:
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import padding
|
||||
from cryptography.exceptions import InvalidSignature
|
||||
except ImportError:
|
||||
return False
|
||||
|
||||
if len(crx_bytes) < 12:
|
||||
raise SystemExit("not a CRX3 file (too short)")
|
||||
if crx_bytes[:4] != b"Cr24":
|
||||
raise SystemExit("not a CRX3 file (bad magic)")
|
||||
version = struct.unpack("<I", crx_bytes[4:8])[0]
|
||||
if version != 3:
|
||||
raise SystemExit(f"unexpected CRX version {version}")
|
||||
header_len = struct.unpack("<I", crx_bytes[8:12])[0]
|
||||
header = crx_bytes[12:12 + header_len]
|
||||
archive = crx_bytes[12 + header_len:]
|
||||
|
||||
fields = _parse_pb(header)
|
||||
signed_header = fields.get(10000, [b""])[0]
|
||||
# Signed payload: "CRX3 SignedData\x00" + uint32LE(len) + signed_header + archive
|
||||
payload = b"CRX3 SignedData\x00" + struct.pack("<I", len(signed_header)) + signed_header + archive
|
||||
declared_id = _parse_pb(signed_header).get(1, [b""])[0] # SignedData.crx_id
|
||||
|
||||
for proof in fields.get(2, []): # sha256_with_rsa proofs
|
||||
p = _parse_pb(proof)
|
||||
pub_der, sig = p.get(1, [None])[0], p.get(2, [None])[0]
|
||||
if not pub_der or not sig:
|
||||
continue
|
||||
appid, digest16 = _crx_appid(pub_der)
|
||||
if appid != APP_ID:
|
||||
continue # not the Widevine publisher key — ignore
|
||||
if declared_id and declared_id != digest16:
|
||||
raise SystemExit("CRX signed-header crx_id does not match the signing key")
|
||||
try:
|
||||
serialization.load_der_public_key(pub_der).verify(
|
||||
sig, payload, padding.PKCS1v15(), hashes.SHA256())
|
||||
except InvalidSignature:
|
||||
raise SystemExit("CRX3 publisher signature is INVALID")
|
||||
return True
|
||||
raise SystemExit("no CRX3 RSA proof from the expected Widevine publisher key")
|
||||
|
||||
|
||||
def _post_json(url, payload):
|
||||
data = json.dumps(payload).encode()
|
||||
req = urllib.request.Request(
|
||||
url, data=data,
|
||||
headers={"User-Agent": "Mozilla/5.0", "Content-Type": "application/json"},
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
body = resp.read().decode("utf-8", "replace")
|
||||
if body.startswith(XSSI_PREFIX):
|
||||
body = body[len(XSSI_PREFIX):]
|
||||
return json.loads(body)
|
||||
|
||||
|
||||
def _resolve_crx(arch):
|
||||
"""Query the component server; return (version, crx_url, sha256_hex)."""
|
||||
payload = {"request": {
|
||||
"@os": "", "@updater": "",
|
||||
"acceptformat": "crx3,download,puff,run,xz,zucc",
|
||||
"apps": [{"appid": APP_ID, "installsource": "ondemand",
|
||||
"updatecheck": {}, "version": INSTALLED_VERSION}],
|
||||
"dedup": "cr", "ismachine": False, "arch": arch,
|
||||
"os": {"arch": arch, "platform": "linux"},
|
||||
"protocol": "4.0", "updaterversion": "142.0.7444.175",
|
||||
}}
|
||||
resp = _post_json(UPDATE_URL, payload)
|
||||
uc = resp["response"]["apps"][0]["updatecheck"]
|
||||
status = uc.get("status")
|
||||
if status and status != "ok":
|
||||
raise SystemExit(f"component server returned status={status!r} (no update available)")
|
||||
version = uc.get("nextversion", "?")
|
||||
# Find the first operation that carries download URLs + its sha256.
|
||||
for pipeline in uc.get("pipelines", []):
|
||||
for op in pipeline.get("operations", []):
|
||||
urls = [u["url"] for u in op.get("urls", []) if u.get("url", "").startswith("https")]
|
||||
if urls:
|
||||
sha = (op.get("out") or {}).get("sha256")
|
||||
return version, urls[0], sha
|
||||
raise SystemExit("no CRX download URL in component server response")
|
||||
|
||||
|
||||
def _download(url, sha256_hex):
|
||||
with urllib.request.urlopen(url, timeout=120) as resp:
|
||||
blob = resp.read()
|
||||
# Integrity: server-provided SHA-256 over TLS (always). The CRX3 publisher
|
||||
# signature is additionally verified in main() when `cryptography` is present.
|
||||
if sha256_hex:
|
||||
got = hashlib.sha256(blob).hexdigest()
|
||||
if got.lower() != sha256_hex.lower():
|
||||
raise SystemExit(f"sha256 mismatch: expected {sha256_hex}, got {got}")
|
||||
return blob
|
||||
|
||||
|
||||
def _extract(crx_bytes, arch, out_dir):
|
||||
"""Extract manifest.json + the .so into out_dir, replacing any prior copy.
|
||||
|
||||
Staged in a temp dir then renamed into place — the rename is atomic, but the
|
||||
rmtree of an existing out_dir that precedes it is not, so this is not safe
|
||||
against another process writing the same out_dir concurrently.
|
||||
"""
|
||||
so_member = f"_platform_specific/linux_{arch}/libwidevinecdm.so"
|
||||
# zipfile locates the central directory from the end, so a CRX3 (header+zip)
|
||||
# opens directly without stripping the prefix.
|
||||
with zipfile.ZipFile(io.BytesIO(crx_bytes)) as zf:
|
||||
names = set(zf.namelist())
|
||||
if "manifest.json" not in names or so_member not in names:
|
||||
raise SystemExit(f"CRX missing expected members (manifest.json / {so_member})")
|
||||
parent = os.path.dirname(os.path.abspath(out_dir)) or "."
|
||||
os.makedirs(parent, exist_ok=True)
|
||||
tmp = tempfile.mkdtemp(prefix=".widevine.tmp.", dir=parent)
|
||||
try:
|
||||
zf.extract("manifest.json", tmp)
|
||||
zf.extract(so_member, tmp)
|
||||
os.chmod(os.path.join(tmp, so_member), 0o644)
|
||||
# Swap into place. The rename is atomic; the preceding rmtree is not.
|
||||
if os.path.exists(out_dir):
|
||||
shutil.rmtree(out_dir)
|
||||
os.rename(tmp, out_dir)
|
||||
except BaseException:
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
raise
|
||||
|
||||
|
||||
def _default_out():
|
||||
cache = os.environ.get("CLOAKBROWSER_CACHE_DIR") or os.path.join(os.path.expanduser("~"), ".cloakbrowser")
|
||||
return os.path.join(cache, "WidevineCdm")
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
ap = argparse.ArgumentParser(description="Fetch the Widevine CDM for CloakBrowser (Linux).")
|
||||
ap.add_argument("--out", default=_default_out(),
|
||||
help="WidevineCdm output directory (default: $CLOAKBROWSER_CACHE_DIR/WidevineCdm)")
|
||||
ap.add_argument("--force", action="store_true", help="re-download even if already present")
|
||||
ap.add_argument("--quiet", action="store_true", help="only print the final path / errors")
|
||||
args = ap.parse_args(argv)
|
||||
|
||||
def log(msg):
|
||||
if not args.quiet:
|
||||
print(f"[fetch-widevine] {msg}", file=sys.stderr)
|
||||
|
||||
# Linux only: the hint-file mechanism is Linux/ChromeOS-specific and the .so
|
||||
# we fetch is a Linux binary. Fail loudly rather than drop a useless .so.
|
||||
if platform.system() != "Linux":
|
||||
raise SystemExit(f"Widevine fetch is Linux-only (this host is {platform.system()})")
|
||||
|
||||
out = os.path.abspath(args.out)
|
||||
if os.path.isfile(os.path.join(out, "manifest.json")) and not args.force:
|
||||
log("already present (cache hit)")
|
||||
print(out)
|
||||
return 0
|
||||
|
||||
arch = _arch()
|
||||
log(f"querying component server (linux {arch})…")
|
||||
version, url, sha = _resolve_crx(arch)
|
||||
log(f"Widevine CDM {version} → downloading…")
|
||||
blob = _download(url, sha) # raises on a SHA-256 mismatch when `sha` is present
|
||||
|
||||
# Integrity policy: require at least one positive check before installing a
|
||||
# native .so the browser will load. The CRX3 publisher signature (when
|
||||
# `cryptography` is available — it is in any pip/Docker install) is the primary
|
||||
# guarantee; the server-provided SHA-256 over TLS is the fallback. The server
|
||||
# can legitimately omit `out.sha256`, so don't treat its presence as given —
|
||||
# if neither check is available, refuse rather than trust TLS alone.
|
||||
sig_ok = _verify_crx3(blob)
|
||||
if sig_ok and sha:
|
||||
log(f"verified {len(blob)} bytes (SHA-256 + CRX3 publisher signature)")
|
||||
elif sig_ok:
|
||||
log(f"verified {len(blob)} bytes (CRX3 publisher signature; server sent no SHA-256)")
|
||||
elif sha:
|
||||
log(f"verified {len(blob)} bytes (SHA-256 over TLS; cryptography absent, CRX3 sig skipped)")
|
||||
else:
|
||||
raise SystemExit(
|
||||
"refusing to install: server provided no SHA-256 and `cryptography` is "
|
||||
"unavailable for CRX3 signature verification — cannot confirm CDM integrity"
|
||||
)
|
||||
log(f"extracting → {out}")
|
||||
_extract(blob, arch, out)
|
||||
log("done")
|
||||
print(out)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
sys.exit(main())
|
||||
except SystemExit:
|
||||
raise
|
||||
except Exception as e: # noqa: BLE001 — top-level guard; entrypoint treats nonzero as soft-fail
|
||||
print(f"[fetch-widevine] error: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
@@ -0,0 +1,54 @@
|
||||
"""cloakbrowser — Stealth Chromium that passes every bot detection test.
|
||||
|
||||
Drop-in Playwright replacement with source-level fingerprint patches.
|
||||
|
||||
Usage:
|
||||
from cloakbrowser import launch
|
||||
|
||||
browser = launch()
|
||||
page = browser.new_page()
|
||||
page.goto("https://protected-site.com")
|
||||
browser.close()
|
||||
"""
|
||||
|
||||
from .browser import launch, launch_async, launch_context, launch_context_async, launch_persistent_context, launch_persistent_context_async, ProxySettings, build_args, maybe_resolve_geoip
|
||||
from .config import CHROMIUM_VERSION, get_default_stealth_args
|
||||
from .download import binary_info, check_for_update, clear_cache, ensure_binary
|
||||
from .license import LicenseInfo, validate_license
|
||||
from ._version import __version__
|
||||
|
||||
# Human-like behavioral layer (optional)
|
||||
def __getattr__(name):
|
||||
if name == "HumanConfig":
|
||||
from .human.config import HumanConfig
|
||||
globals()["HumanConfig"] = HumanConfig
|
||||
return HumanConfig
|
||||
if name == "resolve_human_config":
|
||||
from .human.config import resolve_config
|
||||
globals()["resolve_human_config"] = resolve_config
|
||||
return resolve_config
|
||||
raise AttributeError(f"module 'cloakbrowser' has no attribute {name}")
|
||||
|
||||
__all__ = [
|
||||
"launch",
|
||||
"launch_async",
|
||||
"launch_context",
|
||||
"launch_context_async",
|
||||
"launch_persistent_context",
|
||||
"launch_persistent_context_async",
|
||||
"ensure_binary",
|
||||
"clear_cache",
|
||||
"binary_info",
|
||||
"check_for_update",
|
||||
"CHROMIUM_VERSION",
|
||||
"get_default_stealth_args",
|
||||
"build_args",
|
||||
"maybe_resolve_geoip",
|
||||
"ProxySettings",
|
||||
"validate_license",
|
||||
"LicenseInfo",
|
||||
"HumanConfig",
|
||||
"resolve_human_config",
|
||||
"__version__",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,445 @@
|
||||
"""CLI for cloakbrowser — download and manage the stealth Chromium binary.
|
||||
|
||||
Usage:
|
||||
python -m cloakbrowser install # Download binary (with progress)
|
||||
python -m cloakbrowser info # Environment + binary diagnostics
|
||||
python -m cloakbrowser doctor # Alias for `info`
|
||||
python -m cloakbrowser update # Check for and download newer binary
|
||||
python -m cloakbrowser clear-cache # Remove cached binaries
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import importlib.util
|
||||
import logging
|
||||
import os
|
||||
import platform
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
UPGRADE_HINT = "→ Try the latest Pro binary (Chromium 148) free for 7 days: https://cloakbrowser.dev"
|
||||
|
||||
|
||||
def _setup_logging() -> None:
|
||||
"""Route cloakbrowser logger to stderr with clean output."""
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(message)s",
|
||||
stream=sys.stderr,
|
||||
force=True,
|
||||
)
|
||||
# Suppress noisy HTTP request logs from httpx
|
||||
logging.getLogger("httpx").setLevel(logging.WARNING)
|
||||
|
||||
|
||||
def cmd_install(args: argparse.Namespace) -> None:
|
||||
from .download import ensure_binary
|
||||
|
||||
path = ensure_binary()
|
||||
print(path)
|
||||
|
||||
|
||||
def _module_available(module: str) -> bool:
|
||||
try:
|
||||
return importlib.util.find_spec(module) is not None
|
||||
except (ImportError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
def _binary_version(binary_path: str) -> tuple[bool, str, str]:
|
||||
"""Launch `<binary> --version` to prove it runs. Returns (ok, version, err)."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[binary_path, "--version"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError) as exc:
|
||||
return False, "", str(exc)
|
||||
if result.returncode != 0:
|
||||
return False, "", (result.stderr or result.stdout).strip()
|
||||
return True, result.stdout.strip(), ""
|
||||
|
||||
|
||||
def _missing_shared_libs(binary_path: str) -> list[str]:
|
||||
"""Linux-only: ldd the binary and return missing .so names (empty otherwise)."""
|
||||
if platform.system() != "Linux":
|
||||
return []
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["ldd", "--", binary_path], # -- so a path starting with - isn't read as a flag
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return []
|
||||
missing = []
|
||||
for line in result.stdout.splitlines():
|
||||
if "=> not found" in line:
|
||||
missing.append(line.split("=>")[0].strip())
|
||||
return missing
|
||||
|
||||
|
||||
def _resolve_license() -> tuple[dict, bool]:
|
||||
"""Resolve + validate the license the way ensure_binary does.
|
||||
|
||||
Returns (license_section, entitled_to_pro). Network call (validate) only
|
||||
happens when a key is actually present.
|
||||
"""
|
||||
from .license import resolve_license_key, validate_license
|
||||
|
||||
key = resolve_license_key(None)
|
||||
# ensure_binary disables Pro routing when a custom download URL is set, so the
|
||||
# diagnostic must report free too (matches download.py).
|
||||
if os.environ.get("CLOAKBROWSER_DOWNLOAD_URL"):
|
||||
key = None
|
||||
if not key:
|
||||
return {"tier": "free"}, False
|
||||
try:
|
||||
lic = validate_license(key)
|
||||
except Exception as exc:
|
||||
return {"tier": "unknown", "error": str(exc)}, False
|
||||
if lic is None:
|
||||
return {"tier": "unknown", "error": "could not validate"}, False
|
||||
if lic.valid:
|
||||
return {"tier": lic.plan, "valid": True, "expires": lic.expires}, True
|
||||
return {"tier": "invalid", "valid": False}, False
|
||||
|
||||
|
||||
def _effective_binary(entitled_pro: bool, quick: bool = False) -> dict:
|
||||
"""Describe the binary ensure_binary would actually launch (no download).
|
||||
|
||||
Mirrors ensure_binary's resolution (override > version pin > license tier).
|
||||
Unlike binary_info(), a Pro binary on disk is only reported when the license
|
||||
entitles Pro — so a keyless run correctly shows the free binary even if a
|
||||
Pro binary is cached.
|
||||
"""
|
||||
from .config import (
|
||||
CHROMIUM_VERSION,
|
||||
get_binary_dir,
|
||||
get_binary_path,
|
||||
get_effective_version,
|
||||
get_local_binary_override,
|
||||
normalize_requested_version,
|
||||
)
|
||||
|
||||
override = get_local_binary_override()
|
||||
if override:
|
||||
return {
|
||||
"version": None,
|
||||
"latest_version": None,
|
||||
"pinned": False,
|
||||
"tier": "override",
|
||||
"bundled_version": CHROMIUM_VERSION,
|
||||
"path": override,
|
||||
"installed": os.path.isfile(override),
|
||||
"cache_dir": None,
|
||||
"override": override,
|
||||
}
|
||||
|
||||
requested = normalize_requested_version(None)
|
||||
|
||||
# For a Pro license, surface the server's latest separately from the version
|
||||
# that will actually launch, so `info` can never silently diverge from launch
|
||||
# (the divergence a customer hit: info showed latest, launch ran a stale cache).
|
||||
# --quick keeps `info` fully network-free (skip the server latest lookup).
|
||||
latest_version = None
|
||||
if entitled_pro and not quick:
|
||||
from .license import get_pro_latest_version
|
||||
|
||||
latest_version = get_pro_latest_version()
|
||||
|
||||
if requested:
|
||||
version = requested
|
||||
elif entitled_pro:
|
||||
# "Will launch now" is the cached Pro build; if none is cached, the next
|
||||
# launch downloads latest_version. get_effective_version(pro=True) returns
|
||||
# None (never the free base) when nothing is cached.
|
||||
version = get_effective_version(pro=True) or latest_version
|
||||
else:
|
||||
version = get_effective_version()
|
||||
|
||||
path = get_binary_path(version, pro=entitled_pro) if version else None
|
||||
return {
|
||||
"version": version,
|
||||
"latest_version": latest_version,
|
||||
"pinned": bool(requested),
|
||||
"tier": "pro" if entitled_pro else "free",
|
||||
"bundled_version": CHROMIUM_VERSION,
|
||||
"path": str(path) if path else None,
|
||||
"installed": bool(path) and path.exists(),
|
||||
"cache_dir": str(get_binary_dir(version, pro=entitled_pro)) if version else None,
|
||||
"override": None,
|
||||
}
|
||||
|
||||
|
||||
def _collect_diagnostics(quick: bool) -> dict:
|
||||
"""Gather environment + binary diagnostics without triggering a download."""
|
||||
diag: dict = {}
|
||||
|
||||
diag["environment"] = {
|
||||
"python": sys.version.split()[0],
|
||||
"os": platform.system(),
|
||||
"arch": platform.machine(),
|
||||
}
|
||||
|
||||
# Resolve the license up front — it decides which binary actually launches
|
||||
# (ensure_binary only uses the Pro binary when a key validates). Computed
|
||||
# before the binary section, displayed after it.
|
||||
license_info, entitled_pro = _resolve_license()
|
||||
|
||||
from .config import get_platform_tag
|
||||
|
||||
try:
|
||||
diag["environment"]["platform_tag"] = get_platform_tag()
|
||||
except Exception as exc:
|
||||
diag["environment"]["platform_tag"] = f"unavailable ({exc})"
|
||||
|
||||
try:
|
||||
diag["binary"] = _effective_binary(entitled_pro, quick=quick)
|
||||
except Exception as exc: # platform unsupported, etc.
|
||||
diag["binary"] = {"error": str(exc)}
|
||||
|
||||
# Launch test — prove the binary actually executes (skipped by --quick).
|
||||
binary = diag["binary"].get("path")
|
||||
installed = diag["binary"].get("installed")
|
||||
if quick:
|
||||
diag["launch"] = {"tested": False, "reason": "skipped (--quick)"}
|
||||
elif not binary or not (installed or (binary and os.path.isfile(binary))):
|
||||
diag["launch"] = {"tested": False, "reason": "binary not installed"}
|
||||
else:
|
||||
ok, version, err = _binary_version(binary)
|
||||
diag["launch"] = {"tested": True, "ok": ok, "version": version, "error": err}
|
||||
if not ok:
|
||||
diag["launch"]["missing_libs"] = _missing_shared_libs(binary)
|
||||
|
||||
# Windows-font probe — only meaningful on a Linux host spoofing Windows.
|
||||
# Omitted entirely off Linux, where it carries no signal.
|
||||
if platform.system() == "Linux":
|
||||
from .browser import (
|
||||
_OFFICE_FONT_TELLS,
|
||||
_WINDOWS_FONT_TELLS,
|
||||
_count_fonts_present,
|
||||
)
|
||||
|
||||
# Strict count, not "any one present" — real font installs are atomic
|
||||
# (you have the whole pack or none), so report how complete the set is.
|
||||
win_n = _count_fonts_present(_WINDOWS_FONT_TELLS)
|
||||
office_n = _count_fonts_present(_OFFICE_FONT_TELLS)
|
||||
diag["fonts"] = {
|
||||
"windows": None if win_n is None else [win_n, len(_WINDOWS_FONT_TELLS)],
|
||||
"office": None if office_n is None else [office_n, len(_OFFICE_FONT_TELLS)],
|
||||
}
|
||||
|
||||
diag["license"] = license_info
|
||||
|
||||
# GeoIP DB — presence only, never downloads.
|
||||
from .geoip import GEOIP_DB_FILENAME, _get_geoip_dir
|
||||
|
||||
db_path = _get_geoip_dir() / GEOIP_DB_FILENAME
|
||||
diag["geoip"] = {"db_present": db_path.exists(), "path": str(db_path)}
|
||||
|
||||
# Optional Python modules.
|
||||
diag["modules"] = {
|
||||
label: _module_available(module)
|
||||
for label, module in {
|
||||
"playwright": "playwright.sync_api",
|
||||
"geoip2": "geoip2.database",
|
||||
"aiohttp": "aiohttp",
|
||||
"websockets": "websockets",
|
||||
}.items()
|
||||
}
|
||||
|
||||
return diag
|
||||
|
||||
|
||||
def _print_diagnostics(diag: dict) -> None:
|
||||
"""Render the diagnostics dict as a human-readable report."""
|
||||
env = diag["environment"]
|
||||
print("CloakBrowser diagnostics")
|
||||
print(f"Python: {env['python']}")
|
||||
print(f"OS: {env['os']} {env['arch']}")
|
||||
print(f"Platform: {env.get('platform_tag', 'unknown')}")
|
||||
|
||||
binary = diag["binary"]
|
||||
if "error" in binary:
|
||||
print(f"Binary: unavailable ({binary['error']})")
|
||||
else:
|
||||
if binary["tier"] == "override":
|
||||
print("Version: set via CLOAKBROWSER_BINARY_PATH (see Launch line)")
|
||||
else:
|
||||
latest = binary.get("latest_version")
|
||||
if latest:
|
||||
# Pro: show what launches now AND the server's latest, so the two
|
||||
# can never silently diverge.
|
||||
print(f"Version: {binary['version']} ({binary['tier']}) — will launch")
|
||||
if latest == binary["version"]:
|
||||
print(f"Latest: {latest} (up to date)")
|
||||
elif binary.get("pinned"):
|
||||
print(
|
||||
f"Latest: {latest} (available — pinned; unset "
|
||||
"CLOAKBROWSER_VERSION to upgrade)"
|
||||
)
|
||||
else:
|
||||
print(f"Latest: {latest} (available — next launch upgrades)")
|
||||
elif binary["version"] is None:
|
||||
# Pro with no cached build and no server answer (e.g. offline).
|
||||
print(
|
||||
f"Version: not downloaded yet ({binary['tier']}) "
|
||||
"— next launch downloads the latest"
|
||||
)
|
||||
else:
|
||||
print(f"Version: {binary['version']} ({binary['tier']})")
|
||||
print(f"Binary: {binary['path']}")
|
||||
print(f"Installed: {binary['installed']}")
|
||||
if binary.get("cache_dir"):
|
||||
print(f"Cache: {binary['cache_dir']}")
|
||||
if binary.get("override"):
|
||||
print(f"Override: {binary['override']} (CLOAKBROWSER_BINARY_PATH)")
|
||||
|
||||
launch = diag["launch"]
|
||||
if not launch.get("tested"):
|
||||
print(f"Launch: {launch['reason']}")
|
||||
elif launch["ok"]:
|
||||
print(f"Launch: ✓ {launch['version']}")
|
||||
else:
|
||||
print(f"Launch: ✗ failed — {launch['error']}")
|
||||
for lib in launch.get("missing_libs", []):
|
||||
print(f" missing: {lib}")
|
||||
if launch.get("missing_libs"):
|
||||
print(" → install the missing system libraries (e.g. apt-get install)")
|
||||
|
||||
if "fonts" in diag:
|
||||
win = diag["fonts"]["windows"]
|
||||
if win is None:
|
||||
print("Win fonts: unknown (fc-list unavailable)")
|
||||
else:
|
||||
n, total = win
|
||||
verdict = "ok" if n == total else "missing" if n == 0 else "partial"
|
||||
print(f"Win fonts: {verdict} ({n}/{total})")
|
||||
if n < total:
|
||||
print(" → incomplete Windows font set; copy real Windows fonts (Segoe UI, Calibri, Consolas)")
|
||||
office = diag["fonts"].get("office")
|
||||
if office is not None:
|
||||
n, total = office
|
||||
# Office is informational only — no Office pack is a normal Windows
|
||||
# persona (~53% of real machines have none), so no install nudge.
|
||||
verdict = "ok" if n == total else "absent" if n == 0 else "partial"
|
||||
print(f"Office fonts: {verdict} ({n}/{total})")
|
||||
|
||||
lic = diag["license"]
|
||||
tier = lic["tier"]
|
||||
if tier == "free":
|
||||
print("License: Free")
|
||||
print(f" {UPGRADE_HINT}")
|
||||
elif "error" in lic:
|
||||
print(f"License: {tier} ({lic['error']})")
|
||||
else:
|
||||
print(f"License: {tier}")
|
||||
|
||||
geoip = diag["geoip"]
|
||||
print(f"GeoIP DB: {'present' if geoip['db_present'] else 'not downloaded (optional)'}")
|
||||
|
||||
print("Modules:")
|
||||
for label, available in diag["modules"].items():
|
||||
print(f" {label}: {'ok' if available else 'missing'}")
|
||||
|
||||
|
||||
def cmd_info(args: argparse.Namespace) -> None:
|
||||
quick = getattr(args, "quick", False)
|
||||
diag = _collect_diagnostics(quick=quick)
|
||||
if getattr(args, "json", False):
|
||||
import json
|
||||
|
||||
print(json.dumps(diag, indent=2))
|
||||
else:
|
||||
_print_diagnostics(diag)
|
||||
|
||||
|
||||
def cmd_update(args: argparse.Namespace) -> None:
|
||||
from .download import check_for_pro_update, check_for_update
|
||||
|
||||
logger = logging.getLogger("cloakbrowser")
|
||||
logger.info("Checking for updates...")
|
||||
|
||||
# A valid Pro license updates the Pro binary; everyone else updates free.
|
||||
_, entitled_pro = _resolve_license()
|
||||
if entitled_pro:
|
||||
from .license import resolve_license_key
|
||||
|
||||
new_version = check_for_pro_update(resolve_license_key(None))
|
||||
label = "Pro Chromium"
|
||||
else:
|
||||
new_version = check_for_update()
|
||||
label = "Chromium"
|
||||
|
||||
if new_version:
|
||||
print(f"Updated to {label} {new_version}")
|
||||
else:
|
||||
print("Already up to date.")
|
||||
|
||||
|
||||
def cmd_clear_cache(args: argparse.Namespace) -> None:
|
||||
from .config import get_cache_dir
|
||||
from .download import clear_cache
|
||||
|
||||
if not get_cache_dir().exists():
|
||||
print("No cache to clear.")
|
||||
return
|
||||
clear_cache()
|
||||
print("Cache cleared.")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="cloakbrowser",
|
||||
description="Manage the CloakBrowser stealth Chromium binary.",
|
||||
)
|
||||
sub = parser.add_subparsers(dest="command")
|
||||
|
||||
sub.add_parser("install", help="Download the Chromium binary")
|
||||
|
||||
def _add_info_flags(p: argparse.ArgumentParser) -> None:
|
||||
p.add_argument(
|
||||
"--quick",
|
||||
"--no-launch",
|
||||
action="store_true",
|
||||
dest="quick",
|
||||
help="Skip the binary launch test (faster; the license is still validated)",
|
||||
)
|
||||
p.add_argument("--json", action="store_true", help="Emit diagnostics as JSON")
|
||||
|
||||
_add_info_flags(sub.add_parser("info", help="Environment + binary diagnostics"))
|
||||
_add_info_flags(sub.add_parser("doctor", help="Alias for info"))
|
||||
sub.add_parser("update", help="Check for and download a newer binary")
|
||||
sub.add_parser("clear-cache", help="Remove all cached binaries")
|
||||
|
||||
args = parser.parse_args()
|
||||
if not args.command:
|
||||
parser.print_help()
|
||||
sys.exit(2)
|
||||
|
||||
_setup_logging()
|
||||
|
||||
commands = {
|
||||
"install": cmd_install,
|
||||
"info": cmd_info,
|
||||
"doctor": cmd_info,
|
||||
"update": cmd_update,
|
||||
"clear-cache": cmd_clear_cache,
|
||||
}
|
||||
|
||||
try:
|
||||
commands[args.command](args)
|
||||
except KeyboardInterrupt:
|
||||
sys.exit(130)
|
||||
except Exception as e:
|
||||
print(f"Error: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1 @@
|
||||
__version__ = "0.4.10"
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,424 @@
|
||||
"""Stealth configuration and platform detection for cloakbrowser."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import platform
|
||||
import random
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Chromium version shipped with this release.
|
||||
# Different platforms may ship different versions during transition periods.
|
||||
# CHROMIUM_VERSION is the latest across all platforms (for display/reference).
|
||||
# Use get_chromium_version() for the current platform's actual version.
|
||||
# ---------------------------------------------------------------------------
|
||||
CHROMIUM_VERSION = "146.0.7680.177.5"
|
||||
|
||||
PLATFORM_CHROMIUM_VERSIONS: dict[str, str] = {
|
||||
"linux-x64": "146.0.7680.177.5",
|
||||
"linux-arm64": "146.0.7680.177.3",
|
||||
"darwin-arm64": "145.0.7632.109.2",
|
||||
"darwin-x64": "145.0.7632.109.2",
|
||||
"windows-x64": "146.0.7680.177.5",
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Ed25519 public keys for verifying downloaded binaries.
|
||||
#
|
||||
# Each release publishes SHA256SUMS and a detached signature SHA256SUMS.sig.
|
||||
# The wrapper verifies that signature against the keys below before trusting
|
||||
# any hash in the manifest, so the download origin alone cannot certify a
|
||||
# tampered binary. Values are base64 of the 32-byte raw public key. Multiple
|
||||
# entries are accepted to allow key rotation.
|
||||
# ---------------------------------------------------------------------------
|
||||
BINARY_SIGNING_PUBKEYS: list[str] = [
|
||||
"MKFKwIhUcKWq5xTuNA0Ovg99njcDEcEJvmWYYhApvaU=",
|
||||
]
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Playwright default args to suppress — these leak automation signals.
|
||||
# --enable-automation: exposes navigator.webdriver = true
|
||||
# --enable-unsafe-swiftshader: forces software WebGL rendering via SwiftShader,
|
||||
# producing a distinctive renderer string that no real user browser has
|
||||
# ---------------------------------------------------------------------------
|
||||
IGNORE_DEFAULT_ARGS = ["--enable-automation", "--enable-unsafe-swiftshader"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Default stealth arguments passed to the patched Chromium binary.
|
||||
# These activate source-level fingerprint patches compiled into the binary.
|
||||
# ---------------------------------------------------------------------------
|
||||
def get_default_stealth_args() -> list[str]:
|
||||
"""Build stealth args with a random fingerprint seed per launch.
|
||||
|
||||
On macOS, skips platform/GPU spoofing — runs as a native Mac browser.
|
||||
Spoofing Windows on Mac creates detectable mismatches (fonts, GPU, etc.).
|
||||
"""
|
||||
seed = random.randint(10000, 99999)
|
||||
system = platform.system()
|
||||
|
||||
base = [
|
||||
"--no-sandbox",
|
||||
f"--fingerprint={seed}",
|
||||
]
|
||||
|
||||
if system == "Darwin":
|
||||
# Tell the fingerprint patches we're on macOS so GPU/UA match natively
|
||||
return base + ["--fingerprint-platform=macos"]
|
||||
|
||||
# Linux/Windows: Windows fingerprint profile.
|
||||
# Screen and window size come from the real display, not this flag (verified:
|
||||
# identical across seeds), so the wrapper must not emulate a viewport on top in
|
||||
# headed mode — that would break outerWidth >= innerWidth coherence.
|
||||
return base + ["--fingerprint-platform=windows"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Default viewport — used for HEADLESS only (headed launches use no_viewport so
|
||||
# the page tracks the real window). Headless has no window chrome, so a fixed
|
||||
# viewport stays coherent (outer == inner) and gives deterministic dimensions.
|
||||
# Models a maximized Chrome on 1080p Windows: screen=1920x1080,
|
||||
# innerHeight=947 (minus ~85px Chrome UI: tabs + address bar + bookmarks).
|
||||
# ---------------------------------------------------------------------------
|
||||
DEFAULT_VIEWPORT = {"width": 1920, "height": 947}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Platform detection
|
||||
# ---------------------------------------------------------------------------
|
||||
SUPPORTED_PLATFORMS: dict[tuple[str, str], str] = {
|
||||
("Linux", "x86_64"): "linux-x64",
|
||||
("Linux", "aarch64"): "linux-arm64",
|
||||
("Darwin", "arm64"): "darwin-arm64",
|
||||
("Darwin", "x86_64"): "darwin-x64",
|
||||
("Windows", "AMD64"): "windows-x64",
|
||||
("Windows", "x86_64"): "windows-x64",
|
||||
}
|
||||
|
||||
# Platforms with pre-built binaries available for download (derived from version map).
|
||||
AVAILABLE_PLATFORMS: set[str] = set(PLATFORM_CHROMIUM_VERSIONS.keys())
|
||||
|
||||
|
||||
_VERSION_PIN_RE = re.compile(r"^[0-9]+(?:\.[0-9]+){3,4}$")
|
||||
|
||||
|
||||
def normalize_requested_version(version: str | None = None) -> str | None:
|
||||
"""Return an explicit Chromium version pin from arg/env, or None.
|
||||
|
||||
The explicit argument wins over CLOAKBROWSER_VERSION. Only numeric dotted
|
||||
versions are accepted because the value is interpolated into cache paths and
|
||||
download URLs.
|
||||
"""
|
||||
raw = version if version is not None else os.environ.get("CLOAKBROWSER_VERSION")
|
||||
if raw is None:
|
||||
return None
|
||||
normalized = raw.strip()
|
||||
if not normalized:
|
||||
return None
|
||||
if not _VERSION_PIN_RE.fullmatch(normalized):
|
||||
raise ValueError(
|
||||
"Invalid browser version pin. Use a full numeric Chromium version, "
|
||||
"e.g. '148.0.7778.215.2'."
|
||||
)
|
||||
return normalized
|
||||
|
||||
|
||||
def get_chromium_version() -> str:
|
||||
"""Return the Chromium version for the current platform."""
|
||||
tag = get_platform_tag()
|
||||
return PLATFORM_CHROMIUM_VERSIONS.get(tag, CHROMIUM_VERSION)
|
||||
|
||||
|
||||
def get_platform_tag() -> str:
|
||||
"""Return the platform tag for binary download (e.g. 'linux-x64', 'darwin-arm64')."""
|
||||
system = platform.system()
|
||||
machine = platform.machine()
|
||||
tag = SUPPORTED_PLATFORMS.get((system, machine))
|
||||
if tag is None:
|
||||
raise RuntimeError(
|
||||
f"Unsupported platform: {system} {machine}. "
|
||||
f"Supported: {', '.join(f'{s}-{m}' for (s, m) in SUPPORTED_PLATFORMS)}"
|
||||
)
|
||||
return tag
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Binary cache paths
|
||||
# ---------------------------------------------------------------------------
|
||||
def get_cache_dir() -> Path:
|
||||
"""Return the cache directory for downloaded binaries.
|
||||
|
||||
Override with CLOAKBROWSER_CACHE_DIR env var.
|
||||
Default: ~/.cloakbrowser/
|
||||
"""
|
||||
custom = os.environ.get("CLOAKBROWSER_CACHE_DIR")
|
||||
if custom:
|
||||
return Path(custom)
|
||||
return Path.home() / ".cloakbrowser"
|
||||
|
||||
|
||||
def get_binary_dir(version: str | None = None, pro: bool = False) -> Path:
|
||||
"""Return the directory for a Chromium version binary."""
|
||||
v = version or get_chromium_version()
|
||||
suffix = "-pro" if pro else ""
|
||||
return get_cache_dir() / f"chromium-{v}{suffix}"
|
||||
|
||||
|
||||
def get_binary_path(version: str | None = None, pro: bool = False) -> Path:
|
||||
"""Return the expected path to the chrome executable."""
|
||||
binary_dir = get_binary_dir(version, pro=pro)
|
||||
|
||||
if platform.system() == "Darwin":
|
||||
# macOS: Chromium.app bundle
|
||||
return binary_dir / "Chromium.app" / "Contents" / "MacOS" / "Chromium"
|
||||
elif platform.system() == "Windows":
|
||||
return binary_dir / "chrome.exe"
|
||||
else:
|
||||
# Linux: flat binary
|
||||
return binary_dir / "chrome"
|
||||
|
||||
|
||||
def check_platform_available() -> None:
|
||||
"""Raise a clear error if no pre-built binary exists for this platform.
|
||||
|
||||
Skipped when CLOAKBROWSER_BINARY_PATH is set (user has their own build).
|
||||
"""
|
||||
if get_local_binary_override():
|
||||
return
|
||||
|
||||
tag = get_platform_tag() # raises if platform unsupported entirely
|
||||
if tag not in AVAILABLE_PLATFORMS:
|
||||
available = ", ".join(sorted(AVAILABLE_PLATFORMS))
|
||||
import sys
|
||||
|
||||
sys.exit(
|
||||
f"\n\033[1mCloakBrowser\033[0m — Pre-built binaries are currently only available for: {available}.\n\n"
|
||||
f"To use CloakBrowser now, set CLOAKBROWSER_BINARY_PATH to a local Chromium binary."
|
||||
)
|
||||
|
||||
|
||||
def get_effective_version(pro: bool = False) -> str | None:
|
||||
"""Return the best available version: auto-updated if available, else platform default.
|
||||
|
||||
Reads a platform-scoped marker file from the cache directory.
|
||||
Returns the platform's hardcoded version if no update has been downloaded.
|
||||
|
||||
When ``pro=True``, reads from the Pro-specific marker and returns ``None`` when
|
||||
no cached Pro binary matches it. A valid Pro license must NEVER fall back to the
|
||||
free binary, so there is deliberately no free-version fallback here — callers
|
||||
treat ``None`` as "resolve the latest Pro version from the server" instead.
|
||||
"""
|
||||
base = get_chromium_version()
|
||||
cache = get_cache_dir()
|
||||
|
||||
if pro:
|
||||
marker = cache / f"latest_pro_version_{get_platform_tag()}"
|
||||
if marker.exists():
|
||||
try:
|
||||
version = marker.read_text().strip()
|
||||
if version:
|
||||
binary = get_binary_path(version, pro=True)
|
||||
# Match launch's _pro_binary_ready (exists AND executable) so
|
||||
# `info` never reports a build that launch would reject.
|
||||
if binary.exists() and os.access(binary, os.X_OK):
|
||||
return version
|
||||
except (ValueError, OSError):
|
||||
pass
|
||||
return None
|
||||
|
||||
# Free tier: try platform-scoped marker first, fall back to legacy marker
|
||||
for name in (f"latest_version_{get_platform_tag()}", "latest_version"):
|
||||
marker = cache / name
|
||||
if marker.exists():
|
||||
try:
|
||||
version = marker.read_text().strip()
|
||||
if version and _version_newer(version, base):
|
||||
binary = get_binary_path(version)
|
||||
if binary.exists():
|
||||
return version
|
||||
except (ValueError, OSError):
|
||||
pass
|
||||
return base
|
||||
|
||||
|
||||
def _version_tuple(v: str) -> tuple[int, ...]:
|
||||
"""Parse '145.0.7718.0' into (145, 0, 7718, 0) for comparison."""
|
||||
return tuple(int(x) for x in v.split("."))
|
||||
|
||||
|
||||
def _version_newer(a: str, b: str) -> bool:
|
||||
"""Return True if version a is strictly newer than version b."""
|
||||
return _version_tuple(a) > _version_tuple(b)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Download URL
|
||||
# ---------------------------------------------------------------------------
|
||||
DOWNLOAD_BASE_URL = os.environ.get(
|
||||
"CLOAKBROWSER_DOWNLOAD_URL",
|
||||
"https://cloakbrowser.dev",
|
||||
)
|
||||
|
||||
GITHUB_API_URL = "https://api.github.com/repos/CloakHQ/cloakbrowser/releases"
|
||||
|
||||
GITHUB_DOWNLOAD_BASE_URL = "https://github.com/CloakHQ/cloakbrowser/releases/download"
|
||||
|
||||
|
||||
def get_archive_ext() -> str:
|
||||
"""Return the archive extension for the current platform (.zip for Windows, .tar.gz otherwise)."""
|
||||
return ".zip" if platform.system() == "Windows" else ".tar.gz"
|
||||
|
||||
|
||||
def get_archive_name(tag: str | None = None) -> str:
|
||||
"""Return the archive filename for a platform tag (e.g. 'cloakbrowser-linux-x64.tar.gz')."""
|
||||
t = tag or get_platform_tag()
|
||||
return f"cloakbrowser-{t}{get_archive_ext()}"
|
||||
|
||||
|
||||
def get_download_url(version: str | None = None) -> str:
|
||||
"""Return the full download URL for the current platform's binary archive."""
|
||||
v = version or get_chromium_version()
|
||||
return f"{DOWNLOAD_BASE_URL}/chromium-v{v}/{get_archive_name()}"
|
||||
|
||||
|
||||
def get_fallback_download_url(version: str | None = None) -> str:
|
||||
"""Return the GitHub Releases fallback URL for the binary archive."""
|
||||
v = version or get_chromium_version()
|
||||
return f"{GITHUB_DOWNLOAD_BASE_URL}/chromium-v{v}/{get_archive_name()}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Local binary override (skip download, use your own build)
|
||||
# ---------------------------------------------------------------------------
|
||||
def get_local_binary_override() -> str | None:
|
||||
"""Check if user has set a local binary path via env var.
|
||||
|
||||
Set CLOAKBROWSER_BINARY_PATH to use a locally built Chromium instead of downloading.
|
||||
"""
|
||||
return os.environ.get("CLOAKBROWSER_BINARY_PATH")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Headless viewport handling by binary version
|
||||
# ---------------------------------------------------------------------------
|
||||
# First Chromium build that reports coherent headless dimensions without an
|
||||
# emulated viewport. On these binaries the wrapper launches headless with
|
||||
# no_viewport; older binaries need a fixed DEFAULT_VIEWPORT to stay coherent.
|
||||
# None => not shipped yet; feature off, behavior byte-identical to today.
|
||||
# TODO: set to the chromium version string that first ships it.
|
||||
HEADLESS_NO_VIEWPORT_MIN_VERSION: str | None = "148.0.7778.215.4"
|
||||
|
||||
|
||||
def binary_supports_headless_no_viewport(
|
||||
license_key: str | None = None, browser_version: str | None = None
|
||||
) -> bool:
|
||||
"""Whether headless can launch with ``no_viewport`` on the resolved binary.
|
||||
|
||||
Only binaries at or above ``HEADLESS_NO_VIEWPORT_MIN_VERSION`` qualify; older
|
||||
ones keep ``DEFAULT_VIEWPORT``. A local override binary
|
||||
(``CLOAKBROWSER_BINARY_PATH``) is unknown-version, so stay on the safe path.
|
||||
"""
|
||||
if HEADLESS_NO_VIEWPORT_MIN_VERSION is None:
|
||||
return False
|
||||
# A declared version (browser_version arg OR CLOAKBROWSER_VERSION env) wins even
|
||||
# under a local override — the caller is asserting the version (also how internal
|
||||
# builds opt in). Only an override with no declared version stays on the safe path.
|
||||
try:
|
||||
declared = normalize_requested_version(browser_version)
|
||||
except ValueError:
|
||||
declared = None
|
||||
if declared:
|
||||
version = declared
|
||||
elif get_local_binary_override():
|
||||
return False
|
||||
else:
|
||||
from .license import resolve_license_key
|
||||
|
||||
pro = bool(resolve_license_key(license_key))
|
||||
version = get_effective_version(pro=pro)
|
||||
try:
|
||||
return not _version_newer(HEADLESS_NO_VIEWPORT_MIN_VERSION, version)
|
||||
except (ValueError, AttributeError):
|
||||
return False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Inline HTTP proxy authentication by binary version
|
||||
# ---------------------------------------------------------------------------
|
||||
# First Chromium build, per platform, whose binary can take inline HTTP proxy
|
||||
# credentials (Chromium-native ``--proxy-server=http://user:pass@host``). Below
|
||||
# the floor the wrapper MUST route credentialed HTTP/HTTPS proxies through
|
||||
# Playwright's proxy dict instead — an older binary treats the ``user:pass@``
|
||||
# authority as an invalid proxy host and drops proxy auth (#182).
|
||||
#
|
||||
# A single global threshold (like HEADLESS_NO_VIEWPORT_MIN_VERSION) can't model
|
||||
# this: the capability landed in different build lineages at different points —
|
||||
# linux-x64/windows-x64 have it at 146.0.7680.177.5, but the free macOS (145.x)
|
||||
# and linux-arm64 (146.0.7680.177.3) floors predate it, so those only qualify
|
||||
# once they resolve to a Pro/newer build (148+). Platforms absent from this map
|
||||
# are treated as never-inline (unknown capability => safe fallback).
|
||||
HTTP_PROXY_INLINE_AUTH_MIN_VERSION: dict[str, str] = {
|
||||
"linux-x64": "146.0.7680.177.5",
|
||||
"windows-x64": "146.0.7680.177.5",
|
||||
"linux-arm64": "148.0.7778.215.3",
|
||||
"darwin-arm64": "148.0.7778.215.3",
|
||||
"darwin-x64": "148.0.7778.215.3",
|
||||
}
|
||||
|
||||
|
||||
def binary_supports_http_proxy_inline_auth(
|
||||
license_key: str | None = None, browser_version: str | None = None
|
||||
) -> bool:
|
||||
"""Whether the resolved binary accepts inline HTTP proxy credentials.
|
||||
|
||||
The capability is a function of the binary version; ``license_key`` only
|
||||
resolves *which* version launches (Pro build vs free default), exactly like
|
||||
``binary_supports_headless_no_viewport``. A declared pin wins, else a valid
|
||||
Pro license resolves to the Pro build (which ships the patch), else the free
|
||||
per-platform default — then it compares to this platform's floor. Below the
|
||||
floor, credentialed HTTP proxies fall back to Playwright's proxy dict. A
|
||||
local override with no declared version is unknown-version, so stay on the
|
||||
safe fallback. Python, JS and .NET mirror this gate.
|
||||
"""
|
||||
floor = HTTP_PROXY_INLINE_AUTH_MIN_VERSION.get(get_platform_tag())
|
||||
if floor is None:
|
||||
return False
|
||||
try:
|
||||
declared = normalize_requested_version(browser_version)
|
||||
except ValueError:
|
||||
declared = None
|
||||
if declared:
|
||||
version = declared
|
||||
elif get_local_binary_override():
|
||||
return False
|
||||
else:
|
||||
from .license import resolve_license_key
|
||||
|
||||
pro = bool(resolve_license_key(license_key))
|
||||
version = get_effective_version(pro=pro)
|
||||
if version is None:
|
||||
# Pro with no cached marker => resolve latest Pro from the server, which
|
||||
# is >= the floor by construction and always ships the patch.
|
||||
return True
|
||||
try:
|
||||
return not _version_newer(floor, version)
|
||||
except (ValueError, AttributeError):
|
||||
return False
|
||||
|
||||
|
||||
def binary_supports_maximized_window(
|
||||
license_key: str | None = None, browser_version: str | None = None
|
||||
) -> bool:
|
||||
"""Whether the wrapper may auto-add ``--start-maximized``.
|
||||
|
||||
Gated on the same threshold as the no_viewport shim: only binaries whose
|
||||
headless surface-fix + headed screen-clamp make a maximized window coherent
|
||||
(``outer == screen``). Below it, maximizing headless while the CDP viewport
|
||||
stays at 1280x720 yields ``outerWidth < innerWidth`` — an impossible-window
|
||||
bot tell — so the flag must NOT be added. Shares
|
||||
``HEADLESS_NO_VIEWPORT_MIN_VERSION``; kept as its own name so the two can
|
||||
diverge later. Python, JS and .NET mirror this gate.
|
||||
"""
|
||||
return binary_supports_headless_no_viewport(license_key, browser_version)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,360 @@
|
||||
"""GeoIP-based timezone and locale detection from proxy IP.
|
||||
|
||||
Optional feature — requires ``geoip2`` package::
|
||||
|
||||
pip install cloakbrowser[geoip]
|
||||
|
||||
Downloads GeoLite2-City.mmdb (~70 MB) on first use, caches in
|
||||
``~/.cloakbrowser/geoip/``. Background re-download after 30 days.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
import socket
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlparse
|
||||
|
||||
logger = logging.getLogger("cloakbrowser")
|
||||
|
||||
# P3TERX mirror of MaxMind GeoLite2-City — no license key needed
|
||||
GEOIP_DB_URL = (
|
||||
"https://github.com/P3TERX/GeoLite.mmdb/raw/download/GeoLite2-City.mmdb"
|
||||
)
|
||||
GEOIP_DB_FILENAME = "GeoLite2-City.mmdb"
|
||||
GEOIP_UPDATE_INTERVAL = 30 * 86_400 # 30 days
|
||||
DEFAULT_GEOIP_TIMEOUT_SECONDS = 5.0
|
||||
GEOIP_TIMEOUT_ENV = "CLOAKBROWSER_GEOIP_TIMEOUT_SECONDS"
|
||||
|
||||
# Country ISO code → BCP 47 locale (covers ~90 % of proxy traffic)
|
||||
COUNTRY_LOCALE_MAP: dict[str, str] = {
|
||||
"US": "en-US", "GB": "en-GB", "AU": "en-AU", "CA": "en-CA", "NZ": "en-NZ",
|
||||
"IE": "en-IE", "ZA": "en-ZA", "SG": "en-SG",
|
||||
"DE": "de-DE", "AT": "de-AT", "CH": "de-CH",
|
||||
"FR": "fr-FR", "BE": "fr-BE",
|
||||
"ES": "es-ES", "MX": "es-MX", "AR": "es-AR", "CO": "es-CO", "CL": "es-CL",
|
||||
"BR": "pt-BR", "PT": "pt-PT",
|
||||
"IT": "it-IT", "NL": "nl-NL",
|
||||
"JP": "ja-JP", "KR": "ko-KR", "CN": "zh-CN", "TW": "zh-TW", "HK": "zh-HK",
|
||||
"RU": "ru-RU", "UA": "uk-UA", "PL": "pl-PL", "CZ": "cs-CZ", "RO": "ro-RO",
|
||||
"IL": "he-IL", "TR": "tr-TR", "SA": "ar-SA", "AE": "ar-AE", "EG": "ar-EG",
|
||||
"IN": "hi-IN", "ID": "id-ID", "PH": "en-PH",
|
||||
"TH": "th-TH", "VN": "vi-VN", "MY": "ms-MY",
|
||||
"SE": "sv-SE", "NO": "nb-NO", "DK": "da-DK", "FI": "fi-FI",
|
||||
"GR": "el-GR", "HU": "hu-HU", "BG": "bg-BG",
|
||||
# Extended coverage — common residential/mobile proxy exits
|
||||
"SI": "sl-SI", "SK": "sk-SK", "HR": "hr-HR", "RS": "sr-RS", "LT": "lt-LT",
|
||||
"LV": "lv-LV", "EE": "et-EE", "IS": "is-IS", "LU": "fr-LU", "MT": "en-MT",
|
||||
"CY": "el-CY", "MD": "ro-MD", "BY": "ru-BY", "GE": "ka-GE", "AL": "sq-AL",
|
||||
"MK": "mk-MK", "BA": "bs-BA",
|
||||
"PE": "es-PE", "VE": "es-VE", "EC": "es-EC", "UY": "es-UY", "CR": "es-CR",
|
||||
"DO": "es-DO", "GT": "es-GT", "BO": "es-BO", "PY": "es-PY",
|
||||
"PK": "en-PK", "BD": "bn-BD", "LK": "si-LK", "KZ": "ru-KZ", "IR": "fa-IR",
|
||||
"IQ": "ar-IQ", "JO": "ar-JO", "LB": "ar-LB", "KW": "ar-KW", "QA": "ar-QA",
|
||||
"OM": "ar-OM", "BH": "ar-BH",
|
||||
"NG": "en-NG", "KE": "en-KE", "MA": "fr-MA", "DZ": "ar-DZ", "TN": "ar-TN",
|
||||
"GH": "en-GH",
|
||||
"AM": "hy-AM", "AZ": "az-AZ", "UZ": "uz-UZ", "KG": "ky-KG", "TJ": "tg-TJ",
|
||||
"TM": "tk-TM",
|
||||
"ME": "sr-ME", "XK": "sq-XK", "LI": "de-LI", "MC": "fr-MC", "AD": "ca-AD",
|
||||
"MM": "my-MM", "KH": "km-KH", "LA": "lo-LA", "MN": "mn-MN", "BN": "ms-BN",
|
||||
"MO": "zh-MO",
|
||||
"YE": "ar-YE", "SY": "ar-SY", "PS": "ar-PS", "LY": "ar-LY",
|
||||
"ET": "am-ET", "TZ": "sw-TZ", "UG": "en-UG", "SN": "fr-SN", "CI": "fr-CI",
|
||||
"CM": "fr-CM", "AO": "pt-AO", "MZ": "pt-MZ", "ZM": "en-ZM", "ZW": "en-ZW",
|
||||
"HN": "es-HN", "NI": "es-NI", "SV": "es-SV", "PA": "es-PA", "JM": "en-JM",
|
||||
"TT": "en-TT", "PR": "es-PR",
|
||||
}
|
||||
|
||||
|
||||
def resolve_proxy_geo(proxy_url: str | None) -> tuple[str | None, str | None]:
|
||||
"""Resolve timezone and locale from a proxy's IP address.
|
||||
|
||||
Returns ``(timezone, locale)`` — either or both may be ``None`` on
|
||||
failure (missing dep, DB download error, lookup miss). Never raises.
|
||||
|
||||
When *proxy_url* is falsy, the machine's own public IP is used instead
|
||||
(direct HTTP to the echo services, no proxy).
|
||||
"""
|
||||
tz, locale, _ip = resolve_proxy_geo_with_ip(proxy_url)
|
||||
return tz, locale
|
||||
|
||||
|
||||
def resolve_proxy_geo_with_ip(
|
||||
proxy_url: str | None,
|
||||
) -> tuple[str | None, str | None, str | None]:
|
||||
"""Resolve timezone, locale, and exit IP from a proxy.
|
||||
|
||||
Returns ``(timezone, locale, exit_ip)``. The exit IP is a free bonus
|
||||
from the lookup — reused for WebRTC spoofing without an extra HTTP call.
|
||||
|
||||
When *proxy_url* is falsy, the egress IP is the machine's own public IP
|
||||
(echo services queried directly, no proxy), so geoip works proxy-free.
|
||||
"""
|
||||
try:
|
||||
import geoip2.database # noqa: F811
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"geoip2 is required for geoip=True. Install it with:\n"
|
||||
" pip install cloakbrowser[geoip]"
|
||||
) from None
|
||||
|
||||
# Ensure the DB first — the download must NOT be bounded by the resolution
|
||||
# timeout (a first-use ~70MB fetch legitimately outlasts it).
|
||||
db_path = _ensure_geoip_db()
|
||||
|
||||
timeout = _get_geoip_timeout_seconds()
|
||||
deadline = _deadline_from_timeout(timeout)
|
||||
|
||||
# Exit IP (through proxy, or the machine's own public IP when proxy_url is
|
||||
# falsy) is most accurate — gateway DNS may differ from exit. Resolved even
|
||||
# when the DB is unavailable: the IP does not need the DB, and dropping it on
|
||||
# a DB hiccup would let WebRTC fall back to the real IP behind a proxy while
|
||||
# the connection shows the proxy IP — a real deanonymization.
|
||||
ip = _resolve_exit_ip(proxy_url, timeout=_remaining_seconds(deadline))
|
||||
# Hostname fallback only applies to a proxy; no proxy → echo services only
|
||||
if ip is None and proxy_url and not _deadline_expired(deadline):
|
||||
ip = _resolve_proxy_ip(proxy_url)
|
||||
if ip is None or _deadline_expired(deadline):
|
||||
if deadline is not None and _deadline_expired(deadline):
|
||||
logger.warning("GeoIP resolution timed out after %.1fs; continuing without GeoIP", timeout)
|
||||
return None, None, None
|
||||
|
||||
# DB only drives tz/locale; a missing/failed DB still returns the exit IP.
|
||||
if db_path is None:
|
||||
return None, None, ip
|
||||
|
||||
try:
|
||||
with geoip2.database.Reader(str(db_path)) as reader:
|
||||
resp = reader.city(ip)
|
||||
timezone = resp.location.time_zone
|
||||
country = resp.country.iso_code
|
||||
locale = COUNTRY_LOCALE_MAP.get(country) if country else None
|
||||
logger.debug(
|
||||
"GeoIP: %s → tz=%s, country=%s, locale=%s",
|
||||
ip, timezone, country, locale,
|
||||
)
|
||||
return timezone, locale, ip
|
||||
except Exception as exc:
|
||||
logger.warning("GeoIP lookup failed for %s: %s", ip, exc)
|
||||
return None, None, ip
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Proxy IP resolution
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _resolve_proxy_ip(proxy_url: str) -> str | None:
|
||||
"""Extract proxy hostname from URL and resolve to an IP address."""
|
||||
try:
|
||||
hostname = urlparse(proxy_url).hostname
|
||||
if not hostname:
|
||||
return None
|
||||
|
||||
# Already a literal IP?
|
||||
try:
|
||||
socket.inet_pton(socket.AF_INET, hostname)
|
||||
return hostname
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
socket.inet_pton(socket.AF_INET6, hostname)
|
||||
return hostname
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
# DNS resolve (returns first result, handles both v4/v6)
|
||||
results = socket.getaddrinfo(hostname, None, socket.AF_UNSPEC, socket.SOCK_STREAM)
|
||||
if results:
|
||||
ip = results[0][4][0]
|
||||
logger.debug("Resolved proxy %s → %s", hostname, ip)
|
||||
return ip
|
||||
return None
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to resolve proxy hostname: %s", exc)
|
||||
return None
|
||||
|
||||
|
||||
def _is_private_ip(ip: str) -> bool:
|
||||
"""Check if an IP address is private/internal (not routable on the internet)."""
|
||||
try:
|
||||
return ipaddress.ip_address(ip).is_private
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
# IP echo services — fast, no auth, return just the IP
|
||||
_IP_ECHO_URLS = [
|
||||
"https://api.ipify.org",
|
||||
"https://checkip.amazonaws.com",
|
||||
"https://ifconfig.me/ip",
|
||||
]
|
||||
|
||||
|
||||
def _get_geoip_timeout_seconds() -> float:
|
||||
raw = os.getenv(GEOIP_TIMEOUT_ENV)
|
||||
if not raw:
|
||||
return DEFAULT_GEOIP_TIMEOUT_SECONDS
|
||||
try:
|
||||
timeout = float(raw)
|
||||
except ValueError:
|
||||
timeout = float("nan")
|
||||
if not math.isfinite(timeout):
|
||||
logger.warning(
|
||||
"Invalid %s=%r; using %.1fs",
|
||||
GEOIP_TIMEOUT_ENV,
|
||||
raw,
|
||||
DEFAULT_GEOIP_TIMEOUT_SECONDS,
|
||||
)
|
||||
return DEFAULT_GEOIP_TIMEOUT_SECONDS
|
||||
return max(timeout, 0.0)
|
||||
|
||||
|
||||
def _deadline_from_timeout(timeout: float) -> float | None:
|
||||
if timeout <= 0:
|
||||
return None
|
||||
return time.monotonic() + timeout
|
||||
|
||||
|
||||
def _remaining_seconds(deadline: float | None) -> float | None:
|
||||
if deadline is None:
|
||||
return None
|
||||
return max(deadline - time.monotonic(), 0.0)
|
||||
|
||||
|
||||
def _deadline_expired(deadline: float | None) -> bool:
|
||||
return deadline is not None and time.monotonic() >= deadline
|
||||
|
||||
|
||||
def resolve_proxy_exit_ip(proxy_url: str | None) -> str | None:
|
||||
"""Resolve the egress IP, bounded by the GeoIP timeout.
|
||||
|
||||
With a proxy this is the proxy's exit IP; with no proxy it is the
|
||||
machine's own public IP (echo services queried directly).
|
||||
"""
|
||||
timeout = _get_geoip_timeout_seconds()
|
||||
deadline = _deadline_from_timeout(timeout)
|
||||
ip = _resolve_exit_ip(proxy_url, timeout=timeout)
|
||||
if ip is None and _deadline_expired(deadline):
|
||||
logger.warning("GeoIP resolution timed out after %.1fs; continuing without GeoIP", timeout)
|
||||
return ip
|
||||
|
||||
|
||||
def _resolve_exit_ip(proxy_url: str | None, timeout: float | None = None) -> str | None:
|
||||
"""Discover the egress IP via the echo services.
|
||||
|
||||
Through *proxy_url* when given (the proxy's exit IP); directly (no proxy)
|
||||
when *proxy_url* is falsy — that returns the machine's own public IP.
|
||||
"""
|
||||
import httpx
|
||||
|
||||
deadline = _deadline_from_timeout(timeout or 0)
|
||||
|
||||
for url in _IP_ECHO_URLS:
|
||||
try:
|
||||
remaining = _remaining_seconds(deadline)
|
||||
if remaining is not None and remaining <= 0:
|
||||
return None
|
||||
request_timeout = min(10.0, remaining) if remaining is not None else 10.0
|
||||
resp = httpx.get(url, proxy=proxy_url or None, timeout=request_timeout)
|
||||
resp.raise_for_status()
|
||||
ip = resp.text.strip()
|
||||
# Validate it looks like an IP
|
||||
ipaddress.ip_address(ip)
|
||||
logger.debug("Exit IP via %s: %s", url, ip)
|
||||
return ip
|
||||
except httpx.UnsupportedProtocol:
|
||||
logger.warning(
|
||||
"SOCKS5 proxy requires socksio: pip install cloakbrowser[geoip]"
|
||||
)
|
||||
return None
|
||||
except Exception:
|
||||
continue
|
||||
logger.warning("Failed to discover exit IP through proxy")
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GeoIP database management
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _get_geoip_dir() -> Path:
|
||||
from .config import get_cache_dir
|
||||
|
||||
return get_cache_dir() / "geoip"
|
||||
|
||||
|
||||
def _ensure_geoip_db() -> Path | None:
|
||||
"""Return path to GeoLite2-City.mmdb, downloading on first use."""
|
||||
db_path = _get_geoip_dir() / GEOIP_DB_FILENAME
|
||||
|
||||
if db_path.exists():
|
||||
_maybe_trigger_update(db_path)
|
||||
return db_path
|
||||
|
||||
try:
|
||||
_download_geoip_db(db_path)
|
||||
return db_path
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to download GeoIP database: %s", exc)
|
||||
return None
|
||||
|
||||
|
||||
def _download_geoip_db(dest: Path) -> None:
|
||||
"""Atomic download of GeoLite2-City.mmdb via httpx."""
|
||||
import httpx
|
||||
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
logger.info("Downloading GeoIP database (~70 MB) …")
|
||||
|
||||
tmp_fd, tmp_name = tempfile.mkstemp(dir=dest.parent, suffix=".tmp")
|
||||
tmp_path = Path(tmp_name)
|
||||
try:
|
||||
with httpx.stream(
|
||||
"GET", GEOIP_DB_URL, follow_redirects=True, timeout=300.0
|
||||
) as resp:
|
||||
resp.raise_for_status()
|
||||
total = int(resp.headers.get("content-length", 0))
|
||||
downloaded = 0
|
||||
last_pct = -1
|
||||
with open(tmp_fd, "wb") as f:
|
||||
for chunk in resp.iter_bytes(chunk_size=65_536):
|
||||
f.write(chunk)
|
||||
downloaded += len(chunk)
|
||||
if total:
|
||||
pct = downloaded * 100 // total
|
||||
if pct >= last_pct + 10:
|
||||
last_pct = pct
|
||||
logger.info("GeoIP download: %d %%", pct)
|
||||
|
||||
tmp_path.rename(dest)
|
||||
logger.info("GeoIP database ready: %s", dest)
|
||||
except Exception:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
raise
|
||||
|
||||
|
||||
def _maybe_trigger_update(db_path: Path) -> None:
|
||||
"""Re-download in background if DB is older than 30 days."""
|
||||
try:
|
||||
age = time.time() - db_path.stat().st_mtime
|
||||
if age < GEOIP_UPDATE_INTERVAL:
|
||||
return
|
||||
except OSError:
|
||||
return
|
||||
|
||||
def _bg() -> None:
|
||||
try:
|
||||
_download_geoip_db(db_path)
|
||||
except Exception:
|
||||
logger.debug("Background GeoIP update failed", exc_info=True)
|
||||
|
||||
threading.Thread(target=_bg, daemon=True).start()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,354 @@
|
||||
"""Playwright-style actionability checks for the humanize layer (sync).
|
||||
|
||||
Checks: attached, visible, stable, enabled, editable, receives pointer events.
|
||||
Retry loop with backoff matching Playwright internals: [100, 250, 500, 1000]ms.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from typing import Any, FrozenSet, Optional, Tuple
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Error hierarchy — all subclass RuntimeError for backward compat
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class ActionabilityError(RuntimeError):
|
||||
"""Base for all actionability failures."""
|
||||
|
||||
def __init__(self, selector: str, check: str, message: str):
|
||||
self.selector = selector
|
||||
self.check = check
|
||||
super().__init__(f"Element {selector!r} failed {check} check: {message}")
|
||||
|
||||
|
||||
class ElementNotAttachedError(ActionabilityError):
|
||||
def __init__(self, selector: str):
|
||||
super().__init__(selector, "attached", "element not found in DOM")
|
||||
|
||||
|
||||
class ElementNotVisibleError(ActionabilityError):
|
||||
def __init__(self, selector: str):
|
||||
super().__init__(selector, "visible", "element is not visible")
|
||||
|
||||
|
||||
class ElementNotStableError(ActionabilityError):
|
||||
def __init__(self, selector: str):
|
||||
super().__init__(selector, "stable", "element position is still changing")
|
||||
|
||||
|
||||
class ElementNotEnabledError(ActionabilityError):
|
||||
def __init__(self, selector: str):
|
||||
super().__init__(selector, "enabled", "element is disabled")
|
||||
|
||||
|
||||
class ElementNotEditableError(ActionabilityError):
|
||||
def __init__(self, selector: str):
|
||||
super().__init__(selector, "editable", "element is not editable")
|
||||
|
||||
|
||||
class ElementNotReceivingEventsError(ActionabilityError):
|
||||
def __init__(self, selector: str, covering_tag: str = "unknown"):
|
||||
super().__init__(
|
||||
selector,
|
||||
"pointer_events",
|
||||
f"element is covered by <{covering_tag}>",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Check-set constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
CHECKS_CLICK: FrozenSet[str] = frozenset({"attached", "visible", "enabled", "pointer_events"})
|
||||
CHECKS_HOVER: FrozenSet[str] = frozenset({"attached", "visible", "pointer_events"})
|
||||
CHECKS_INPUT: FrozenSet[str] = frozenset({"attached", "visible", "enabled", "editable", "pointer_events"})
|
||||
CHECKS_FOCUS: FrozenSet[str] = frozenset({"attached", "visible", "enabled"})
|
||||
CHECKS_CHECK: FrozenSet[str] = frozenset({"attached", "visible", "enabled", "pointer_events"})
|
||||
|
||||
_BACKOFF_MS = [100, 250, 500, 1000]
|
||||
|
||||
|
||||
def _backoff_sleep(attempt: int) -> None:
|
||||
idx = min(attempt, len(_BACKOFF_MS) - 1)
|
||||
time.sleep(_BACKOFF_MS[idx] / 1000.0)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pre-scroll actionability: attached, visible, enabled, editable
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def ensure_actionable(
|
||||
page: Any,
|
||||
selector: str,
|
||||
checks: FrozenSet[str],
|
||||
timeout: float = 30000,
|
||||
force: bool = False,
|
||||
) -> None:
|
||||
"""Wait for element to pass actionability checks (pre-scroll).
|
||||
|
||||
Retries with backoff until *timeout* ms elapsed.
|
||||
Raises a specific ``ActionabilityError`` subclass on failure.
|
||||
If *force* is True, returns immediately.
|
||||
"""
|
||||
if force:
|
||||
return
|
||||
|
||||
deadline = time.monotonic() + timeout / 1000.0
|
||||
attempt = 0
|
||||
last_error: Optional[ActionabilityError] = None
|
||||
|
||||
while True:
|
||||
remaining_ms = max(0, (deadline - time.monotonic()) * 1000)
|
||||
if remaining_ms <= 0:
|
||||
if last_error is not None:
|
||||
raise last_error
|
||||
raise ActionabilityError(selector, "timeout", "timeout expired before first check")
|
||||
|
||||
try:
|
||||
loc = page.locator(selector).first
|
||||
|
||||
if "attached" in checks:
|
||||
try:
|
||||
loc.wait_for(state="attached", timeout=max(1, min(remaining_ms, 2000)))
|
||||
except Exception:
|
||||
raise ElementNotAttachedError(selector)
|
||||
|
||||
if "visible" in checks:
|
||||
if not loc.is_visible():
|
||||
raise ElementNotVisibleError(selector)
|
||||
|
||||
if "enabled" in checks:
|
||||
if not loc.is_enabled():
|
||||
raise ElementNotEnabledError(selector)
|
||||
|
||||
if "editable" in checks:
|
||||
if not loc.is_editable():
|
||||
raise ElementNotEditableError(selector)
|
||||
|
||||
return
|
||||
|
||||
except ActionabilityError as e:
|
||||
last_error = e
|
||||
if time.monotonic() >= deadline:
|
||||
raise last_error
|
||||
_backoff_sleep(attempt)
|
||||
attempt += 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Post-scroll stability check
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _boxes_differ(a: dict, b: dict) -> bool:
|
||||
return (
|
||||
abs(a["x"] - b["x"]) > 1
|
||||
or abs(a["y"] - b["y"]) > 1
|
||||
or abs(a["width"] - b["width"]) > 1
|
||||
or abs(a["height"] - b["height"]) > 1
|
||||
)
|
||||
|
||||
|
||||
def ensure_stable(
|
||||
page: Any,
|
||||
selector: str,
|
||||
timeout: float = 5000,
|
||||
) -> None:
|
||||
"""Wait for element position to stabilize (two samples 100ms apart).
|
||||
|
||||
Only call after scroll — skip if element was already in viewport.
|
||||
"""
|
||||
deadline = time.monotonic() + timeout / 1000.0
|
||||
attempt = 0
|
||||
|
||||
while True:
|
||||
remaining_ms = max(0, (deadline - time.monotonic()) * 1000)
|
||||
if remaining_ms <= 0:
|
||||
raise ElementNotStableError(selector)
|
||||
|
||||
loc = page.locator(selector).first
|
||||
box1 = loc.bounding_box(timeout=max(1, min(remaining_ms, 1000)))
|
||||
if box1 is None:
|
||||
raise ElementNotAttachedError(selector)
|
||||
|
||||
time.sleep(0.1)
|
||||
|
||||
box2 = loc.bounding_box(timeout=max(1, min(remaining_ms, 1000)))
|
||||
if box2 is None:
|
||||
raise ElementNotAttachedError(selector)
|
||||
|
||||
if not _boxes_differ(box1, box2):
|
||||
return
|
||||
|
||||
if time.monotonic() >= deadline:
|
||||
raise ElementNotStableError(selector)
|
||||
|
||||
_backoff_sleep(attempt)
|
||||
attempt += 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pointer-events check (post-scroll, at actual click coordinates)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# data.box is page-space (from bounding_box); rect is frame-local. Their delta
|
||||
# is the iframe offset, needed to map page-space click coords into the frame's
|
||||
# own viewport before elementFromPoint. For main-frame elements the offset is 0.
|
||||
_POINTER_EVENTS_LOCATOR_JS = """(expected, data) => {
|
||||
const rect = expected.getBoundingClientRect();
|
||||
const frameOffsetX = data.box ? data.box.x - rect.x : 0;
|
||||
const frameOffsetY = data.box ? data.box.y - rect.y : 0;
|
||||
const target = document.elementFromPoint(data.x - frameOffsetX, data.y - frameOffsetY);
|
||||
if (!target) return { hit: false, reason: 'no_element_at_point', covering: 'none' };
|
||||
let node = target;
|
||||
while (node) { if (node === expected) return { hit: true }; node = node.parentNode; }
|
||||
if (expected.contains(target)) return { hit: true };
|
||||
return { hit: false, reason: 'covered', covering: target.tagName || 'unknown' };
|
||||
}"""
|
||||
|
||||
_POINTER_EVENTS_HANDLE_JS = """(expected, data) => {
|
||||
const rect = expected.getBoundingClientRect();
|
||||
const frameOffsetX = data.box ? data.box.x - rect.x : 0;
|
||||
const frameOffsetY = data.box ? data.box.y - rect.y : 0;
|
||||
const target = document.elementFromPoint(data.x - frameOffsetX, data.y - frameOffsetY);
|
||||
if (!target) return { hit: false, reason: 'no_element_at_point', covering: 'none' };
|
||||
let node = target;
|
||||
while (node) { if (node === expected) return { hit: true }; node = node.parentNode; }
|
||||
if (expected.contains(target)) return { hit: true };
|
||||
return { hit: false, reason: 'covered', covering: target.tagName || 'unknown' };
|
||||
}"""
|
||||
|
||||
|
||||
def check_pointer_events(
|
||||
page: Any,
|
||||
selector: str,
|
||||
x: float,
|
||||
y: float,
|
||||
stealth: Any = None,
|
||||
timeout: float = 5000,
|
||||
) -> None:
|
||||
"""Check that elementFromPoint(x, y) hits the expected element.
|
||||
|
||||
Uses locator.evaluate() so all Playwright selector types work
|
||||
(text=, role=, XPath, CSS, etc.). Retries with backoff for transient overlays.
|
||||
"""
|
||||
deadline = time.monotonic() + timeout / 1000.0
|
||||
attempt = 0
|
||||
|
||||
while True:
|
||||
try:
|
||||
loc = page.locator(selector).first
|
||||
box = loc.bounding_box(timeout=max(1, min((deadline - time.monotonic()) * 1000, 1000)))
|
||||
result = loc.evaluate(_POINTER_EVENTS_LOCATOR_JS, {"x": x, "y": y, "box": box})
|
||||
except Exception as exc:
|
||||
logger.debug("pointer_events check failed for %r: %s", selector, exc)
|
||||
result = None
|
||||
|
||||
# Proceed if the check confirms a hit, or if it could not be determined
|
||||
# (None) — failing closed would block legitimate clicks.
|
||||
if result is None or result.get("hit", False):
|
||||
return
|
||||
|
||||
covering = (result or {}).get("covering", "unknown")
|
||||
|
||||
if time.monotonic() >= deadline:
|
||||
raise ElementNotReceivingEventsError(selector, covering)
|
||||
|
||||
_backoff_sleep(attempt)
|
||||
attempt += 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ElementHandle variant
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def ensure_actionable_handle(
|
||||
page: Any,
|
||||
el: Any,
|
||||
checks: FrozenSet[str],
|
||||
timeout: float = 30000,
|
||||
force: bool = False,
|
||||
) -> None:
|
||||
"""Actionability checks for ElementHandle (no selector needed).
|
||||
|
||||
Uses Playwright's wait_for_element_state where available.
|
||||
"""
|
||||
if force:
|
||||
return
|
||||
|
||||
deadline = time.monotonic() + timeout / 1000.0
|
||||
attempt = 0
|
||||
last_error: Optional[ActionabilityError] = None
|
||||
label = "<ElementHandle>"
|
||||
|
||||
while True:
|
||||
remaining_ms = max(0, (deadline - time.monotonic()) * 1000)
|
||||
if remaining_ms <= 0:
|
||||
if last_error is not None:
|
||||
raise last_error
|
||||
raise ActionabilityError(label, "timeout", "timeout expired before first check")
|
||||
|
||||
try:
|
||||
if "visible" in checks:
|
||||
try:
|
||||
el.wait_for_element_state("visible", timeout=max(1, min(remaining_ms, 2000)))
|
||||
except Exception:
|
||||
raise ElementNotVisibleError(label)
|
||||
|
||||
if "enabled" in checks:
|
||||
try:
|
||||
el.wait_for_element_state("enabled", timeout=max(1, min(remaining_ms, 2000)))
|
||||
except Exception:
|
||||
raise ElementNotEnabledError(label)
|
||||
|
||||
if "editable" in checks:
|
||||
try:
|
||||
el.wait_for_element_state("editable", timeout=max(1, min(remaining_ms, 2000)))
|
||||
except Exception:
|
||||
raise ElementNotEditableError(label)
|
||||
|
||||
return
|
||||
|
||||
except ActionabilityError as e:
|
||||
last_error = e
|
||||
if time.monotonic() >= deadline:
|
||||
raise last_error
|
||||
_backoff_sleep(attempt)
|
||||
attempt += 1
|
||||
|
||||
|
||||
def check_pointer_events_handle(
|
||||
page: Any,
|
||||
el: Any,
|
||||
x: float,
|
||||
y: float,
|
||||
timeout: float = 5000,
|
||||
) -> None:
|
||||
"""Pointer-events check for ElementHandle."""
|
||||
deadline = time.monotonic() + timeout / 1000.0
|
||||
attempt = 0
|
||||
|
||||
while True:
|
||||
try:
|
||||
box = el.bounding_box()
|
||||
result = el.evaluate(_POINTER_EVENTS_HANDLE_JS, {"x": x, "y": y, "box": box})
|
||||
except Exception:
|
||||
result = None
|
||||
|
||||
# Proceed if the check confirms a hit, or if it could not be determined
|
||||
# (None) — failing closed would block legitimate clicks.
|
||||
if result is None or result.get("hit", False):
|
||||
return
|
||||
|
||||
covering = (result or {}).get("covering", "unknown")
|
||||
|
||||
if time.monotonic() >= deadline:
|
||||
raise ElementNotReceivingEventsError("<ElementHandle>", covering)
|
||||
|
||||
_backoff_sleep(attempt)
|
||||
attempt += 1
|
||||
@@ -0,0 +1,250 @@
|
||||
"""Playwright-style actionability checks for the humanize layer (async).
|
||||
|
||||
Async mirror of actionability.py — same logic, uses asyncio.sleep and await.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from typing import Any, FrozenSet, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
from .actionability import (
|
||||
ActionabilityError,
|
||||
ElementNotAttachedError,
|
||||
ElementNotVisibleError,
|
||||
ElementNotStableError,
|
||||
ElementNotEnabledError,
|
||||
ElementNotEditableError,
|
||||
ElementNotReceivingEventsError,
|
||||
_BACKOFF_MS,
|
||||
_boxes_differ,
|
||||
_POINTER_EVENTS_LOCATOR_JS,
|
||||
_POINTER_EVENTS_HANDLE_JS,
|
||||
)
|
||||
|
||||
|
||||
async def _async_backoff_sleep(attempt: int) -> None:
|
||||
idx = min(attempt, len(_BACKOFF_MS) - 1)
|
||||
await asyncio.sleep(_BACKOFF_MS[idx] / 1000.0)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pre-scroll actionability
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def async_ensure_actionable(
|
||||
page: Any,
|
||||
selector: str,
|
||||
checks: FrozenSet[str],
|
||||
timeout: float = 30000,
|
||||
force: bool = False,
|
||||
) -> None:
|
||||
if force:
|
||||
return
|
||||
|
||||
deadline = time.monotonic() + timeout / 1000.0
|
||||
attempt = 0
|
||||
last_error: Optional[ActionabilityError] = None
|
||||
|
||||
while True:
|
||||
remaining_ms = max(0, (deadline - time.monotonic()) * 1000)
|
||||
if remaining_ms <= 0:
|
||||
if last_error is not None:
|
||||
raise last_error
|
||||
raise ActionabilityError(selector, "timeout", "timeout expired before first check")
|
||||
|
||||
try:
|
||||
loc = page.locator(selector).first
|
||||
|
||||
if "attached" in checks:
|
||||
try:
|
||||
await loc.wait_for(state="attached", timeout=max(1, min(remaining_ms, 2000)))
|
||||
except Exception:
|
||||
raise ElementNotAttachedError(selector)
|
||||
|
||||
if "visible" in checks:
|
||||
if not await loc.is_visible():
|
||||
raise ElementNotVisibleError(selector)
|
||||
|
||||
if "enabled" in checks:
|
||||
if not await loc.is_enabled():
|
||||
raise ElementNotEnabledError(selector)
|
||||
|
||||
if "editable" in checks:
|
||||
if not await loc.is_editable():
|
||||
raise ElementNotEditableError(selector)
|
||||
|
||||
return
|
||||
|
||||
except ActionabilityError as e:
|
||||
last_error = e
|
||||
if time.monotonic() >= deadline:
|
||||
raise last_error
|
||||
await _async_backoff_sleep(attempt)
|
||||
attempt += 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Post-scroll stability check
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def async_ensure_stable(
|
||||
page: Any,
|
||||
selector: str,
|
||||
timeout: float = 5000,
|
||||
) -> None:
|
||||
deadline = time.monotonic() + timeout / 1000.0
|
||||
attempt = 0
|
||||
|
||||
while True:
|
||||
remaining_ms = max(0, (deadline - time.monotonic()) * 1000)
|
||||
if remaining_ms <= 0:
|
||||
raise ElementNotStableError(selector)
|
||||
|
||||
loc = page.locator(selector).first
|
||||
box1 = await loc.bounding_box(timeout=max(1, min(remaining_ms, 1000)))
|
||||
if box1 is None:
|
||||
raise ElementNotAttachedError(selector)
|
||||
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
box2 = await loc.bounding_box(timeout=max(1, min(remaining_ms, 1000)))
|
||||
if box2 is None:
|
||||
raise ElementNotAttachedError(selector)
|
||||
|
||||
if not _boxes_differ(box1, box2):
|
||||
return
|
||||
|
||||
if time.monotonic() >= deadline:
|
||||
raise ElementNotStableError(selector)
|
||||
|
||||
await _async_backoff_sleep(attempt)
|
||||
attempt += 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pointer-events check
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def async_check_pointer_events(
|
||||
page: Any,
|
||||
selector: str,
|
||||
x: float,
|
||||
y: float,
|
||||
stealth: Any = None,
|
||||
timeout: float = 5000,
|
||||
) -> None:
|
||||
deadline = time.monotonic() + timeout / 1000.0
|
||||
attempt = 0
|
||||
|
||||
while True:
|
||||
try:
|
||||
loc = page.locator(selector).first
|
||||
box = await loc.bounding_box(timeout=max(1, min((deadline - time.monotonic()) * 1000, 1000)))
|
||||
result = await loc.evaluate(_POINTER_EVENTS_LOCATOR_JS, {"x": x, "y": y, "box": box})
|
||||
except Exception as exc:
|
||||
logger.debug("pointer_events check failed for %r: %s", selector, exc)
|
||||
result = None
|
||||
|
||||
# Proceed if the check confirms a hit, or if it could not be determined
|
||||
# (None) — failing closed would block legitimate clicks.
|
||||
if result is None or result.get("hit", False):
|
||||
return
|
||||
|
||||
covering = (result or {}).get("covering", "unknown")
|
||||
|
||||
if time.monotonic() >= deadline:
|
||||
raise ElementNotReceivingEventsError(selector, covering)
|
||||
|
||||
await _async_backoff_sleep(attempt)
|
||||
attempt += 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ElementHandle variant
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def async_ensure_actionable_handle(
|
||||
page: Any,
|
||||
el: Any,
|
||||
checks: FrozenSet[str],
|
||||
timeout: float = 30000,
|
||||
force: bool = False,
|
||||
) -> None:
|
||||
if force:
|
||||
return
|
||||
|
||||
deadline = time.monotonic() + timeout / 1000.0
|
||||
attempt = 0
|
||||
last_error: Optional[ActionabilityError] = None
|
||||
label = "<ElementHandle>"
|
||||
|
||||
while True:
|
||||
remaining_ms = max(0, (deadline - time.monotonic()) * 1000)
|
||||
if remaining_ms <= 0:
|
||||
if last_error is not None:
|
||||
raise last_error
|
||||
raise ActionabilityError(label, "timeout", "timeout expired before first check")
|
||||
|
||||
try:
|
||||
if "visible" in checks:
|
||||
try:
|
||||
await el.wait_for_element_state("visible", timeout=max(1, min(remaining_ms, 2000)))
|
||||
except Exception:
|
||||
raise ElementNotVisibleError(label)
|
||||
|
||||
if "enabled" in checks:
|
||||
try:
|
||||
await el.wait_for_element_state("enabled", timeout=max(1, min(remaining_ms, 2000)))
|
||||
except Exception:
|
||||
raise ElementNotEnabledError(label)
|
||||
|
||||
if "editable" in checks:
|
||||
try:
|
||||
await el.wait_for_element_state("editable", timeout=max(1, min(remaining_ms, 2000)))
|
||||
except Exception:
|
||||
raise ElementNotEditableError(label)
|
||||
|
||||
return
|
||||
|
||||
except ActionabilityError as e:
|
||||
last_error = e
|
||||
if time.monotonic() >= deadline:
|
||||
raise last_error
|
||||
await _async_backoff_sleep(attempt)
|
||||
attempt += 1
|
||||
|
||||
|
||||
async def async_check_pointer_events_handle(
|
||||
page: Any,
|
||||
el: Any,
|
||||
x: float,
|
||||
y: float,
|
||||
timeout: float = 5000,
|
||||
) -> None:
|
||||
deadline = time.monotonic() + timeout / 1000.0
|
||||
attempt = 0
|
||||
|
||||
while True:
|
||||
try:
|
||||
box = await el.bounding_box()
|
||||
result = await el.evaluate(_POINTER_EVENTS_HANDLE_JS, {"x": x, "y": y, "box": box})
|
||||
except Exception:
|
||||
result = None
|
||||
|
||||
# Proceed if the check confirms a hit, or if it could not be determined
|
||||
# (None) — failing closed would block legitimate clicks.
|
||||
if result is None or result.get("hit", False):
|
||||
return
|
||||
|
||||
covering = (result or {}).get("covering", "unknown")
|
||||
|
||||
if time.monotonic() >= deadline:
|
||||
raise ElementNotReceivingEventsError("<ElementHandle>", covering)
|
||||
|
||||
await _async_backoff_sleep(attempt)
|
||||
attempt += 1
|
||||
@@ -0,0 +1,257 @@
|
||||
"""cloakbrowser-human — Configuration and presets.
|
||||
|
||||
All numeric parameters for human-like behavior are centralized here.
|
||||
Two built-in presets: 'default' (normal human speed) and 'careful' (slower, more cautious).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import random
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Literal, Tuple, TypedDict
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Type alias
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Range = Tuple[float, float]
|
||||
HumanPreset = Literal["default", "careful"]
|
||||
|
||||
|
||||
class HumanConfigOverrides(TypedDict, total=False):
|
||||
typing_delay: float
|
||||
typing_delay_spread: float
|
||||
typing_pause_chance: float
|
||||
typing_pause_range: Range
|
||||
shift_down_delay: Range
|
||||
shift_up_delay: Range
|
||||
key_hold: Range
|
||||
field_switch_delay: Range
|
||||
mistype_chance: float
|
||||
mistype_delay_notice: Range
|
||||
mistype_delay_correct: Range
|
||||
mouse_steps_divisor: float
|
||||
mouse_min_steps: int
|
||||
mouse_max_steps: int
|
||||
mouse_wobble_max: float
|
||||
mouse_overshoot_chance: float
|
||||
mouse_overshoot_px: Range
|
||||
mouse_burst_size: Range
|
||||
mouse_burst_pause: Range
|
||||
click_aim_delay_input: Range
|
||||
click_aim_delay_button: Range
|
||||
click_hold_input: Range
|
||||
click_hold_button: Range
|
||||
click_input_x_range: Range
|
||||
idle_drift_px: float
|
||||
idle_pause_range: Range
|
||||
scroll_delta_base: Range
|
||||
scroll_delta_variance: float
|
||||
scroll_pause_fast: Range
|
||||
scroll_pause_slow: Range
|
||||
scroll_accel_steps: Range
|
||||
scroll_decel_steps: Range
|
||||
scroll_overshoot_chance: float
|
||||
scroll_overshoot_px: Range
|
||||
scroll_settle_delay: Range
|
||||
scroll_target_zone: Range
|
||||
scroll_pre_move_delay: Range
|
||||
initial_cursor_x: Range
|
||||
initial_cursor_y: Range
|
||||
idle_between_actions: bool
|
||||
idle_between_duration: Range
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Configuration dataclass
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@dataclass
|
||||
class HumanConfig:
|
||||
"""All tunable parameters for human-like behavior."""
|
||||
|
||||
# Keyboard
|
||||
typing_delay: float = 70
|
||||
typing_delay_spread: float = 40
|
||||
typing_pause_chance: float = 0.1
|
||||
typing_pause_range: Range = (400, 1000)
|
||||
shift_down_delay: Range = (30, 70)
|
||||
shift_up_delay: Range = (20, 50)
|
||||
key_hold: Range = (15, 35)
|
||||
|
||||
# Mistype (typo simulation)
|
||||
mistype_chance: float = 0.02
|
||||
mistype_delay_notice: Range = (100, 300)
|
||||
mistype_delay_correct: Range = (50, 150)
|
||||
|
||||
field_switch_delay: Range = (800, 1500)
|
||||
|
||||
# Mouse — movement
|
||||
mouse_steps_divisor: float = 8
|
||||
mouse_min_steps: int = 25
|
||||
mouse_max_steps: int = 80
|
||||
mouse_wobble_max: float = 1.5
|
||||
mouse_overshoot_chance: float = 0.15
|
||||
mouse_overshoot_px: Range = (3, 6)
|
||||
mouse_burst_size: Range = (3, 5)
|
||||
mouse_burst_pause: Range = (8, 18)
|
||||
|
||||
# Mouse — clicks
|
||||
click_aim_delay_input: Range = (60, 140)
|
||||
click_aim_delay_button: Range = (80, 200)
|
||||
click_hold_input: Range = (40, 100)
|
||||
click_hold_button: Range = (60, 150)
|
||||
click_input_x_range: Range = (0.05, 0.30)
|
||||
|
||||
# Mouse — idle
|
||||
idle_drift_px: float = 3
|
||||
idle_pause_range: Range = (300, 1000)
|
||||
|
||||
# Scroll
|
||||
scroll_delta_base: Range = (80, 130)
|
||||
scroll_delta_variance: float = 0.2
|
||||
scroll_pause_fast: Range = (30, 80)
|
||||
scroll_pause_slow: Range = (80, 200)
|
||||
scroll_accel_steps: Range = (2, 3)
|
||||
scroll_decel_steps: Range = (2, 3)
|
||||
scroll_overshoot_chance: float = 0.1
|
||||
scroll_overshoot_px: Range = (50, 150)
|
||||
scroll_settle_delay: Range = (300, 600)
|
||||
scroll_target_zone: Range = (0.20, 0.80)
|
||||
scroll_pre_move_delay: Range = (100, 300)
|
||||
|
||||
# Initial cursor position (as if coming from the address bar area)
|
||||
initial_cursor_x: Range = (400, 700)
|
||||
initial_cursor_y: Range = (45, 60)
|
||||
|
||||
# Idle micro-movements between actions (opt-in, adds latency)
|
||||
idle_between_actions: bool = False
|
||||
idle_between_duration: Range = (0.3, 0.8)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Presets
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _careful_config() -> HumanConfig:
|
||||
"""Careful preset — everything slower and more deliberate."""
|
||||
return HumanConfig(
|
||||
# Keyboard — slower typing
|
||||
typing_delay=100,
|
||||
typing_delay_spread=50,
|
||||
typing_pause_chance=0.15,
|
||||
typing_pause_range=(500, 1200),
|
||||
shift_down_delay=(40, 90),
|
||||
shift_up_delay=(30, 70),
|
||||
key_hold=(20, 45),
|
||||
field_switch_delay=(1000, 2000),
|
||||
# Mouse — slower, more precise
|
||||
mouse_overshoot_chance=0.10,
|
||||
mouse_burst_pause=(12, 25),
|
||||
# Mouse — clicks (longer aiming and holding)
|
||||
click_aim_delay_input=(80, 180),
|
||||
click_aim_delay_button=(120, 280),
|
||||
click_hold_input=(60, 140),
|
||||
click_hold_button=(80, 200),
|
||||
# Scroll — slower
|
||||
scroll_pause_fast=(100, 200),
|
||||
scroll_pause_slow=(250, 600),
|
||||
scroll_settle_delay=(400, 800),
|
||||
scroll_pre_move_delay=(150, 400),
|
||||
# Idle between actions enabled for careful preset
|
||||
idle_between_actions=True,
|
||||
idle_between_duration=(0.4, 1.0),
|
||||
)
|
||||
|
||||
|
||||
_PRESETS: dict[str, HumanConfig] = {
|
||||
"default": HumanConfig(),
|
||||
"careful": _careful_config(),
|
||||
}
|
||||
|
||||
|
||||
def resolve_config(
|
||||
preset: HumanPreset = "default",
|
||||
overrides: HumanConfigOverrides | None = None,
|
||||
) -> HumanConfig:
|
||||
"""Resolve a preset name + optional overrides into a full HumanConfig.
|
||||
|
||||
Args:
|
||||
preset: 'default' or 'careful'.
|
||||
overrides: Typed mapping of HumanConfig field names to override values.
|
||||
|
||||
Returns:
|
||||
A new HumanConfig instance.
|
||||
|
||||
Raises:
|
||||
ValueError: If preset is not a recognized name.
|
||||
"""
|
||||
if preset not in _PRESETS:
|
||||
raise ValueError(
|
||||
f"Unknown humanize preset {preset!r}. "
|
||||
f"Valid presets: {', '.join(sorted(_PRESETS.keys()))}"
|
||||
)
|
||||
base = _PRESETS[preset]
|
||||
if not overrides:
|
||||
return HumanConfig(**{k: getattr(base, k) for k in base.__dataclass_fields__})
|
||||
merged = {k: getattr(base, k) for k in base.__dataclass_fields__}
|
||||
merged.update(overrides)
|
||||
return HumanConfig(**merged)
|
||||
|
||||
|
||||
def merge_config(base: HumanConfig, overrides: dict | None) -> HumanConfig:
|
||||
"""Merge ``overrides`` (a dict of HumanConfig field names → values) on top of
|
||||
``base``. Returns a new HumanConfig — ``base`` is never mutated.
|
||||
|
||||
Used by per-call overrides like ``page.type(sel, text, human_config={...})``
|
||||
so the same page can use different timings for different inputs without
|
||||
re-patching.
|
||||
|
||||
Unknown keys are ignored silently to keep this forgiving for callers.
|
||||
"""
|
||||
if not overrides:
|
||||
return base
|
||||
merged = {k: getattr(base, k) for k in base.__dataclass_fields__}
|
||||
for k, v in overrides.items():
|
||||
if k in base.__dataclass_fields__:
|
||||
merged[k] = v
|
||||
return HumanConfig(**merged)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Utility functions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def rand(lo: float, hi: float) -> float:
|
||||
"""Random float in [lo, hi]."""
|
||||
return random.uniform(lo, hi)
|
||||
|
||||
|
||||
def rand_int(lo: int, hi: int) -> int:
|
||||
"""Random integer in [lo, hi] inclusive."""
|
||||
return random.randint(lo, hi)
|
||||
|
||||
|
||||
def rand_range(r: Range) -> float:
|
||||
"""Random float from a (min, max) tuple."""
|
||||
return random.uniform(r[0], r[1])
|
||||
|
||||
|
||||
def rand_int_range(r: Range) -> int:
|
||||
"""Random integer from a (min, max) tuple, inclusive."""
|
||||
return random.randint(int(r[0]), int(r[1]))
|
||||
|
||||
|
||||
def sleep_ms(ms: float) -> None:
|
||||
"""Sleep for `ms` milliseconds."""
|
||||
if ms > 0:
|
||||
time.sleep(ms / 1000.0)
|
||||
|
||||
|
||||
async def async_sleep_ms(ms: float) -> None:
|
||||
"""Async sleep for `ms` milliseconds."""
|
||||
if ms > 0:
|
||||
import asyncio
|
||||
await asyncio.sleep(ms / 1000.0)
|
||||
@@ -0,0 +1,189 @@
|
||||
"""cloakbrowser-human — Human-like keyboard input.
|
||||
|
||||
Stealth-aware: when a CDP session is provided, shift symbols are typed
|
||||
via CDP Input.dispatchKeyEvent (isTrusted=true, no evaluate stack trace).
|
||||
Falls back to page.evaluate when no CDP session is available.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
from typing import Any, Optional, Protocol
|
||||
|
||||
from .config import HumanConfig, rand, rand_range, sleep_ms
|
||||
|
||||
|
||||
class RawKeyboard(Protocol):
|
||||
def down(self, key: str) -> None: ...
|
||||
def up(self, key: str) -> None: ...
|
||||
def type(self, text: str) -> None: ...
|
||||
def insert_text(self, text: str) -> None: ...
|
||||
|
||||
|
||||
SHIFT_SYMBOLS = frozenset('@#!$%^&*()_+{}|:"<>?~')
|
||||
|
||||
NEARBY_KEYS = {
|
||||
'a': 'sqwz', 'b': 'vghn', 'c': 'xdfv', 'd': 'sfecx', 'e': 'wrsdf',
|
||||
'f': 'dgrtcv', 'g': 'fhtyb', 'h': 'gjybn', 'i': 'ujko', 'j': 'hkunm',
|
||||
'k': 'jloi', 'l': 'kop', 'm': 'njk', 'n': 'bhjm', 'o': 'iklp',
|
||||
'p': 'ol', 'q': 'wa', 'r': 'edft', 's': 'awedxz', 't': 'rfgy',
|
||||
'u': 'yhji', 'v': 'cfgb', 'w': 'qase', 'x': 'zsdc', 'y': 'tghu',
|
||||
'z': 'asx',
|
||||
'1': '2q', '2': '13qw', '3': '24we', '4': '35er', '5': '46rt',
|
||||
'6': '57ty', '7': '68yu', '8': '79ui', '9': '80io', '0': '9p',
|
||||
}
|
||||
|
||||
# CDP key code for each shift symbol's physical key.
|
||||
_SHIFT_SYMBOL_CODES: dict[str, str] = {
|
||||
'!': 'Digit1', '@': 'Digit2', '#': 'Digit3', '$': 'Digit4',
|
||||
'%': 'Digit5', '^': 'Digit6', '&': 'Digit7', '*': 'Digit8',
|
||||
'(': 'Digit9', ')': 'Digit0', '_': 'Minus', '+': 'Equal',
|
||||
'{': 'BracketLeft', '}': 'BracketRight', '|': 'Backslash',
|
||||
':': 'Semicolon', '"': 'Quote', '<': 'Comma', '>': 'Period',
|
||||
'?': 'Slash', '~': 'Backquote',
|
||||
}
|
||||
|
||||
# Windows virtual key codes for Input.dispatchKeyEvent.
|
||||
_SHIFT_SYMBOL_KEYCODES: dict[str, int] = {
|
||||
'!': 49, '@': 50, '#': 51, '$': 52, '%': 53,
|
||||
'^': 54, '&': 55, '*': 56, '(': 57, ')': 48,
|
||||
'_': 189, '+': 187, '{': 219, '}': 221, '|': 220,
|
||||
':': 186, '"': 222, '<': 188, '>': 190, '?': 191,
|
||||
'~': 192,
|
||||
}
|
||||
|
||||
|
||||
def _get_nearby_key(ch: str) -> str:
|
||||
"""Return a random adjacent key for the given character."""
|
||||
lower = ch.lower()
|
||||
if lower in NEARBY_KEYS:
|
||||
neighbors = NEARBY_KEYS[lower]
|
||||
wrong = random.choice(neighbors)
|
||||
return wrong.upper() if ch.isupper() else wrong
|
||||
return ch
|
||||
|
||||
|
||||
def human_type(
|
||||
page: Any, raw: RawKeyboard, text: str, cfg: HumanConfig,
|
||||
cdp_session: Any = None,
|
||||
) -> None:
|
||||
"""Type text with human-like per-character timing.
|
||||
|
||||
Args:
|
||||
cdp_session: If provided, shift symbols use CDP Input.dispatchKeyEvent
|
||||
producing isTrusted=true events with no evaluate stack trace.
|
||||
If None, falls back to page.evaluate (detectable).
|
||||
"""
|
||||
for i, ch in enumerate(text):
|
||||
# Non-ASCII characters (Cyrillic, CJK, emoji) — use insertText
|
||||
if not ch.isascii():
|
||||
sleep_ms(rand_range(cfg.key_hold))
|
||||
raw.insert_text(ch)
|
||||
if i < len(text) - 1:
|
||||
_inter_char_delay(cfg)
|
||||
continue
|
||||
|
||||
# Mistype chance — only for ASCII alphanumeric
|
||||
if random.random() < cfg.mistype_chance and ch.isalnum():
|
||||
wrong = _get_nearby_key(ch)
|
||||
_type_normal_char(raw, wrong, cfg)
|
||||
sleep_ms(rand_range(cfg.mistype_delay_notice))
|
||||
raw.down("Backspace")
|
||||
sleep_ms(rand_range(cfg.key_hold))
|
||||
raw.up("Backspace")
|
||||
sleep_ms(rand_range(cfg.mistype_delay_correct))
|
||||
|
||||
if ch.isupper() and ch.isalpha():
|
||||
_type_shifted_char(page, raw, ch, cfg)
|
||||
elif ch in SHIFT_SYMBOLS:
|
||||
_type_shift_symbol(page, raw, ch, cfg, cdp_session)
|
||||
else:
|
||||
_type_normal_char(raw, ch, cfg)
|
||||
|
||||
if i < len(text) - 1:
|
||||
_inter_char_delay(cfg)
|
||||
|
||||
|
||||
def _type_normal_char(raw: RawKeyboard, ch: str, cfg: HumanConfig) -> None:
|
||||
raw.down(ch)
|
||||
sleep_ms(rand_range(cfg.key_hold))
|
||||
raw.up(ch)
|
||||
|
||||
|
||||
def _type_shifted_char(page: Any, raw: RawKeyboard, ch: str, cfg: HumanConfig) -> None:
|
||||
raw.down("Shift")
|
||||
sleep_ms(rand_range(cfg.shift_down_delay))
|
||||
raw.down(ch)
|
||||
sleep_ms(rand_range(cfg.key_hold))
|
||||
raw.up(ch)
|
||||
sleep_ms(rand_range(cfg.shift_up_delay))
|
||||
raw.up("Shift")
|
||||
|
||||
|
||||
def _type_shift_symbol(
|
||||
page: Any, raw: RawKeyboard, ch: str, cfg: HumanConfig,
|
||||
cdp_session: Any = None,
|
||||
) -> None:
|
||||
"""Type a shift symbol character.
|
||||
|
||||
Stealth path (cdp_session provided):
|
||||
Uses CDP Input.dispatchKeyEvent → isTrusted=true, clean stack.
|
||||
|
||||
Fallback path (no cdp_session):
|
||||
Uses raw.insertText + page.evaluate to dispatch synthetic KeyboardEvent.
|
||||
Detectable via isTrusted=false and evaluate stack frame.
|
||||
"""
|
||||
if cdp_session is not None:
|
||||
# --- Stealth path: CDP Input.dispatchKeyEvent ---
|
||||
code = _SHIFT_SYMBOL_CODES.get(ch, '')
|
||||
key_code = _SHIFT_SYMBOL_KEYCODES.get(ch, 0)
|
||||
|
||||
raw.down("Shift")
|
||||
sleep_ms(rand_range(cfg.shift_down_delay))
|
||||
|
||||
cdp_session.send("Input.dispatchKeyEvent", {
|
||||
"type": "keyDown",
|
||||
"modifiers": 8, # Shift modifier flag
|
||||
"key": ch,
|
||||
"code": code,
|
||||
"windowsVirtualKeyCode": key_code,
|
||||
"text": ch,
|
||||
"unmodifiedText": ch,
|
||||
})
|
||||
sleep_ms(rand_range(cfg.key_hold))
|
||||
|
||||
cdp_session.send("Input.dispatchKeyEvent", {
|
||||
"type": "keyUp",
|
||||
"modifiers": 8,
|
||||
"key": ch,
|
||||
"code": code,
|
||||
"windowsVirtualKeyCode": key_code,
|
||||
})
|
||||
|
||||
sleep_ms(rand_range(cfg.shift_up_delay))
|
||||
raw.up("Shift")
|
||||
else:
|
||||
# --- Fallback path: page.evaluate (detectable) ---
|
||||
raw.down("Shift")
|
||||
sleep_ms(rand_range(cfg.shift_down_delay))
|
||||
raw.insert_text(ch)
|
||||
page.evaluate(
|
||||
"""(key) => {
|
||||
const el = document.activeElement;
|
||||
if (el) {
|
||||
el.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true }));
|
||||
el.dispatchEvent(new KeyboardEvent('keyup', { key, bubbles: true }));
|
||||
}
|
||||
}""",
|
||||
ch,
|
||||
)
|
||||
sleep_ms(rand_range(cfg.shift_up_delay))
|
||||
raw.up("Shift")
|
||||
|
||||
|
||||
def _inter_char_delay(cfg: HumanConfig) -> None:
|
||||
if random.random() < cfg.typing_pause_chance:
|
||||
sleep_ms(rand_range(cfg.typing_pause_range))
|
||||
else:
|
||||
delay = cfg.typing_delay + (random.random() - 0.5) * 2 * cfg.typing_delay_spread
|
||||
sleep_ms(max(10, delay))
|
||||
@@ -0,0 +1,150 @@
|
||||
"""cloakbrowser-human — Async human-like keyboard input.
|
||||
|
||||
Mirrors keyboard.py but uses ``await`` for all Playwright calls and
|
||||
``async_sleep_ms`` instead of ``sleep_ms``.
|
||||
|
||||
Stealth-aware: when a CDP session is provided, shift symbols are typed
|
||||
via CDP Input.dispatchKeyEvent (isTrusted=true, no evaluate stack trace).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
from typing import Any, Optional, Protocol
|
||||
|
||||
from .config import HumanConfig, rand, rand_range, async_sleep_ms
|
||||
from .keyboard import SHIFT_SYMBOLS, NEARBY_KEYS, _get_nearby_key
|
||||
from .keyboard import _SHIFT_SYMBOL_CODES, _SHIFT_SYMBOL_KEYCODES
|
||||
|
||||
|
||||
class AsyncRawKeyboard(Protocol):
|
||||
async def down(self, key: str) -> None: ...
|
||||
async def up(self, key: str) -> None: ...
|
||||
async def type(self, text: str) -> None: ...
|
||||
async def insert_text(self, text: str) -> None: ...
|
||||
|
||||
|
||||
async def async_human_type(
|
||||
page: Any, raw: AsyncRawKeyboard, text: str, cfg: HumanConfig,
|
||||
cdp_session: Any = None,
|
||||
) -> None:
|
||||
"""Type text with human-like per-character timing (async).
|
||||
|
||||
Args:
|
||||
cdp_session: If provided, shift symbols use CDP Input.dispatchKeyEvent
|
||||
producing isTrusted=true events with no evaluate stack trace.
|
||||
If None, falls back to page.evaluate (detectable).
|
||||
"""
|
||||
for i, ch in enumerate(text):
|
||||
# Non-ASCII characters (Cyrillic, CJK, emoji) — use insertText
|
||||
if not ch.isascii():
|
||||
await async_sleep_ms(rand_range(cfg.key_hold))
|
||||
await raw.insert_text(ch)
|
||||
if i < len(text) - 1:
|
||||
await _inter_char_delay(cfg)
|
||||
continue
|
||||
|
||||
# Mistype chance — only for ASCII alphanumeric
|
||||
if random.random() < cfg.mistype_chance and ch.isalnum():
|
||||
wrong = _get_nearby_key(ch)
|
||||
await _type_normal_char(raw, wrong, cfg)
|
||||
await async_sleep_ms(rand_range(cfg.mistype_delay_notice))
|
||||
await raw.down("Backspace")
|
||||
await async_sleep_ms(rand_range(cfg.key_hold))
|
||||
await raw.up("Backspace")
|
||||
await async_sleep_ms(rand_range(cfg.mistype_delay_correct))
|
||||
|
||||
if ch.isupper() and ch.isalpha():
|
||||
await _type_shifted_char(page, raw, ch, cfg)
|
||||
elif ch in SHIFT_SYMBOLS:
|
||||
await _type_shift_symbol(page, raw, ch, cfg, cdp_session)
|
||||
else:
|
||||
await _type_normal_char(raw, ch, cfg)
|
||||
|
||||
if i < len(text) - 1:
|
||||
await _inter_char_delay(cfg)
|
||||
|
||||
|
||||
async def _type_normal_char(raw: AsyncRawKeyboard, ch: str, cfg: HumanConfig) -> None:
|
||||
await raw.down(ch)
|
||||
await async_sleep_ms(rand_range(cfg.key_hold))
|
||||
await raw.up(ch)
|
||||
|
||||
|
||||
async def _type_shifted_char(page: Any, raw: AsyncRawKeyboard, ch: str, cfg: HumanConfig) -> None:
|
||||
await raw.down("Shift")
|
||||
await async_sleep_ms(rand_range(cfg.shift_down_delay))
|
||||
await raw.down(ch)
|
||||
await async_sleep_ms(rand_range(cfg.key_hold))
|
||||
await raw.up(ch)
|
||||
await async_sleep_ms(rand_range(cfg.shift_up_delay))
|
||||
await raw.up("Shift")
|
||||
|
||||
|
||||
async def _type_shift_symbol(
|
||||
page: Any, raw: AsyncRawKeyboard, ch: str, cfg: HumanConfig,
|
||||
cdp_session: Any = None,
|
||||
) -> None:
|
||||
"""Type a shift symbol character (async).
|
||||
|
||||
Stealth path (cdp_session provided):
|
||||
Uses CDP Input.dispatchKeyEvent → isTrusted=true, clean stack.
|
||||
|
||||
Fallback path (no cdp_session):
|
||||
Uses raw.insertText + page.evaluate to dispatch synthetic KeyboardEvent.
|
||||
Detectable via isTrusted=false and evaluate stack frame.
|
||||
"""
|
||||
if cdp_session is not None:
|
||||
# --- Stealth path: CDP Input.dispatchKeyEvent ---
|
||||
code = _SHIFT_SYMBOL_CODES.get(ch, '')
|
||||
key_code = _SHIFT_SYMBOL_KEYCODES.get(ch, 0)
|
||||
|
||||
await raw.down("Shift")
|
||||
await async_sleep_ms(rand_range(cfg.shift_down_delay))
|
||||
|
||||
await cdp_session.send("Input.dispatchKeyEvent", {
|
||||
"type": "keyDown",
|
||||
"modifiers": 8, # Shift modifier flag
|
||||
"key": ch,
|
||||
"code": code,
|
||||
"windowsVirtualKeyCode": key_code,
|
||||
"text": ch,
|
||||
"unmodifiedText": ch,
|
||||
})
|
||||
await async_sleep_ms(rand_range(cfg.key_hold))
|
||||
|
||||
await cdp_session.send("Input.dispatchKeyEvent", {
|
||||
"type": "keyUp",
|
||||
"modifiers": 8,
|
||||
"key": ch,
|
||||
"code": code,
|
||||
"windowsVirtualKeyCode": key_code,
|
||||
})
|
||||
|
||||
await async_sleep_ms(rand_range(cfg.shift_up_delay))
|
||||
await raw.up("Shift")
|
||||
else:
|
||||
# --- Fallback path: page.evaluate (detectable) ---
|
||||
await raw.down("Shift")
|
||||
await async_sleep_ms(rand_range(cfg.shift_down_delay))
|
||||
await raw.insert_text(ch)
|
||||
await page.evaluate(
|
||||
"""(key) => {
|
||||
const el = document.activeElement;
|
||||
if (el) {
|
||||
el.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true }));
|
||||
el.dispatchEvent(new KeyboardEvent('keyup', { key, bubbles: true }));
|
||||
}
|
||||
}""",
|
||||
ch,
|
||||
)
|
||||
await async_sleep_ms(rand_range(cfg.shift_up_delay))
|
||||
await raw.up("Shift")
|
||||
|
||||
|
||||
async def _inter_char_delay(cfg: HumanConfig) -> None:
|
||||
if random.random() < cfg.typing_pause_chance:
|
||||
await async_sleep_ms(rand_range(cfg.typing_pause_range))
|
||||
else:
|
||||
delay = cfg.typing_delay + (random.random() - 0.5) * 2 * cfg.typing_delay_spread
|
||||
await async_sleep_ms(max(10, delay))
|
||||
@@ -0,0 +1,132 @@
|
||||
"""cloakbrowser-human — Human-like mouse movement and clicking."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import random
|
||||
from typing import Any, Protocol, Tuple
|
||||
|
||||
from .config import HumanConfig, rand, rand_range, rand_int_range, sleep_ms
|
||||
|
||||
|
||||
class RawMouse(Protocol):
|
||||
def move(self, x: float, y: float) -> None: ...
|
||||
def down(self) -> None: ...
|
||||
def up(self) -> None: ...
|
||||
def wheel(self, delta_x: float, delta_y: float) -> None: ...
|
||||
|
||||
|
||||
class Point:
|
||||
__slots__ = ("x", "y")
|
||||
def __init__(self, x: float, y: float):
|
||||
self.x = x
|
||||
self.y = y
|
||||
|
||||
|
||||
def _ease_in_out(t: float) -> float:
|
||||
if t < 0.5:
|
||||
return 4 * t * t * t
|
||||
return 1 - pow(-2 * t + 2, 3) / 2
|
||||
|
||||
|
||||
def _bezier(p0: Point, p1: Point, p2: Point, p3: Point, t: float) -> Point:
|
||||
u = 1 - t
|
||||
uu = u * u
|
||||
uuu = uu * u
|
||||
tt = t * t
|
||||
ttt = tt * t
|
||||
return Point(
|
||||
uuu * p0.x + 3 * uu * t * p1.x + 3 * u * tt * p2.x + ttt * p3.x,
|
||||
uuu * p0.y + 3 * uu * t * p1.y + 3 * u * tt * p2.y + ttt * p3.y,
|
||||
)
|
||||
|
||||
|
||||
def _random_control_points(start: Point, end: Point) -> Tuple[Point, Point]:
|
||||
dx = end.x - start.x
|
||||
dy = end.y - start.y
|
||||
dist = math.hypot(dx, dy) or 1
|
||||
px = -dy / dist
|
||||
py = dx / dist
|
||||
bias1 = rand(-0.3, 0.3) * dist
|
||||
bias2 = rand(-0.3, 0.3) * dist
|
||||
return (
|
||||
Point(start.x + dx * 0.25 + px * bias1, start.y + dy * 0.25 + py * bias1),
|
||||
Point(start.x + dx * 0.75 + px * bias2, start.y + dy * 0.75 + py * bias2),
|
||||
)
|
||||
|
||||
|
||||
def human_move(
|
||||
raw: RawMouse,
|
||||
start_x: float, start_y: float,
|
||||
end_x: float, end_y: float,
|
||||
cfg: HumanConfig,
|
||||
) -> None:
|
||||
dist = math.hypot(end_x - start_x, end_y - start_y)
|
||||
if dist < 1:
|
||||
return
|
||||
|
||||
steps = max(cfg.mouse_min_steps, min(cfg.mouse_max_steps, round(dist / cfg.mouse_steps_divisor)))
|
||||
start = Point(start_x, start_y)
|
||||
end = Point(end_x, end_y)
|
||||
cp1, cp2 = _random_control_points(start, end)
|
||||
|
||||
burst_counter = 0
|
||||
burst_size = rand_int_range(cfg.mouse_burst_size)
|
||||
|
||||
for i in range(steps + 1):
|
||||
progress = i / steps
|
||||
eased_t = _ease_in_out(progress)
|
||||
pt = _bezier(start, cp1, cp2, end, eased_t)
|
||||
|
||||
wobble_amp = math.sin(math.pi * progress) * cfg.mouse_wobble_max
|
||||
wx = pt.x + (random.random() - 0.5) * 2 * wobble_amp
|
||||
wy = pt.y + (random.random() - 0.5) * 2 * wobble_amp
|
||||
|
||||
raw.move(round(wx), round(wy))
|
||||
|
||||
burst_counter += 1
|
||||
if burst_counter >= burst_size and i < steps:
|
||||
sleep_ms(rand_range(cfg.mouse_burst_pause))
|
||||
burst_counter = 0
|
||||
|
||||
if random.random() < cfg.mouse_overshoot_chance:
|
||||
overshoot_dist = rand_range(cfg.mouse_overshoot_px)
|
||||
angle = math.atan2(end_y - start_y, end_x - start_x)
|
||||
raw.move(round(end_x + math.cos(angle) * overshoot_dist),
|
||||
round(end_y + math.sin(angle) * overshoot_dist))
|
||||
sleep_ms(rand(30, 70))
|
||||
raw.move(round(end_x + (random.random() - 0.5) * 4),
|
||||
round(end_y + (random.random() - 0.5) * 4))
|
||||
|
||||
|
||||
def click_target(box: dict, is_input: bool, cfg: HumanConfig) -> Point:
|
||||
if is_input:
|
||||
x_frac = rand_range(cfg.click_input_x_range)
|
||||
y_frac = rand(0.30, 0.70)
|
||||
else:
|
||||
x_frac = rand(0.35, 0.65)
|
||||
y_frac = rand(0.35, 0.65)
|
||||
return Point(round(box["x"] + box["width"] * x_frac),
|
||||
round(box["y"] + box["height"] * y_frac))
|
||||
|
||||
|
||||
def human_click(raw: RawMouse, is_input: bool, cfg: HumanConfig) -> None:
|
||||
aim_delay = rand_range(cfg.click_aim_delay_input) if is_input else rand_range(cfg.click_aim_delay_button)
|
||||
sleep_ms(aim_delay)
|
||||
hold_time = rand_range(cfg.click_hold_input) if is_input else rand_range(cfg.click_hold_button)
|
||||
raw.down()
|
||||
sleep_ms(hold_time)
|
||||
raw.up()
|
||||
|
||||
|
||||
def human_idle(raw: RawMouse, seconds: float, cx: float, cy: float, cfg: HumanConfig) -> None:
|
||||
import time as _time
|
||||
end_time = _time.monotonic() + seconds
|
||||
x, y = cx, cy
|
||||
while _time.monotonic() < end_time:
|
||||
dx = (random.random() - 0.5) * 2 * cfg.idle_drift_px
|
||||
dy = (random.random() - 0.5) * 2 * cfg.idle_drift_px
|
||||
x += dx
|
||||
y += dy
|
||||
raw.move(round(x), round(y))
|
||||
sleep_ms(rand_range(cfg.idle_pause_range))
|
||||
@@ -0,0 +1,87 @@
|
||||
"""cloakbrowser-human — Async human-like mouse movement and clicking.
|
||||
|
||||
Mirrors mouse.py but uses ``await`` for all Playwright calls and
|
||||
``async_sleep_ms`` instead of ``sleep_ms``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import random
|
||||
from typing import Any, Protocol
|
||||
|
||||
from .config import HumanConfig, rand, rand_range, rand_int_range, async_sleep_ms
|
||||
from .mouse import Point, _ease_in_out, _bezier, _random_control_points, click_target # noqa: reuse pure math
|
||||
|
||||
|
||||
class AsyncRawMouse(Protocol):
|
||||
async def move(self, x: float, y: float) -> None: ...
|
||||
async def down(self) -> None: ...
|
||||
async def up(self) -> None: ...
|
||||
async def wheel(self, delta_x: float, delta_y: float) -> None: ...
|
||||
|
||||
|
||||
async def async_human_move(
|
||||
raw: AsyncRawMouse,
|
||||
start_x: float, start_y: float,
|
||||
end_x: float, end_y: float,
|
||||
cfg: HumanConfig,
|
||||
) -> None:
|
||||
dist = math.hypot(end_x - start_x, end_y - start_y)
|
||||
if dist < 1:
|
||||
return
|
||||
|
||||
steps = max(cfg.mouse_min_steps, min(cfg.mouse_max_steps, round(dist / cfg.mouse_steps_divisor)))
|
||||
start = Point(start_x, start_y)
|
||||
end = Point(end_x, end_y)
|
||||
cp1, cp2 = _random_control_points(start, end)
|
||||
|
||||
burst_counter = 0
|
||||
burst_size = rand_int_range(cfg.mouse_burst_size)
|
||||
|
||||
for i in range(steps + 1):
|
||||
progress = i / steps
|
||||
eased_t = _ease_in_out(progress)
|
||||
pt = _bezier(start, cp1, cp2, end, eased_t)
|
||||
|
||||
wobble_amp = math.sin(math.pi * progress) * cfg.mouse_wobble_max
|
||||
wx = pt.x + (random.random() - 0.5) * 2 * wobble_amp
|
||||
wy = pt.y + (random.random() - 0.5) * 2 * wobble_amp
|
||||
|
||||
await raw.move(round(wx), round(wy))
|
||||
|
||||
burst_counter += 1
|
||||
if burst_counter >= burst_size and i < steps:
|
||||
await async_sleep_ms(rand_range(cfg.mouse_burst_pause))
|
||||
burst_counter = 0
|
||||
|
||||
if random.random() < cfg.mouse_overshoot_chance:
|
||||
overshoot_dist = rand_range(cfg.mouse_overshoot_px)
|
||||
angle = math.atan2(end_y - start_y, end_x - start_x)
|
||||
await raw.move(round(end_x + math.cos(angle) * overshoot_dist),
|
||||
round(end_y + math.sin(angle) * overshoot_dist))
|
||||
await async_sleep_ms(rand(30, 70))
|
||||
await raw.move(round(end_x + (random.random() - 0.5) * 4),
|
||||
round(end_y + (random.random() - 0.5) * 4))
|
||||
|
||||
|
||||
async def async_human_click(raw: AsyncRawMouse, is_input: bool, cfg: HumanConfig) -> None:
|
||||
aim_delay = rand_range(cfg.click_aim_delay_input) if is_input else rand_range(cfg.click_aim_delay_button)
|
||||
await async_sleep_ms(aim_delay)
|
||||
hold_time = rand_range(cfg.click_hold_input) if is_input else rand_range(cfg.click_hold_button)
|
||||
await raw.down()
|
||||
await async_sleep_ms(hold_time)
|
||||
await raw.up()
|
||||
|
||||
|
||||
async def async_human_idle(raw: AsyncRawMouse, seconds: float, cx: float, cy: float, cfg: HumanConfig) -> None:
|
||||
import time as _time
|
||||
end_time = _time.monotonic() + seconds
|
||||
x, y = cx, cy
|
||||
while _time.monotonic() < end_time:
|
||||
dx = (random.random() - 0.5) * 2 * cfg.idle_drift_px
|
||||
dy = (random.random() - 0.5) * 2 * cfg.idle_drift_px
|
||||
x += dx
|
||||
y += dy
|
||||
await raw.move(round(x), round(y))
|
||||
await async_sleep_ms(rand_range(cfg.idle_pause_range))
|
||||
@@ -0,0 +1,175 @@
|
||||
"""cloakbrowser-human — Human-like scrolling via mouse wheel events."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import random
|
||||
from typing import Any, Callable, Optional, Tuple
|
||||
|
||||
from .config import HumanConfig, rand, rand_range, rand_int_range, sleep_ms
|
||||
from .mouse import RawMouse, human_move
|
||||
|
||||
|
||||
def _is_in_viewport(bounds: dict, viewport_height: int, cfg: HumanConfig) -> bool:
|
||||
top_edge = bounds["y"]
|
||||
bottom_edge = bounds["y"] + bounds["height"]
|
||||
zone_top = viewport_height * cfg.scroll_target_zone[0]
|
||||
zone_bottom = viewport_height * cfg.scroll_target_zone[1]
|
||||
return top_edge >= zone_top and bottom_edge <= zone_bottom
|
||||
|
||||
|
||||
def _get_element_box(page: Any, selector: str, timeout: float = 30000) -> Optional[dict]:
|
||||
"""Locate ``selector`` and return its bounding box.
|
||||
|
||||
The ``timeout`` is forwarded to Playwright's ``boundingBox(timeout=...)``
|
||||
so callers can extend it for slow-loading elements (#172).
|
||||
"""
|
||||
try:
|
||||
el = page.locator(selector).first
|
||||
return el.bounding_box(timeout=max(1, timeout))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _smooth_wheel(raw: RawMouse, delta: int, cfg: HumanConfig) -> None:
|
||||
"""Send one logical scroll as a burst of small wheel events (like real inertia)."""
|
||||
abs_d = abs(delta)
|
||||
sign = 1 if delta > 0 else -1
|
||||
sent = 0
|
||||
while sent < abs_d:
|
||||
step_size = rand(20, 40)
|
||||
chunk = min(step_size, abs_d - sent)
|
||||
raw.wheel(0, round(chunk) * sign)
|
||||
sent += chunk
|
||||
sleep_ms(rand(8, 20))
|
||||
|
||||
|
||||
def human_scroll_into_view(
|
||||
page: Any,
|
||||
raw: RawMouse,
|
||||
get_box: Callable[[], Optional[dict]],
|
||||
cursor_x: float, cursor_y: float,
|
||||
cfg: HumanConfig,
|
||||
) -> Tuple[dict, float, float, bool]:
|
||||
"""Humanized scrolling that uses an arbitrary ``get_box`` callable
|
||||
instead of a CSS selector.
|
||||
|
||||
Used both by ``scroll_to_element`` (selector-based) and by
|
||||
``ElementHandle.scroll_into_view_if_needed`` / ``Locator.scroll_into_view_if_needed``
|
||||
(handle-based) so the same accelerate \u2192 cruise \u2192 decelerate \u2192 overshoot
|
||||
behavior runs everywhere.
|
||||
|
||||
Returns ``(box, cursor_x, cursor_y, did_scroll)`` \u2014 *did_scroll* is False
|
||||
when the element was already in the viewport.
|
||||
"""
|
||||
viewport = page.viewport_size
|
||||
if not viewport:
|
||||
# Headed launches default to no_viewport so the page tracks the real OS
|
||||
# window; page.viewport_size is then None. Fall back to the live window
|
||||
# dimensions so humanize works headed (the stealth-relevant mode).
|
||||
viewport = page.evaluate(
|
||||
"() => ({ width: window.innerWidth, height: window.innerHeight })"
|
||||
)
|
||||
if not viewport or not viewport.get("height"):
|
||||
raise RuntimeError("Viewport size not available")
|
||||
|
||||
viewport_height = viewport["height"]
|
||||
viewport_width = viewport["width"]
|
||||
|
||||
box = get_box()
|
||||
if box is None:
|
||||
raise RuntimeError("Element not found while scrolling into view")
|
||||
|
||||
if _is_in_viewport(box, viewport_height, cfg):
|
||||
return box, cursor_x, cursor_y, False
|
||||
|
||||
# Move cursor into scroll area
|
||||
scroll_area_x = round(viewport_width * rand(0.3, 0.7))
|
||||
scroll_area_y = round(viewport_height * rand(0.3, 0.7))
|
||||
human_move(raw, cursor_x, cursor_y, scroll_area_x, scroll_area_y, cfg)
|
||||
cursor_x = scroll_area_x
|
||||
cursor_y = scroll_area_y
|
||||
sleep_ms(rand_range(cfg.scroll_pre_move_delay))
|
||||
|
||||
# Calculate scroll distance
|
||||
target_y = viewport_height * rand(cfg.scroll_target_zone[0], cfg.scroll_target_zone[1])
|
||||
element_center = box["y"] + box["height"] / 2
|
||||
distance_to_scroll = element_center - target_y
|
||||
|
||||
direction = 1 if distance_to_scroll > 0 else -1
|
||||
abs_distance = abs(distance_to_scroll)
|
||||
avg_delta = (cfg.scroll_delta_base[0] + cfg.scroll_delta_base[1]) / 2
|
||||
total_clicks = max(3, math.ceil(abs_distance / avg_delta))
|
||||
accel_steps = rand_int_range(cfg.scroll_accel_steps)
|
||||
decel_steps = rand_int_range(cfg.scroll_decel_steps)
|
||||
|
||||
# Scroll loop: accelerate → cruise → decelerate
|
||||
scrolled = 0
|
||||
for i in range(total_clicks):
|
||||
if i < accel_steps:
|
||||
delta = rand(80, 100)
|
||||
pause = rand_range(cfg.scroll_pause_slow)
|
||||
elif i >= total_clicks - decel_steps:
|
||||
delta = rand(60, 90)
|
||||
pause = rand_range(cfg.scroll_pause_slow)
|
||||
else:
|
||||
delta = rand_range(cfg.scroll_delta_base)
|
||||
pause = rand_range(cfg.scroll_pause_fast)
|
||||
|
||||
delta *= 1 + (random.random() - 0.5) * 2 * cfg.scroll_delta_variance
|
||||
delta = round(delta) * direction
|
||||
|
||||
_smooth_wheel(raw, delta, cfg)
|
||||
scrolled += abs(delta)
|
||||
sleep_ms(pause)
|
||||
|
||||
# Check visibility every 3 steps
|
||||
if i % 3 == 2 or i == total_clicks - 1:
|
||||
box = get_box()
|
||||
if box and _is_in_viewport(box, viewport_height, cfg):
|
||||
break
|
||||
if scrolled >= abs_distance * 1.1:
|
||||
break
|
||||
|
||||
# Optional overshoot + correction
|
||||
if random.random() < cfg.scroll_overshoot_chance:
|
||||
overshoot_px = round(rand_range(cfg.scroll_overshoot_px)) * direction
|
||||
_smooth_wheel(raw, overshoot_px, cfg)
|
||||
sleep_ms(rand_range(cfg.scroll_settle_delay))
|
||||
corrections = rand_int_range((1, 2))
|
||||
for _ in range(corrections):
|
||||
corr_delta = round(rand(40, 80)) * -direction
|
||||
_smooth_wheel(raw, corr_delta, cfg)
|
||||
sleep_ms(rand(100, 250))
|
||||
|
||||
# Settle
|
||||
sleep_ms(rand_range(cfg.scroll_settle_delay))
|
||||
|
||||
box = get_box()
|
||||
if box is None:
|
||||
raise RuntimeError("Element lost after scrolling into view")
|
||||
|
||||
return box, cursor_x, cursor_y, True
|
||||
|
||||
|
||||
def scroll_to_element(
|
||||
page: Any,
|
||||
raw: RawMouse,
|
||||
selector: str,
|
||||
cursor_x: float, cursor_y: float,
|
||||
cfg: HumanConfig,
|
||||
timeout: float = 30000,
|
||||
) -> Tuple[dict, float, float, bool]:
|
||||
"""Selector-based humanized scroll.
|
||||
|
||||
``timeout`` is forwarded to ``locator.bounding_box(timeout=...)`` so callers
|
||||
such as ``page.click('#x', timeout=5000)`` can wait longer for slow elements
|
||||
(#172). Default matches Playwright's 30000ms when not specified.
|
||||
|
||||
Returns ``(box, cursor_x, cursor_y, did_scroll)``.
|
||||
"""
|
||||
return human_scroll_into_view(
|
||||
page, raw,
|
||||
lambda: _get_element_box(page, selector, timeout),
|
||||
cursor_x, cursor_y, cfg,
|
||||
)
|
||||
@@ -0,0 +1,171 @@
|
||||
"""cloakbrowser-human — Async human-like scrolling via mouse wheel events.
|
||||
|
||||
Mirrors scroll.py but uses ``await`` for all Playwright calls and
|
||||
``async_sleep_ms`` instead of ``sleep_ms``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import random
|
||||
from typing import Any, Awaitable, Callable, Optional, Tuple
|
||||
|
||||
from .config import HumanConfig, rand, rand_range, rand_int_range, async_sleep_ms
|
||||
from .mouse_async import AsyncRawMouse, async_human_move
|
||||
from .scroll import _is_in_viewport
|
||||
|
||||
|
||||
async def _get_element_box_async(
|
||||
page: Any, selector: str, timeout: float = 30000,
|
||||
) -> Optional[dict]:
|
||||
"""Async variant. ``timeout`` is forwarded to Playwright's
|
||||
``boundingBox(timeout=...)`` so callers can extend it for slow-loading
|
||||
elements (#172)."""
|
||||
try:
|
||||
el = page.locator(selector).first
|
||||
return await el.bounding_box(timeout=max(1, timeout))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
async def _async_smooth_wheel(raw: AsyncRawMouse, delta: int, cfg: HumanConfig) -> None:
|
||||
"""Send one logical scroll as a burst of small wheel events (like real inertia)."""
|
||||
abs_d = abs(delta)
|
||||
sign = 1 if delta > 0 else -1
|
||||
sent = 0
|
||||
while sent < abs_d:
|
||||
step_size = rand(20, 40)
|
||||
chunk = min(step_size, abs_d - sent)
|
||||
await raw.wheel(0, round(chunk) * sign)
|
||||
sent += chunk
|
||||
await async_sleep_ms(rand(8, 20))
|
||||
|
||||
|
||||
async def async_human_scroll_into_view(
|
||||
page: Any,
|
||||
raw: AsyncRawMouse,
|
||||
get_box: Callable[[], Awaitable[Optional[dict]]],
|
||||
cursor_x: float, cursor_y: float,
|
||||
cfg: HumanConfig,
|
||||
) -> Tuple[dict, float, float, bool]:
|
||||
"""Humanized scrolling using an arbitrary async ``get_box`` callable.
|
||||
|
||||
Used by both ``async_scroll_to_element`` (selector-based) and the
|
||||
ElementHandle / Locator ``scroll_into_view_if_needed`` patches so all
|
||||
scrolling paths share the same accelerate \u2192 cruise \u2192 decelerate
|
||||
\u2192 overshoot behavior.
|
||||
|
||||
Returns ``(box, cursor_x, cursor_y, did_scroll)`` \u2014 *did_scroll* is False
|
||||
when the element was already in the viewport.
|
||||
"""
|
||||
viewport = page.viewport_size
|
||||
if not viewport:
|
||||
# Headed launches default to no_viewport so the page tracks the real OS
|
||||
# window; page.viewport_size is then None. Fall back to the live window
|
||||
# dimensions so humanize works headed (the stealth-relevant mode).
|
||||
viewport = await page.evaluate(
|
||||
"() => ({ width: window.innerWidth, height: window.innerHeight })"
|
||||
)
|
||||
if not viewport or not viewport.get("height"):
|
||||
raise RuntimeError("Viewport size not available")
|
||||
|
||||
viewport_height = viewport["height"]
|
||||
viewport_width = viewport["width"]
|
||||
|
||||
box = await get_box()
|
||||
if box is None:
|
||||
raise RuntimeError("Element not found while scrolling into view")
|
||||
|
||||
if _is_in_viewport(box, viewport_height, cfg):
|
||||
return box, cursor_x, cursor_y, False
|
||||
|
||||
# Move cursor into scroll area
|
||||
scroll_area_x = round(viewport_width * rand(0.3, 0.7))
|
||||
scroll_area_y = round(viewport_height * rand(0.3, 0.7))
|
||||
await async_human_move(raw, cursor_x, cursor_y, scroll_area_x, scroll_area_y, cfg)
|
||||
cursor_x = scroll_area_x
|
||||
cursor_y = scroll_area_y
|
||||
await async_sleep_ms(rand_range(cfg.scroll_pre_move_delay))
|
||||
|
||||
# Calculate scroll distance
|
||||
target_y = viewport_height * rand(cfg.scroll_target_zone[0], cfg.scroll_target_zone[1])
|
||||
element_center = box["y"] + box["height"] / 2
|
||||
distance_to_scroll = element_center - target_y
|
||||
|
||||
direction = 1 if distance_to_scroll > 0 else -1
|
||||
abs_distance = abs(distance_to_scroll)
|
||||
avg_delta = (cfg.scroll_delta_base[0] + cfg.scroll_delta_base[1]) / 2
|
||||
total_clicks = max(3, math.ceil(abs_distance / avg_delta))
|
||||
accel_steps = rand_int_range(cfg.scroll_accel_steps)
|
||||
decel_steps = rand_int_range(cfg.scroll_decel_steps)
|
||||
|
||||
# Scroll loop: accelerate → cruise → decelerate
|
||||
scrolled = 0
|
||||
for i in range(total_clicks):
|
||||
if i < accel_steps:
|
||||
delta = rand(80, 100)
|
||||
pause = rand_range(cfg.scroll_pause_slow)
|
||||
elif i >= total_clicks - decel_steps:
|
||||
delta = rand(60, 90)
|
||||
pause = rand_range(cfg.scroll_pause_slow)
|
||||
else:
|
||||
delta = rand_range(cfg.scroll_delta_base)
|
||||
pause = rand_range(cfg.scroll_pause_fast)
|
||||
|
||||
delta *= 1 + (random.random() - 0.5) * 2 * cfg.scroll_delta_variance
|
||||
delta = round(delta) * direction
|
||||
|
||||
await _async_smooth_wheel(raw, delta, cfg)
|
||||
scrolled += abs(delta)
|
||||
await async_sleep_ms(pause)
|
||||
|
||||
# Check visibility every 3 steps
|
||||
if i % 3 == 2 or i == total_clicks - 1:
|
||||
box = await get_box()
|
||||
if box and _is_in_viewport(box, viewport_height, cfg):
|
||||
break
|
||||
if scrolled >= abs_distance * 1.1:
|
||||
break
|
||||
|
||||
# Optional overshoot + correction
|
||||
if random.random() < cfg.scroll_overshoot_chance:
|
||||
overshoot_px = round(rand_range(cfg.scroll_overshoot_px)) * direction
|
||||
await _async_smooth_wheel(raw, overshoot_px, cfg)
|
||||
await async_sleep_ms(rand_range(cfg.scroll_settle_delay))
|
||||
corrections = rand_int_range((1, 2))
|
||||
for _ in range(corrections):
|
||||
corr_delta = round(rand(40, 80)) * -direction
|
||||
await _async_smooth_wheel(raw, corr_delta, cfg)
|
||||
await async_sleep_ms(rand(100, 250))
|
||||
|
||||
# Settle
|
||||
await async_sleep_ms(rand_range(cfg.scroll_settle_delay))
|
||||
|
||||
box = await get_box()
|
||||
if box is None:
|
||||
raise RuntimeError("Element lost after scrolling into view")
|
||||
|
||||
return box, cursor_x, cursor_y, True
|
||||
|
||||
|
||||
async def async_scroll_to_element(
|
||||
page: Any,
|
||||
raw: AsyncRawMouse,
|
||||
selector: str,
|
||||
cursor_x: float, cursor_y: float,
|
||||
cfg: HumanConfig,
|
||||
timeout: float = 30000,
|
||||
) -> Tuple[dict, float, float, bool]:
|
||||
"""Selector-based humanized scroll (async).
|
||||
|
||||
``timeout`` is forwarded to ``locator.bounding_box(timeout=...)`` so callers
|
||||
such as ``page.click('#x', timeout=5000)`` can wait longer for slow elements
|
||||
(#172). Default matches Playwright's 30000ms when not specified.
|
||||
|
||||
Returns ``(box, cursor_x, cursor_y, did_scroll)``.
|
||||
"""
|
||||
async def _get():
|
||||
return await _get_element_box_async(page, selector, timeout)
|
||||
return await async_human_scroll_into_view(
|
||||
page, raw, _get, cursor_x, cursor_y, cfg,
|
||||
)
|
||||
@@ -0,0 +1,291 @@
|
||||
"""License validation and caching for CloakBrowser Pro.
|
||||
|
||||
Handles license key resolution, server validation with local caching,
|
||||
and Pro version checks.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
|
||||
from .config import get_cache_dir, get_platform_tag
|
||||
|
||||
logger = logging.getLogger("cloakbrowser")
|
||||
|
||||
VALIDATE_URL = "https://cloakbrowser.dev/api/license/validate"
|
||||
PRO_VERSION_URL = "https://cloakbrowser.dev/api/download/version"
|
||||
|
||||
LICENSE_CACHE_TTL = 86400 # 24 hours
|
||||
PRO_VERSION_CHECK_INTERVAL = 3600 # 1 hour
|
||||
|
||||
|
||||
@dataclass
|
||||
class LicenseInfo:
|
||||
valid: bool
|
||||
plan: str
|
||||
expires: str | None
|
||||
|
||||
|
||||
_LICENSE_KEY_SOURCE_PARAM = "param"
|
||||
_LICENSE_KEY_SOURCE_ENV = "env"
|
||||
_LICENSE_KEY_SOURCE_DEFAULT_FILE = "default_file"
|
||||
_LICENSE_KEY_SOURCE_CUSTOM_FILE = "custom_file"
|
||||
_LICENSE_KEY_SOURCE_NONE = "none"
|
||||
|
||||
|
||||
def _resolve_license_key_with_source(
|
||||
license_key: str | None = None,
|
||||
) -> tuple[str | None, str]:
|
||||
"""Resolve license key with source tracking for env-injection decisions.
|
||||
|
||||
Returns (key, source) where source is one of the _LICENSE_KEY_SOURCE_*
|
||||
constants. The source tells the caller *how* the key was found so they
|
||||
can decide whether env injection is needed (e.g. the binary reads the
|
||||
default file path directly, so env injection is unnecessary).
|
||||
"""
|
||||
# 1. Explicit param
|
||||
if license_key and license_key.strip():
|
||||
return (license_key.strip(), _LICENSE_KEY_SOURCE_PARAM)
|
||||
|
||||
# 2. Environment variable
|
||||
env_key = os.environ.get("CLOAKBROWSER_LICENSE_KEY", "").strip()
|
||||
if env_key:
|
||||
return (env_key, _LICENSE_KEY_SOURCE_ENV)
|
||||
|
||||
# 3. File in the wrapper cache dir
|
||||
cache_dir = get_cache_dir()
|
||||
key_file = cache_dir / "license.key"
|
||||
try:
|
||||
content = key_file.read_text().strip()
|
||||
if content:
|
||||
default_cache = Path.home() / ".cloakbrowser"
|
||||
if cache_dir.resolve() == default_cache.resolve():
|
||||
source = _LICENSE_KEY_SOURCE_DEFAULT_FILE
|
||||
else:
|
||||
source = _LICENSE_KEY_SOURCE_CUSTOM_FILE
|
||||
return (content, source)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
return (None, _LICENSE_KEY_SOURCE_NONE)
|
||||
|
||||
|
||||
def resolve_license_key(license_key: str | None = None) -> str | None:
|
||||
"""Resolve the license key: explicit param > env var > file > None."""
|
||||
key, _ = _resolve_license_key_with_source(license_key)
|
||||
return key
|
||||
|
||||
|
||||
def build_launch_env(
|
||||
license_key: str | None = None,
|
||||
user_env: dict[str, str] | None = None,
|
||||
) -> dict[str, str] | None:
|
||||
"""Build child process env dict with any needed license key injection.
|
||||
|
||||
The Pro binary reads ``CLOAKBROWSER_LICENSE_KEY`` from its own process
|
||||
environment at startup. This helper merges the resolved key into the
|
||||
child process env dict **only** when injection is necessary:
|
||||
|
||||
* **param** or **custom_file** source -> inject the key into the child env
|
||||
(the binary cannot see the wrapper-only key or the custom file path).
|
||||
* **env** source -> the key is already in ``os.environ``, so the child
|
||||
inherits it naturally. No injection.
|
||||
* **default_file** source -> the binary reads ``~/.cloakbrowser/license.key``
|
||||
directly, so injection is unnecessary (and keeps the key out of process
|
||||
env for security) — *unless* the caller passes a custom ``user_env``,
|
||||
which Playwright uses to replace (not merge) the child env; a replaced
|
||||
env can drop ``HOME`` and hide the file, so the key is injected then.
|
||||
* **none** -> no key at all, no injection.
|
||||
|
||||
When *user_env* is provided (e.g. the caller passed ``env=`` via
|
||||
Playwright kwargs), it is used as the base instead of ``os.environ``,
|
||||
and the key is injected only when needed.
|
||||
|
||||
Returns ``None`` when no injection is needed and no custom user_env was
|
||||
given — Playwright treats ``env=None`` as "inherit parent env", which
|
||||
is correct in those cases.
|
||||
"""
|
||||
key, source = _resolve_license_key_with_source(license_key)
|
||||
|
||||
# Normalize the custom env once so every return path behaves identically:
|
||||
# drop None values (Playwright's env is typed str->str).
|
||||
base_env = (
|
||||
{k: v for k, v in user_env.items() if v is not None}
|
||||
if user_env is not None
|
||||
else None
|
||||
)
|
||||
|
||||
# Default file: binary reads it directly — no env injection needed,
|
||||
# UNLESS the caller passes a custom env. Playwright replaces (not merges)
|
||||
# the child env, which can drop HOME and hide the file from the binary,
|
||||
# so inject the key too in that case (fall through to the merge below).
|
||||
if source == _LICENSE_KEY_SOURCE_DEFAULT_FILE and base_env is None:
|
||||
return None
|
||||
|
||||
# No key at all: pass through the custom env or None.
|
||||
if source == _LICENSE_KEY_SOURCE_NONE or key is None:
|
||||
return base_env
|
||||
|
||||
# Env source, no custom user env: child inherits parent env, which
|
||||
# already has CLOAKBROWSER_LICENSE_KEY.
|
||||
if source == _LICENSE_KEY_SOURCE_ENV and base_env is None:
|
||||
return None
|
||||
|
||||
# Build the merged env dict.
|
||||
merged = dict(base_env) if base_env is not None else dict(os.environ)
|
||||
|
||||
# For param/custom_file this is THE injection into the child env.
|
||||
# For env source with a custom user_env this ensures the key persists
|
||||
# through the user's env override (Playwright replaces, not merges).
|
||||
merged["CLOAKBROWSER_LICENSE_KEY"] = key
|
||||
|
||||
return merged
|
||||
|
||||
|
||||
def validate_license(license_key: str) -> LicenseInfo | None:
|
||||
"""Validate a license key with the CloakBrowser server.
|
||||
|
||||
Checks a local file cache first (24h TTL). Falls back to stale
|
||||
cache if the server is unreachable.
|
||||
|
||||
Returns LicenseInfo if validation succeeded, None on total failure.
|
||||
"""
|
||||
cache_path = get_cache_dir() / ".license_cache"
|
||||
key_sha = hashlib.sha256(license_key.encode()).hexdigest()
|
||||
|
||||
cached = _read_cache(cache_path, key_sha)
|
||||
if cached:
|
||||
return cached
|
||||
|
||||
try:
|
||||
resp = httpx.post(
|
||||
VALIDATE_URL,
|
||||
json={"license_key": license_key},
|
||||
timeout=10.0,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
|
||||
info = LicenseInfo(
|
||||
valid=data.get("valid", False),
|
||||
plan=data.get("plan", "solo"),
|
||||
expires=data.get("expires"),
|
||||
)
|
||||
|
||||
if info.valid:
|
||||
_write_cache(cache_path, key_sha, info)
|
||||
return info
|
||||
|
||||
except Exception as e:
|
||||
logger.warning("License validation request failed: %s", e)
|
||||
|
||||
stale = _read_cache(cache_path, key_sha, ignore_ttl=True)
|
||||
if stale:
|
||||
logger.warning("Using cached license validation (server unreachable)")
|
||||
return stale
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def get_pro_latest_version() -> str | None:
|
||||
"""Get the latest Pro binary version from the server.
|
||||
|
||||
Rate-limited to 1 call per hour via a marker file.
|
||||
"""
|
||||
marker = get_cache_dir() / f".last_pro_version_check_{get_platform_tag()}"
|
||||
|
||||
if marker.exists():
|
||||
try:
|
||||
age = time.time() - marker.stat().st_mtime
|
||||
if age < PRO_VERSION_CHECK_INTERVAL:
|
||||
content = marker.read_text().strip()
|
||||
return content if content else None
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
try:
|
||||
resp = httpx.get(
|
||||
PRO_VERSION_URL,
|
||||
headers={"X-Platform": get_platform_tag()},
|
||||
timeout=10.0,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
version = resp.json().get("version")
|
||||
if not version:
|
||||
return None
|
||||
|
||||
marker.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = marker.with_suffix(".tmp")
|
||||
tmp.write_text(version)
|
||||
os.replace(str(tmp), str(marker))
|
||||
return version
|
||||
|
||||
except Exception as e:
|
||||
logger.debug("Pro version check failed: %s", e)
|
||||
return None
|
||||
|
||||
|
||||
def _read_cache(
|
||||
cache_path: Path, key_sha: str, ignore_ttl: bool = False
|
||||
) -> LicenseInfo | None:
|
||||
"""Read cached license validation if it exists and is fresh."""
|
||||
try:
|
||||
if not cache_path.exists():
|
||||
return None
|
||||
|
||||
data = json.loads(cache_path.read_text())
|
||||
|
||||
if data.get("key_sha256") != key_sha:
|
||||
return None
|
||||
|
||||
if not ignore_ttl:
|
||||
validated_at = data.get("validated_at", 0)
|
||||
if time.time() - validated_at > LICENSE_CACHE_TTL:
|
||||
return None
|
||||
|
||||
expires = data.get("expires")
|
||||
if expires:
|
||||
try:
|
||||
from datetime import datetime, timezone
|
||||
exp_dt = datetime.fromisoformat(expires)
|
||||
if exp_dt.tzinfo is None:
|
||||
exp_dt = exp_dt.replace(tzinfo=timezone.utc)
|
||||
if exp_dt < datetime.now(timezone.utc):
|
||||
return LicenseInfo(valid=False, plan=data.get("plan", "solo"), expires=expires)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
return LicenseInfo(
|
||||
valid=data.get("valid", False),
|
||||
plan=data.get("plan", "solo"),
|
||||
expires=expires,
|
||||
)
|
||||
except (json.JSONDecodeError, OSError, KeyError, TypeError):
|
||||
# TypeError: a corrupted cache with a non-numeric validated_at. Treat any
|
||||
# unreadable cache as absent rather than crashing the caller.
|
||||
return None
|
||||
|
||||
|
||||
def _write_cache(cache_path: Path, key_sha: str, info: LicenseInfo) -> None:
|
||||
"""Write license validation result to local cache (atomic via tmp+rename)."""
|
||||
try:
|
||||
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp_path = cache_path.with_suffix(".tmp")
|
||||
tmp_path.write_text(json.dumps({
|
||||
"key_sha256": key_sha,
|
||||
"valid": info.valid,
|
||||
"plan": info.plan,
|
||||
"expires": info.expires,
|
||||
"validated_at": time.time(),
|
||||
}))
|
||||
os.replace(str(tmp_path), str(cache_path))
|
||||
except OSError as e:
|
||||
logger.debug("Failed to write license cache: %s", e)
|
||||
@@ -0,0 +1,128 @@
|
||||
"""Widevine CDM hint-file seeding for persistent contexts.
|
||||
|
||||
CloakBrowser's binary is built with Widevine support but ships no CDM (the CDM
|
||||
is a proprietary Google binary we can't redistribute). Users sideload it by
|
||||
copying a ``WidevineCdm/`` directory from a real Chrome install next to the
|
||||
binary (see issue #96).
|
||||
|
||||
Chromium discovers a sideloaded CDM in two phases: an early-startup pass that
|
||||
reads a "hint file" from the user-data-dir, and a later async component-updater
|
||||
pass that writes that hint file. On a fresh profile the hint file doesn't exist
|
||||
on the first launch, and Playwright passes ``--disable-component-update``, so the
|
||||
updater never writes it — Widevine only works after a manual two-launch dance.
|
||||
|
||||
This module pre-seeds the hint file before launch so a sideloaded CDM works on
|
||||
the very first launch. It never bundles, downloads, or copies the CDM itself —
|
||||
it only writes the hint when a CDM the user provided is already present.
|
||||
|
||||
Linux only: Chromium's hint-file mechanism is Linux/ChromeOS-specific. On Windows
|
||||
the CDM can't initialise (DRM host verification), and macOS uses a different CDM
|
||||
layout, so seeding is a no-op there.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import platform
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger("cloakbrowser")
|
||||
|
||||
# Chromium reads this file from <user-data-dir>/WidevineCdm/ at early startup.
|
||||
_HINT_FILENAME = "latest-component-updated-widevine-cdm"
|
||||
|
||||
|
||||
def _seeding_disabled() -> bool:
|
||||
"""True if CLOAKBROWSER_WIDEVINE is set to a falsey value (kill switch)."""
|
||||
val = os.environ.get("CLOAKBROWSER_WIDEVINE", "").strip().lower()
|
||||
return val in ("0", "false", "off", "no")
|
||||
|
||||
|
||||
def resolve_widevine_cdm_dir(binary_path: str | os.PathLike) -> Path | None:
|
||||
"""Locate a sideloaded Widevine CDM directory, or None if absent.
|
||||
|
||||
Resolution order:
|
||||
1. If CLOAKBROWSER_WIDEVINE_CDM is set, it is used **exclusively** (overrides
|
||||
auto-detection). An invalid value (no ``manifest.json``) skips seeding.
|
||||
2. ``<dir of the chrome binary>/WidevineCdm`` — where a user naturally drops
|
||||
a manual sideload, per Chromium binary version.
|
||||
3. ``<cache dir>/WidevineCdm`` (``~/.cloakbrowser/WidevineCdm``) — the
|
||||
version-independent location the Docker auto-fetch and ``fetch-widevine.py``
|
||||
write to. This fallback lets one fetched CDM serve any binary (free or
|
||||
Pro, any version) with no env var — the CDM ``.so`` is arch-specific but
|
||||
not version-specific.
|
||||
|
||||
A directory counts only if it contains ``manifest.json`` (so we don't seed a
|
||||
hint pointing at a bogus path). The returned path is absolute and
|
||||
symlink-resolved (``Path.resolve()``).
|
||||
"""
|
||||
custom = os.environ.get("CLOAKBROWSER_WIDEVINE_CDM")
|
||||
if custom is not None:
|
||||
# Set exclusively (overrides auto-detection). An empty/whitespace value is
|
||||
# invalid — return None rather than let Path("") resolve to "." and match a
|
||||
# stray manifest.json in the working directory.
|
||||
if not custom.strip():
|
||||
return None
|
||||
cdm_dir = Path(custom)
|
||||
return cdm_dir.resolve() if (cdm_dir / "manifest.json").is_file() else None
|
||||
|
||||
from .config import get_cache_dir # local import avoids any import-cycle risk
|
||||
|
||||
for cdm_dir in (Path(os.fspath(binary_path)).parent / "WidevineCdm",
|
||||
get_cache_dir() / "WidevineCdm"):
|
||||
if (cdm_dir / "manifest.json").is_file():
|
||||
return cdm_dir.resolve()
|
||||
return None
|
||||
|
||||
|
||||
def seed_widevine_hint(user_data_dir: str | os.PathLike, binary_path: str | os.PathLike) -> None:
|
||||
"""Write the Widevine CDM hint file into a persistent profile before launch.
|
||||
|
||||
``binary_path`` is the resolved chrome executable; the CDM is looked for next
|
||||
to it. No-op on non-Linux platforms, when seeding is disabled via
|
||||
CLOAKBROWSER_WIDEVINE, or when no sideloaded CDM is present. Never raises —
|
||||
a failure here must not break the browser launch.
|
||||
"""
|
||||
if platform.system() != "Linux":
|
||||
return
|
||||
if _seeding_disabled():
|
||||
logger.debug("Widevine hint seeding disabled via CLOAKBROWSER_WIDEVINE")
|
||||
return
|
||||
if not user_data_dir:
|
||||
# Empty user_data_dir = Playwright's ephemeral profile (its own temp dir);
|
||||
# a persistent hint can't be placed there, and "" would pollute the CWD.
|
||||
return
|
||||
|
||||
# Everything below is best-effort and must never break the browser launch,
|
||||
# so the whole body (resolution + write) is guarded.
|
||||
try:
|
||||
cdm_dir = resolve_widevine_cdm_dir(binary_path)
|
||||
if cdm_dir is None:
|
||||
if os.environ.get("CLOAKBROWSER_WIDEVINE_CDM") is not None:
|
||||
logger.warning(
|
||||
"CLOAKBROWSER_WIDEVINE_CDM is set but has no manifest.json; "
|
||||
"skipping Widevine hint seeding"
|
||||
)
|
||||
else:
|
||||
logger.debug("No sideloaded Widevine CDM found; skipping hint seeding")
|
||||
return
|
||||
|
||||
hint_dir = Path(os.fspath(user_data_dir)) / "WidevineCdm"
|
||||
hint_dir.mkdir(parents=True, exist_ok=True)
|
||||
hint_file = hint_dir / _HINT_FILENAME
|
||||
# cdm_dir is already absolute/resolved. Compact separators + ensure_ascii=False
|
||||
# byte-match the JS wrapper's JSON.stringify (UTF-8) output.
|
||||
content = json.dumps({"Path": str(cdm_dir)}, separators=(",", ":"), ensure_ascii=False)
|
||||
|
||||
try:
|
||||
if hint_file.is_file() and hint_file.read_text(encoding="utf-8") == content:
|
||||
return # already seeded correctly
|
||||
except Exception:
|
||||
logger.warning("Existing Widevine hint unreadable; rewriting")
|
||||
|
||||
hint_file.write_text(content, encoding="utf-8")
|
||||
logger.info("Seeded Widevine CDM hint -> %s", cdm_dir)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to seed Widevine CDM hint file: %s", e)
|
||||
@@ -0,0 +1,8 @@
|
||||
# .NET build artifacts
|
||||
bin/
|
||||
obj/
|
||||
*.user
|
||||
.vs/
|
||||
|
||||
*.png
|
||||
cloak-persist-test/
|
||||
@@ -0,0 +1,59 @@
|
||||
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio Version 17
|
||||
VisualStudioVersion = 17.0.31903.59
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{57F29479-09EB-40B8-8811-3EDAEC7E7294}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CloakBrowser", "src\CloakBrowser\CloakBrowser.csproj", "{2451570B-6417-4615-9070-5C45A24B0A05}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CloakBrowser.Cli", "src\CloakBrowser.Cli\CloakBrowser.Cli.csproj", "{E1A470E8-044F-4AB4-BABC-33B5179061B2}"
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{8A508583-113A-430D-90C9-73C706332269}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CloakBrowser.Tests", "tests\CloakBrowser.Tests\CloakBrowser.Tests.csproj", "{06500720-AF0C-434D-B4C6-FACE892A2F10}"
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "examples", "examples", "{A19AACE1-BC14-4D60-8EBD-082E971B184B}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CloakBrowser.Examples", "examples\CloakBrowser.Examples\CloakBrowser.Examples.csproj", "{24A34FBF-6B3C-4EF7-8B4C-A881677565FA}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CloakBrowser.Generators", "src\CloakBrowser.Generators\CloakBrowser.Generators.csproj", "{598B07D4-0D2E-47F3-BC70-A81D3F7EFD45}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
Release|Any CPU = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{2451570B-6417-4615-9070-5C45A24B0A05}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{2451570B-6417-4615-9070-5C45A24B0A05}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{2451570B-6417-4615-9070-5C45A24B0A05}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{2451570B-6417-4615-9070-5C45A24B0A05}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{E1A470E8-044F-4AB4-BABC-33B5179061B2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{E1A470E8-044F-4AB4-BABC-33B5179061B2}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{E1A470E8-044F-4AB4-BABC-33B5179061B2}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{E1A470E8-044F-4AB4-BABC-33B5179061B2}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{06500720-AF0C-434D-B4C6-FACE892A2F10}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{06500720-AF0C-434D-B4C6-FACE892A2F10}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{06500720-AF0C-434D-B4C6-FACE892A2F10}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{06500720-AF0C-434D-B4C6-FACE892A2F10}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{24A34FBF-6B3C-4EF7-8B4C-A881677565FA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{24A34FBF-6B3C-4EF7-8B4C-A881677565FA}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{24A34FBF-6B3C-4EF7-8B4C-A881677565FA}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{24A34FBF-6B3C-4EF7-8B4C-A881677565FA}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{598B07D4-0D2E-47F3-BC70-A81D3F7EFD45}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{598B07D4-0D2E-47F3-BC70-A81D3F7EFD45}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{598B07D4-0D2E-47F3-BC70-A81D3F7EFD45}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{598B07D4-0D2E-47F3-BC70-A81D3F7EFD45}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(NestedProjects) = preSolution
|
||||
{2451570B-6417-4615-9070-5C45A24B0A05} = {57F29479-09EB-40B8-8811-3EDAEC7E7294}
|
||||
{E1A470E8-044F-4AB4-BABC-33B5179061B2} = {57F29479-09EB-40B8-8811-3EDAEC7E7294}
|
||||
{06500720-AF0C-434D-B4C6-FACE892A2F10} = {8A508583-113A-430D-90C9-73C706332269}
|
||||
{24A34FBF-6B3C-4EF7-8B4C-A881677565FA} = {A19AACE1-BC14-4D60-8EBD-082E971B184B}
|
||||
{598B07D4-0D2E-47F3-BC70-A81D3F7EFD45} = {57F29479-09EB-40B8-8811-3EDAEC7E7294}
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
@@ -0,0 +1,678 @@
|
||||
# CloakBrowser for .NET
|
||||
|
||||
A complete, faithful **.NET 8 / C#** port of the
|
||||
[CloakBrowser](https://github.com/CloakHQ/CloakBrowser) Python wrapper - stealth
|
||||
Chromium that passes bot-detection tests, built on top of
|
||||
[`Microsoft.Playwright`](https://playwright.dev/dotnet/).
|
||||
|
||||
CloakBrowser is a thin wrapper around a closed-source, source-level patched
|
||||
Chromium binary (58 C++ fingerprint patches). This port reproduces **all** of the
|
||||
wrapper functionality with identical behavior - same launch flags, same proxy /
|
||||
GeoIP / WebRTC logic, and a humanize layer whose curves, timings, and stealth
|
||||
paths match the Python and JavaScript clients exactly.
|
||||
|
||||
---
|
||||
|
||||
## Table of contents
|
||||
|
||||
- [Why a decorator layer?](#why-a-decorator-layer)
|
||||
- [Feature matrix](#feature-matrix)
|
||||
- [Requirements](#requirements)
|
||||
- [Installation](#installation)
|
||||
- [Project layout](#project-layout)
|
||||
- [Quick start](#quick-start)
|
||||
- [Humanized interactions (transparent)](#humanized-interactions-transparent)
|
||||
- [What changes vs. plain Playwright](#what-changes-vs-plain-playwright)
|
||||
- [Explicit `HumanPage` engine (advanced)](#explicit-humanpage-engine-advanced)
|
||||
- [Launch API reference](#launch-api-reference)
|
||||
- [Options reference](#options-reference)
|
||||
- [`HumanConfig` reference](#humanconfig-reference)
|
||||
- [How the humanize layer works](#how-the-humanize-layer-works)
|
||||
- [Mouse motion (Bezier)](#mouse-motion-bezier)
|
||||
- [Human typing & the CDP stealth path](#human-typing--the-cdp-stealth-path)
|
||||
- [Non-ASCII input](#non-ascii-input)
|
||||
- [Scrolling](#scrolling)
|
||||
- [Actionability checks](#actionability-checks)
|
||||
- [Shared timeout budget (#307)](#shared-timeout-budget-307)
|
||||
- [Focus-aware press / clear](#focus-aware-press--clear)
|
||||
- [Proxy, GeoIP & WebRTC](#proxy-geoip--webrtc)
|
||||
- [CLI](#cli)
|
||||
- [Environment variables](#environment-variables)
|
||||
- [Building & testing](#building--testing)
|
||||
- [Test suite map](#test-suite-map)
|
||||
- [Mapping to the Python source](#mapping-to-the-python-source)
|
||||
- [License](#license)
|
||||
|
||||
---
|
||||
|
||||
## Why a decorator layer?
|
||||
|
||||
.NET's Playwright exposes **sealed interfaces** (`IPage`, `ILocator`, `IMouse`, ...)
|
||||
that **cannot** be monkey-patched the way the Python/JS clients replace methods at
|
||||
runtime. CloakBrowser bridges this gap with a **transparent decorator layer**:
|
||||
|
||||
1. You pass `Humanize = true` at launch.
|
||||
2. `NewPageAsync()` / `NewContextAsync()` return a **wrapped** object.
|
||||
3. Every standard Playwright call (`page.ClickAsync`, `page.FillAsync`,
|
||||
`page.Mouse.MoveAsync`, ...) is automatically humanized - **no API changes** in
|
||||
your code.
|
||||
4. The wrappers are generated at **compile time** by a Roslyn source generator
|
||||
(`[GenerateInterfaceDelegation]`), so they are fully statically typed with **no
|
||||
reflection on the hot path**. Non-intercepted members are delegated verbatim.
|
||||
|
||||
```
|
||||
your code ──> HumanizedPage ──┬─ intercepted member ─> humanize engine ─> raw IPage
|
||||
(IPage) (generated) └─ everything else ─────────────────────> raw IPage (verbatim)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Feature matrix
|
||||
|
||||
| Capability | Status | Source |
|
||||
| --- | :---: | --- |
|
||||
| Automatic binary **download / cache / auto-update** (SHA-256 verified) | ✅ | `Download.cs`, `Config.cs` |
|
||||
| **Stealth launch args** (random fingerprint seed, platform spoofing) | ✅ | `CloakLauncher.cs` |
|
||||
| **Proxy** - HTTP/HTTPS + SOCKS5, inline URL-encoded credentials | ✅ | `ProxyResolver.cs` |
|
||||
| **GeoIP** timezone/locale from proxy exit IP (MaxMind GeoLite2) | ✅ | `GeoIp.cs` |
|
||||
| **WebRTC** IP spoofing (`--fingerprint-webrtc-ip=auto`) | ✅ | `CloakLauncher.cs` |
|
||||
| **Widevine** CDM hint seeding (Linux) | ✅ | `Widevine.cs` |
|
||||
| **Persistent contexts** (profile reuse) | ✅ | `CloakLauncher.cs` |
|
||||
| **Humanize** - Bezier mouse, human typing, scrolling, actionability | ✅ | `Human/`, `Wrappers/` |
|
||||
| Transparent humanize via **source generator** | ✅ | `CloakBrowser.Generators/` |
|
||||
| **CLI** (`install` / `info` / `update` / `clear-cache`) | ✅ | `CloakBrowser.Cli/` |
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
| Dependency | Version | Why |
|
||||
| --- | --- | --- |
|
||||
| .NET SDK | **8.0** | target framework `net8.0` |
|
||||
| `Microsoft.Playwright` | **1.49.0** | underlying browser automation |
|
||||
| `MaxMind.GeoIP2` | **5.2.0** | GeoIP timezone/locale resolution |
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
dotnet add package CloakBrowser
|
||||
```
|
||||
|
||||
The patched Chromium binary downloads automatically on first launch, cached under
|
||||
`~/.cloakbrowser` (override with `CLOAKBROWSER_CACHE_DIR`).
|
||||
|
||||
---
|
||||
|
||||
## Project layout
|
||||
|
||||
```
|
||||
dotnet/
|
||||
├── CloakBrowser.sln
|
||||
├── src/
|
||||
│ ├── CloakBrowser/ # the library
|
||||
│ │ ├── Config.cs # <- cloakbrowser/config.py
|
||||
│ │ ├── Download.cs # <- cloakbrowser/download.py (+ zip-slip guard)
|
||||
│ │ ├── GeoIp.cs # <- cloakbrowser/geoip.py
|
||||
│ │ ├── Widevine.cs # <- cloakbrowser/widevine.py
|
||||
│ │ ├── CloakLauncher.cs # <- cloakbrowser/browser.py (launch funcs, build_args)
|
||||
│ │ ├── ProxyResolver.cs # <- cloakbrowser/browser.py (proxy URL helpers)
|
||||
│ │ ├── ProxySettings.cs # <- ProxySettings TypedDict
|
||||
│ │ ├── LaunchOptions.cs # launch option records
|
||||
│ │ ├── Handles.cs # CloakBrowserHandle / CloakContextHandle
|
||||
│ │ ├── CloakLog.cs # logging facade
|
||||
│ │ ├── Human/ # <- cloakbrowser/human/*
|
||||
│ │ │ ├── HumanConfig.cs # <- human/config.py (HumanConfig + merge)
|
||||
│ │ │ ├── HumanRandom.cs # <- human/config.py (rand/sleep helpers)
|
||||
│ │ │ ├── HumanMouse.cs # <- human/mouse.py (Bezier engine)
|
||||
│ │ │ ├── HumanKeyboard.cs # <- human/keyboard.py (typing + CDP stealth)
|
||||
│ │ │ ├── HumanScroll.cs # <- human/scroll.py
|
||||
│ │ │ ├── Actionability.cs # <- human/actionability.py
|
||||
│ │ │ ├── IsolatedWorld.cs # <- _AsyncIsolatedWorld
|
||||
│ │ │ ├── PlaywrightAdapters.cs # IMouse/IKeyboard/ICDPSession -> raw protocols
|
||||
│ │ │ └── HumanPage.cs # <- patch_page flows (explicit engine)
|
||||
│ │ └── Wrappers/ # transparent humanize decorators (Humanize=true)
|
||||
│ │ ├── Humanize.cs # wrap entry points + helpers (idempotent)
|
||||
│ │ ├── HumanCursor.cs # shared per-page cursor / CDP stealth state
|
||||
│ │ ├── LocatorHumanizer.cs # locator-based humanize engine
|
||||
│ │ ├── HumanizedBrowser.cs # IBrowser decorator
|
||||
│ │ ├── HumanizedBrowserContext.cs # IBrowserContext decorator
|
||||
│ │ ├── HumanizedPage.cs # IPage decorator
|
||||
│ │ ├── HumanizedFrame.cs # IFrame decorator
|
||||
│ │ ├── HumanizedLocator.cs # ILocator decorator
|
||||
│ │ ├── HumanizedElementHandle.cs # IElementHandle decorator
|
||||
│ │ ├── HumanizedMouse.cs # IMouse decorator
|
||||
│ │ └── HumanizedKeyboard.cs # IKeyboard decorator
|
||||
│ ├── CloakBrowser.Generators/ # Roslyn source generator
|
||||
│ │ └── InterfaceDelegationGenerator.cs # emits delegating members
|
||||
│ └── CloakBrowser.Cli/ # <- cloakbrowser/__main__.py
|
||||
├── examples/CloakBrowser.Examples/ # runnable examples (see below)
|
||||
└── tests/CloakBrowser.Tests/ # xUnit tests
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Quick start
|
||||
|
||||
```csharp
|
||||
using CloakBrowser;
|
||||
|
||||
await using var browser = await CloakLauncher.LaunchAsync(new LaunchOptions
|
||||
{
|
||||
Headless = true,
|
||||
});
|
||||
|
||||
var page = await browser.NewPageAsync();
|
||||
await page.GotoAsync("https://bot.incolumitas.com/");
|
||||
Console.WriteLine(await page.TitleAsync());
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Humanized interactions (transparent)
|
||||
|
||||
Pass `Humanize = true` and **write ordinary Playwright code** - mouse moves,
|
||||
clicks, typing, and scrolling are humanized automatically. Nothing else in your
|
||||
code changes:
|
||||
|
||||
```csharp
|
||||
using CloakBrowser;
|
||||
using CloakBrowser.Human;
|
||||
|
||||
await using var browser = await CloakLauncher.LaunchAsync(new LaunchOptions
|
||||
{
|
||||
Headless = false,
|
||||
Humanize = true, // <- the only change
|
||||
HumanPreset = HumanPreset.Careful, // optional: Default | Careful
|
||||
HumanConfig = new Dictionary<string, object> { ["typing_delay"] = 90.0 }, // optional overrides
|
||||
});
|
||||
|
||||
var page = await browser.NewPageAsync(); // returns a transparently-wrapped IPage
|
||||
await page.GotoAsync("https://example.com/login");
|
||||
|
||||
await page.FillAsync("#username", "alice"); // per-character human typing
|
||||
await page.FillAsync("#password", "s3cr3t!"); // (variable delays, thinking pauses, typos+fixes)
|
||||
await page.ClickAsync("button[type=submit]"); // Bezier mouse curve to a realistic aim point
|
||||
await page.Mouse.WheelAsync(0, 600); // accelerate -> cruise -> decelerate scroll
|
||||
```
|
||||
|
||||
The wrapping is **complete and transitive** - anything you reach through the page
|
||||
is wrapped too:
|
||||
|
||||
```csharp
|
||||
await page.Mouse.MoveAsync(400, 300); // humanized
|
||||
await page.Keyboard.TypeAsync("hello"); // humanized
|
||||
var btn = page.Locator("#submit"); // wrapped ILocator
|
||||
await btn.ClickAsync(); // humanized
|
||||
foreach (var frame in page.Frames) { /* wrapped IFrame */ }
|
||||
```
|
||||
|
||||
### What changes vs. plain Playwright
|
||||
|
||||
| Member | `Humanize = false` (default) | `Humanize = true` |
|
||||
| --- | --- | --- |
|
||||
| `IPage` / `ILocator` `ClickAsync`, `DblClickAsync`, `HoverAsync`, `TapAsync` | direct Playwright dispatch | Bezier-curve mouse move to a randomized in-element aim point, then click |
|
||||
| `IPage` / `ILocator` `FillAsync`, `TypeAsync` / `PressSequentiallyAsync` | instant value set / fast type | per-character typing with variable delays, thinking pauses, occasional typos that self-correct |
|
||||
| `IPage` / `ILocator` `PressAsync` | direct | human key timing; clicks to focus first only if not already focused |
|
||||
| `ILocator` `CheckAsync`, `UncheckAsync`, `SetCheckedAsync`, `DragToAsync` | direct | humanized click / curved drag |
|
||||
| `ILocator` `SelectOptionAsync` (all overloads) | instant native select | curved hover to the `<select>` + pause, then native select |
|
||||
| `ILocator` `ClearAsync` | instant value reset | focus (humanized click if needed) + select-all + Backspace |
|
||||
| `IMouse` `MoveAsync`, `ClickAsync`, `DblClickAsync`, `DownAsync`, `UpAsync` | direct | curved, eased motion with overshoot |
|
||||
| `IMouse` `WheelAsync` | single event | accelerate-cruise-decelerate microsteps |
|
||||
| `IKeyboard` `TypeAsync`, `PressAsync`, `InsertTextAsync` | direct | human-timed CDP key events |
|
||||
| `page.Mouse`, `page.Keyboard`, `page.Locator(...)`, `page.GetBy*`, `page.Frames`, `QuerySelectorAsync`, `IBrowser.NewPageAsync/NewContextAsync`, `IBrowserContext.NewPageAsync` | raw Playwright objects | the same call returns a **wrapped** object so humanization stays transitive |
|
||||
| every **other** member (navigation, waits, evaluation, screenshots, network, etc.) | - | **delegated verbatim** to Playwright (identical signatures, return types, exceptions, and `CancellationToken` support) |
|
||||
| escape hatch | n/a | `((HumanizedPage)page).Original` (also `.Inner`) returns the raw, un-wrapped object; `browser.RawBrowser` on the handle |
|
||||
|
||||
Per-call overrides are honored - e.g.
|
||||
`await page.ClickAsync("#x", new() { Force = true })` still works and is
|
||||
humanized. `ElementHandle` interactions are humanized too, but prefer `ILocator`
|
||||
(Playwright's recommendation) for the most reliable aiming.
|
||||
|
||||
### Calling the original Playwright methods (escape hatch)
|
||||
|
||||
When you want a specific call to skip humanization (for raw speed, or to bypass a
|
||||
behavior you don't want for one action) every wrapper exposes the underlying,
|
||||
un-humanized Playwright object via **`Original`** (with **`Inner`** as an alias).
|
||||
This is the .NET equivalent of the `page._original` escape hatch in the Python/JS
|
||||
clients - the object you get back is the genuine Playwright interface, so the
|
||||
whole API is available on it:
|
||||
|
||||
```csharp
|
||||
await using var browser = await CloakLauncher.LaunchAsync(new() { Humanize = true });
|
||||
var page = await browser.NewPageAsync();
|
||||
|
||||
// Humanized (default): Bezier mouse curve, realistic aim point, etc.
|
||||
await page.ClickAsync("#submit");
|
||||
|
||||
// Raw Playwright, no humanization - instant, via the escape hatch:
|
||||
await ((HumanizedPage)page).Original.ClickAsync("#submit");
|
||||
await ((HumanizedPage)page).Original.FillAsync("#token", value);
|
||||
|
||||
// Works on every wrapped object:
|
||||
await ((HumanizedMouse)page.Mouse).Original.MoveAsync(10, 10);
|
||||
var rawLocator = ((HumanizedLocator)page.Locator("#row")).Original;
|
||||
```
|
||||
|
||||
The handle also exposes the un-wrapped browser/context directly:
|
||||
`browser.RawBrowser` (and `RawContext` on a context handle).
|
||||
|
||||
> Note: when `Humanize = false`, `NewPageAsync()` already returns the raw
|
||||
> Playwright `IPage`, so no escape hatch is needed.
|
||||
|
||||
### Explicit `HumanPage` engine (advanced)
|
||||
|
||||
If you want the explicit, non-transparent engine object instead of the
|
||||
transparent layer, `NewHumanPageAsync` returns a `HumanPage`. Its raw Playwright
|
||||
page is always available via `human.Page`.
|
||||
|
||||
```csharp
|
||||
HumanPage human = await browser.NewHumanPageAsync();
|
||||
await human.GotoAsync("https://example.com/login");
|
||||
await human.FillAsync("#username", "alice");
|
||||
await human.ClickAsync("button[type=submit]");
|
||||
```
|
||||
|
||||
`HumanPage` methods (all take an optional `HumanActionOptions { Timeout, Force }`):
|
||||
|
||||
| Method | Description |
|
||||
| --- | --- |
|
||||
| `GotoAsync(url, options?)` | navigate (delegates to the raw page) |
|
||||
| `ClickAsync(selector)` | Bezier move + human click |
|
||||
| `DblClickAsync(selector)` | move + double click |
|
||||
| `HoverAsync(selector)` | curved move, no click |
|
||||
| `TapAsync(selector)` | same motion as click |
|
||||
| `TypeAsync(selector, text)` | focus + per-character typing |
|
||||
| `FillAsync(selector, value)` | click + select-all + clear + type |
|
||||
| `PressAsync(selector, key)` | focus-aware single key press |
|
||||
| `PressSequentiallyAsync(selector, text)` | focus + per-character typing |
|
||||
| `ClearAsync(selector)` | focus + select-all + Backspace |
|
||||
| `CheckAsync` / `UncheckAsync` / `SetCheckedAsync(selector, state)` | humanized toggle via click |
|
||||
| `SelectOptionAsync(selector, values)` | curved hover + native select |
|
||||
| `FocusAsync(selector)` | curved move + **programmatic** focus (no click side-effects) |
|
||||
| `ScrollIntoViewIfNeededAsync(selector)` | humanized accelerate->cruise->decelerate->overshoot scroll |
|
||||
| `DragAndDropAsync(src, dst)` | curved press-drag-release |
|
||||
| `MouseMoveAsync(x, y)` / `MouseClickAsync(x, y)` | low-level curved motion |
|
||||
| `KeyboardTypeAsync(text)` | low-level human typing at the focused element |
|
||||
|
||||
---
|
||||
|
||||
## Launch API reference
|
||||
|
||||
All entry points live on the static `CloakLauncher` class and return an
|
||||
`await using`-friendly handle.
|
||||
|
||||
| Method | Returns | Notes |
|
||||
| --- | --- | --- |
|
||||
| `LaunchAsync(LaunchOptions)` | `CloakBrowserHandle` | launches a browser; `NewPageAsync`, `NewContextAsync`, `NewHumanPageAsync`, `RawBrowser` |
|
||||
| `LaunchContextAsync(LaunchContextOptions)` | `CloakContextHandle` | browser-owned context with emulation |
|
||||
| `LaunchPersistentContextAsync(userDataDir, LaunchContextOptions)` | `CloakContextHandle` | reuse a profile directory (cookies/localStorage persist) |
|
||||
|
||||
```csharp
|
||||
// emulated context
|
||||
await using var ctx = await CloakLauncher.LaunchContextAsync(new LaunchContextOptions
|
||||
{
|
||||
Locale = "en-US",
|
||||
Timezone = "America/New_York",
|
||||
Viewport = (1280, 800),
|
||||
ColorScheme = "dark",
|
||||
});
|
||||
var page = await ctx.NewPageAsync();
|
||||
```
|
||||
|
||||
> Locale and timezone are applied via **binary flags** (`--lang`,
|
||||
> `--fingerprint-locale`, `--fingerprint-timezone`) - *not* detectable CDP
|
||||
> emulation - matching the Python wrapper.
|
||||
|
||||
```csharp
|
||||
// persistent profile - cookies / localStorage survive across runs, so the
|
||||
// browser looks like a returning user instead of a fresh incognito session
|
||||
await using var ctx = await CloakLauncher.LaunchPersistentContextAsync("./profile",
|
||||
new LaunchContextOptions { Headless = false });
|
||||
var page = await ctx.NewPageAsync();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Options reference
|
||||
|
||||
`LaunchOptions` / `LaunchContextOptions` (commonly used fields):
|
||||
|
||||
| Option | Type | Default | Purpose |
|
||||
| --- | --- | --- | --- |
|
||||
| `Headless` | `bool` | `true` | run headless (detectors often flag this) |
|
||||
| `Humanize` | `bool` | `false` | enable the transparent humanize layer |
|
||||
| `HumanPreset` | `HumanPreset` | `Default` | `Default` or `Careful` (slower, more cautious) |
|
||||
| `HumanConfig` | `Dictionary<string,object>` | `null` | per-field overrides (snake_case **or** PascalCase keys) |
|
||||
| `Proxy` | `string` / `ProxySettings` | `null` | HTTP/HTTPS or SOCKS5 proxy |
|
||||
| `GeoIp` | `bool` | `false` | resolve timezone/locale from the proxy exit IP |
|
||||
| `Args` | `List<string>` | `[]` | extra Chromium flags (e.g. `--fingerprint-webrtc-ip=auto`) |
|
||||
| `Locale` | `string` | `null` | BCP 47 locale -> `--lang`, `--fingerprint-locale` |
|
||||
| `Timezone` | `string` | `null` | IANA timezone -> `--fingerprint-timezone` |
|
||||
| `Viewport` | `(int,int)` | - | window size |
|
||||
| `NoViewport` | `bool` | `false` | disable viewport emulation (track the real window) |
|
||||
| `ColorScheme` | `string` | - | `light` / `dark` |
|
||||
| `LicenseKey` | `string` | `null` | CloakBrowser Pro key (or `CLOAKBROWSER_LICENSE_KEY` env / `~/.cloakbrowser/license.key`) |
|
||||
| `BrowserVersion` | `string` | `null` | Pin an exact Chromium version (e.g. `"148.0.7778.215.2"`). Also reads from `CLOAKBROWSER_VERSION` env var. Works with Free and Pro. |
|
||||
|
||||
### CloakBrowser Pro
|
||||
|
||||
CloakBrowser ships in two tiers:
|
||||
|
||||
- **Free (v146)** — free forever on [GitHub Releases](https://github.com/CloakHQ/cloakbrowser/releases). Unlimited sessions. Works today, goes stale as detection evolves.
|
||||
- **Pro (latest, v148)** — the newest patches and Chromium upgrades first, so detection stays green as anti-bot systems change. Linux, Windows, and macOS (Apple Silicon + Intel).
|
||||
|
||||
Pro plans → **[cloakbrowser.dev](https://cloakbrowser.dev)**
|
||||
|
||||
Activate with a license key — the `LicenseKey` option, the `CLOAKBROWSER_LICENSE_KEY`
|
||||
env var, or `~/.cloakbrowser/license.key`:
|
||||
|
||||
```csharp
|
||||
await using var browser = await CloakLauncher.LaunchAsync(new LaunchOptions
|
||||
{
|
||||
LicenseKey = "cb_xxxxxxxx", // or via CLOAKBROWSER_LICENSE_KEY / ~/.cloakbrowser/license.key
|
||||
});
|
||||
```
|
||||
|
||||
A valid key downloads the latest Pro binary from cloakbrowser.dev; without one, the
|
||||
free binary downloads from GitHub Releases. Validation is cached locally for 24h, and
|
||||
the Pro binary is authenticated with the **same pinned Ed25519 signature** as the free
|
||||
binary — a key whose Pro download or signature check fails surfaces a clear error
|
||||
rather than silently downgrading. `Download.BinaryInfo()` exposes a `Tier` field
|
||||
(`"pro"` / `"free"`); `License.ValidateLicense` / `License.LicenseInfo` mirror the
|
||||
Python `validate_license` / `LicenseInfo` exports.
|
||||
|
||||
### Rolling back to a previous binary version
|
||||
|
||||
Pin an exact Chromium version without downgrading the wrapper — works for Free and Pro:
|
||||
|
||||
```csharp
|
||||
// Free — pin a public release
|
||||
await using var browser = await CloakLauncher.LaunchAsync(new LaunchOptions
|
||||
{
|
||||
BrowserVersion = "146.0.7680.177.5",
|
||||
});
|
||||
|
||||
// Pro — pin a previous Pro version
|
||||
await using var browser = await CloakLauncher.LaunchAsync(new LaunchOptions
|
||||
{
|
||||
LicenseKey = "cb_xxxxxxxx",
|
||||
BrowserVersion = "148.0.7778.215.2",
|
||||
});
|
||||
```
|
||||
|
||||
The pin is **never sticky** — unpinned launches always use the latest available version.
|
||||
You can also set it globally via the `CLOAKBROWSER_VERSION` env var (see [Environment variables](#environment-variables)).
|
||||
|
||||
---
|
||||
|
||||
## `HumanConfig` reference
|
||||
|
||||
Every behavior is tunable. Names match the Python `HumanConfig` dataclass; the
|
||||
override dictionary accepts both `snake_case` (Python parity) and `PascalCase`
|
||||
keys. Ranges are `(min, max)` and may be passed as a `(double,double)` tuple or a
|
||||
2-element array.
|
||||
|
||||
| Group | Field (PascalCase) | Default | Meaning |
|
||||
| --- | --- | --- | --- |
|
||||
| Keyboard | `TypingDelay` | `70` | base inter-key delay (ms) |
|
||||
| | `TypingDelaySpread` | `40` | ± jitter around `TypingDelay` |
|
||||
| | `TypingPauseChance` | `0.1` | chance of a longer "thinking" pause |
|
||||
| | `TypingPauseRange` | `(400, 1000)` | thinking-pause duration (ms) |
|
||||
| | `ShiftDownDelay` / `ShiftUpDelay` | `(30,70)` / `(20,50)` | shift key timing |
|
||||
| | `KeyHold` | `(15, 35)` | key-down hold time (ms) |
|
||||
| Mistype | `MistypeChance` | `0.02` | chance of a fat-finger typo (ASCII alnum only) |
|
||||
| | `MistypeDelayNotice` | `(100, 300)` | pause before noticing the typo |
|
||||
| | `MistypeDelayCorrect` | `(50, 150)` | pause after correcting |
|
||||
| | `FieldSwitchDelay` | `(800, 1500)` | delay when moving between fields |
|
||||
| Mouse - move | `MouseStepsDivisor` | `8` | distance ÷ divisor = step count |
|
||||
| | `MouseMinSteps` / `MouseMaxSteps` | `25` / `80` | clamp on step count |
|
||||
| | `MouseWobbleMax` | `1.5` | perpendicular wobble amplitude (px) |
|
||||
| | `MouseOvershootChance` | `0.15` | chance of overshoot + correction |
|
||||
| | `MouseOvershootPx` | `(3, 6)` | overshoot distance |
|
||||
| | `MouseBurstSize` / `MouseBurstPause` | `(3,5)` / `(8,18)` | move-burst grouping + pause |
|
||||
| Mouse - click | `ClickAimDelayInput` / `...Button` | `(60,140)` / `(80,200)` | aim delay before pressing |
|
||||
| | `ClickHoldInput` / `...Button` | `(40,100)` / `(60,150)` | mouse-down hold time |
|
||||
| | `ClickInputXRange` | `(0.05, 0.30)` | left-biased X target inside inputs |
|
||||
| Mouse - idle | `IdleDriftPx` | `3` | idle drift amplitude |
|
||||
| | `IdlePauseRange` | `(300, 1000)` | idle pause between drifts |
|
||||
| Scroll | `ScrollDeltaBase` | `(80, 130)` | wheel delta per microstep |
|
||||
| | `ScrollOvershootChance` | `0.1` | chance to overshoot then settle |
|
||||
| | `ScrollSettleDelay` | `(300, 600)` | pause after reaching the target |
|
||||
| | `ScrollTargetZone` | `(0.20, 0.80)` | viewport band the element lands in |
|
||||
| Cursor | `InitialCursorX` / `...Y` | `(400,700)` / `(45,60)` | starting cursor position (address-bar area) |
|
||||
| Idle between actions | `IdleBetweenActions` | `false` | opt-in micro-movements between actions (adds latency) |
|
||||
|
||||
Resolve / merge helpers:
|
||||
|
||||
```csharp
|
||||
// preset + overrides
|
||||
var cfg = HumanConfigFactory.Resolve(
|
||||
HumanPreset.Careful,
|
||||
new Dictionary<string, object> { ["typing_delay"] = 120.0, ["key_hold"] = (40.0, 90.0) });
|
||||
|
||||
// merge onto an existing config (never mutates the base; returns a new instance)
|
||||
var faster = cfg.With(new Dictionary<string, object> { ["TypingDelay"] = 30.0 });
|
||||
```
|
||||
|
||||
`With(...)` is **forgiving**: unknown keys are ignored silently, `null`/empty
|
||||
overrides return a clone, and non-overridden fields are preserved.
|
||||
|
||||
---
|
||||
|
||||
## How the humanize layer works
|
||||
|
||||
### Mouse motion (Bezier)
|
||||
|
||||
`HumanMouse.HumanMoveAsync` traces a **cubic Bezier curve** between the current
|
||||
cursor position and a randomized aim point inside the target's bounding box:
|
||||
|
||||
1. Step count scales with distance (`dist / MouseStepsDivisor`, clamped to
|
||||
`[MouseMinSteps, MouseMaxSteps]`).
|
||||
2. Two control points are biased **perpendicular** to the straight path, so the
|
||||
curve is never a straight line.
|
||||
3. Each step adds **sinusoidal wobble** (max amplitude `MouseWobbleMax`).
|
||||
4. Motion is **eased** (cubic ease-in-out) and grouped into bursts with short
|
||||
pauses (`MouseBurstSize` / `MouseBurstPause`).
|
||||
5. With probability `MouseOvershootChance`, the cursor overshoots the target and
|
||||
corrects back - exactly like a real hand.
|
||||
|
||||
Click points are computed by `HumanMouse.ClickTarget`: inputs get a left-biased X
|
||||
and wide Y band; buttons get a centered cluster.
|
||||
|
||||
### Human typing & the CDP stealth path
|
||||
|
||||
`HumanKeyboard.HumanTypeAsync` types one character at a time with variable delays
|
||||
and occasional "thinking" pauses. ASCII alphanumeric characters can trigger a
|
||||
**fat-finger typo** (a nearby QWERTY key) that is then noticed and corrected with
|
||||
Backspace.
|
||||
|
||||
Shift symbols (`@ # ! $ ...`) are the tricky case for stealth. When a **CDP
|
||||
session** is available, they are dispatched through
|
||||
`Input.dispatchKeyEvent` - producing `isTrusted = true` events with **no
|
||||
`evaluate` stack trace** for detectors to find. Without CDP, it falls back to a
|
||||
(detectable) `page.evaluate` path.
|
||||
|
||||
### Non-ASCII input
|
||||
|
||||
Cyrillic, CJK, emoji and other non-ASCII characters cannot be produced with
|
||||
physical key codes, so they are inserted via **`InsertText`**, one character at a
|
||||
time, while ASCII characters keep going through `Down`/`Up` key presses. For a
|
||||
mixed string like `"Hi Мир"`:
|
||||
|
||||
| Char | Path |
|
||||
| --- | --- |
|
||||
| `H`, `i`, space | `DownAsync` / `UpAsync` (with Shift for `H`) |
|
||||
| `М`, `и`, `р` | `InsertTextAsync` (per character) |
|
||||
|
||||
### Scrolling
|
||||
|
||||
`HumanScroll` performs an **accelerate -> cruise -> decelerate** wheel sequence in
|
||||
microsteps, with an optional overshoot-and-settle, landing the element in a
|
||||
natural viewport band (`ScrollTargetZone`).
|
||||
|
||||
### Actionability checks
|
||||
|
||||
Before interacting, the humanize layer runs Playwright-style **actionability
|
||||
checks** (`Actionability.cs`), with a retry/backoff loop `[100, 250, 500, 1000]`
|
||||
ms:
|
||||
|
||||
| Check | Meaning | Error on failure |
|
||||
| --- | --- | --- |
|
||||
| `attached` | element exists in the DOM | `ElementNotAttachedError` |
|
||||
| `visible` | element is visible | `ElementNotVisibleError` |
|
||||
| `stable` | bounding box stopped moving (post-scroll) | `ElementNotStableError` |
|
||||
| `enabled` | element is enabled | `ElementNotEnabledError` |
|
||||
| `editable` | element is editable | `ElementNotEditableError` |
|
||||
| `pointer_events` | the click point actually hits the element | `ElementNotReceivingEventsError` |
|
||||
|
||||
Action presets: `ChecksClick`, `ChecksHover`, `ChecksInput`, `ChecksFocus`,
|
||||
`ChecksCheck`.
|
||||
|
||||
**Fail-open pointer check.** The `pointer_events` probe uses
|
||||
`document.elementFromPoint`. If it *cannot run* (stale handle, execution context
|
||||
destroyed - `EvaluateAsync` throws), the check **fails open** and returns
|
||||
promptly rather than blocking until timeout - failing closed would wrongly block
|
||||
legitimate clicks. But an explicit `{ hit: false }` (the element is genuinely
|
||||
*covered*) still raises `ElementNotReceivingEventsError`.
|
||||
|
||||
### Shared timeout budget (#307)
|
||||
|
||||
All sequential steps of one action share a **single deadline** rather than each
|
||||
restarting the full timeout. The helper
|
||||
`Actionability.RemainingMs(deadline)` returns the milliseconds left (clamped at
|
||||
zero, never negative). Because every step subtracts from the *same* budget, the
|
||||
total wall-clock time can never multiply across steps - the bug fixed in
|
||||
upstream issue **#307**.
|
||||
|
||||
### Focus-aware press / clear
|
||||
|
||||
`PressAsync` and `ClearAsync` first probe focus with
|
||||
`EvaluateAsync<bool>("el => el === document.activeElement")`. If the element is
|
||||
**already focused**, they **skip the humanized click** entirely (the cursor does
|
||||
not move) and go straight to the keystrokes; otherwise they perform a humanized
|
||||
focus-click first. This avoids pointless mouse motion on an already-focused
|
||||
field.
|
||||
|
||||
---
|
||||
|
||||
## Proxy, GeoIP & WebRTC
|
||||
|
||||
```csharp
|
||||
await using var browser = await CloakLauncher.LaunchAsync(new LaunchOptions
|
||||
{
|
||||
Proxy = "http://user:pass@proxy.example.com:8080", // or a ProxySettings
|
||||
GeoIp = true, // tz/locale from exit IP
|
||||
Args = new List<string> { "--fingerprint-webrtc-ip=auto" },
|
||||
});
|
||||
```
|
||||
|
||||
- **SOCKS5** and **credentialed HTTP** proxies are routed through Chrome's
|
||||
`--proxy-server` with inline, URL-encoded credentials (matching the Python
|
||||
logic, including the `linux-x64` / `windows-x64` + binary-version gate for HTTP
|
||||
inline auth).
|
||||
- **GeoIP** looks up the proxy exit IP against MaxMind GeoLite2 and applies the
|
||||
resolved timezone/locale via binary flags.
|
||||
- **WebRTC** spoofing reuses that exit IP so `RTCPeerConnection` cannot leak the
|
||||
real address.
|
||||
|
||||
---
|
||||
|
||||
## CLI
|
||||
|
||||
Installed via NuGet you don't need a CLI — the binary auto-downloads on first launch
|
||||
and updates in the background, and `Download.BinaryInfo()` reports the installed
|
||||
version, path, and tier (`Download.EnsureBinary()` pre-fetches it on demand).
|
||||
|
||||
When running from a clone of the repo, a small CLI does the same:
|
||||
|
||||
```bash
|
||||
dotnet run --project src/CloakBrowser.Cli -- install # download the binary
|
||||
dotnet run --project src/CloakBrowser.Cli -- info # version / path / platform
|
||||
dotnet run --project src/CloakBrowser.Cli -- update # check + download newer
|
||||
dotnet run --project src/CloakBrowser.Cli -- clear-cache # remove cached binaries
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Environment variables
|
||||
|
||||
Same set as the Python wrapper:
|
||||
|
||||
| Variable | Purpose |
|
||||
| --- | --- |
|
||||
| `CLOAKBROWSER_BINARY_PATH` | Use a local binary, skip download |
|
||||
| `CLOAKBROWSER_CACHE_DIR` | Override the cache directory |
|
||||
| `CLOAKBROWSER_DOWNLOAD_URL` | Override the download URL |
|
||||
| `CLOAKBROWSER_AUTO_UPDATE` | Enable/disable background auto-update |
|
||||
| `CLOAKBROWSER_SKIP_CHECKSUM` | Skip SHA-256 verification |
|
||||
| `CLOAKBROWSER_VERSION` | Pin to an exact Chromium version for rollback (e.g. `148.0.7778.215.2`). Works with Free and Pro binaries |
|
||||
| `CLOAKBROWSER_GEOIP_TIMEOUT_SECONDS` | GeoIP HTTP timeout |
|
||||
| `CLOAKBROWSER_WIDEVINE_CDM` / `CLOAKBROWSER_WIDEVINE` | Widevine seeding control |
|
||||
|
||||
---
|
||||
|
||||
## Building & testing
|
||||
|
||||
```bash
|
||||
cd dotnet
|
||||
dotnet build CloakBrowser.sln # 0 warnings, 0 errors
|
||||
dotnet test CloakBrowser.sln # all green
|
||||
```
|
||||
|
||||
The runnable examples accept a scenario name:
|
||||
|
||||
```bash
|
||||
dotnet run --project examples/CloakBrowser.Examples -- basic
|
||||
dotnet run --project examples/CloakBrowser.Examples -- humanize
|
||||
dotnet run --project examples/CloakBrowser.Examples -- visual # on-screen cursor trail
|
||||
dotnet run --project examples/CloakBrowser.Examples -- behavioral
|
||||
dotnet run --project examples/CloakBrowser.Examples -- proxy-geoip
|
||||
```
|
||||
|
||||
### Test suite map
|
||||
|
||||
All tests are **browser-free**: production wrappers are exercised through
|
||||
`DispatchProxy`-backed Playwright fakes (`Wrappers/FakeProxy.cs`,
|
||||
`Fake.Of<T>()`), and `InternalsVisibleTo` lets the tests reach internal types.
|
||||
A handful of genuinely browser-dependent timing tests are marked
|
||||
`[Fact(Skip = "requires browser")]` rather than faked.
|
||||
|
||||
| Area | File(s) | What it proves |
|
||||
| --- | --- | --- |
|
||||
| Version / download | `ConfigTests.cs`, `DownloadConfigTests.cs` | version compare, archive names, checksum parsing, **zip-slip** path guard |
|
||||
| Launch args | `BuildArgsTests.cs`, `MiscTests.cs` | stealth args, `build_args` dedup |
|
||||
| Proxy | `ProxyResolverTests.cs` | URL resolution / encoding |
|
||||
| Bezier math | `BezierMathTests.cs` | curve produces many points, ends near target, no big jumps, deviates from a straight line |
|
||||
| Humanize config | `HumanConfigTests.cs` | presets, snake/Pascal overrides, range coercion, **merge never mutates base**, null/empty/unknown-key handling |
|
||||
| Transparent layer | `Wrappers/*.cs` | non-intercepted members delegate verbatim; intercepted members humanize; nested objects come back wrapped; exceptions & `CancellationToken`s propagate |
|
||||
| Non-ASCII keyboard | `Human/NonAsciiKeyboardTests.cs` | Cyrillic/CJK go via `InsertText`; ASCII via key presses; mixed strings route per-character |
|
||||
| Pointer-events fail-open | `Human/PointerEventsFailOpenTests.cs` | throwing probe returns fast (`< 500ms`); explicit "covered" raises `ElementNotReceivingEventsError` |
|
||||
| Timeout budget (#307) | `Human/TimeoutBudgetTests.cs` | `RemainingMs` never negative, decreases over time, shared (not multiplied) |
|
||||
| Focus check | `Wrappers/FocusCheckTests.cs` | focused element -> no humanized click (cursor doesn't move); non-focused -> click happens |
|
||||
|
||||
---
|
||||
|
||||
## Mapping to the Python source
|
||||
|
||||
| .NET file | Python source |
|
||||
| --- | --- |
|
||||
| `Config.cs` | `cloakbrowser/config.py` |
|
||||
| `Download.cs` | `cloakbrowser/download.py` |
|
||||
| `GeoIp.cs` | `cloakbrowser/geoip.py` |
|
||||
| `Widevine.cs` | `cloakbrowser/widevine.py` |
|
||||
| `CloakLauncher.cs` / `ProxyResolver.cs` | `cloakbrowser/browser.py` |
|
||||
| `Human/HumanConfig.cs` | `cloakbrowser/human/config.py` |
|
||||
| `Human/HumanMouse.cs` | `cloakbrowser/human/mouse.py` |
|
||||
| `Human/HumanKeyboard.cs` | `cloakbrowser/human/keyboard.py` |
|
||||
| `Human/HumanScroll.cs` | `cloakbrowser/human/scroll.py` |
|
||||
| `Human/Actionability.cs` | `cloakbrowser/human/actionability.py` |
|
||||
| `Human/HumanPage.cs` | `cloakbrowser/human/__init__.py` (`patch_page`) |
|
||||
| `CloakBrowser.Cli/` | `cloakbrowser/__main__.py` |
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
MIT - same as the upstream CloakBrowser project.
|
||||
@@ -0,0 +1,16 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<AssemblyName>cloakbrowser-examples</AssemblyName>
|
||||
<RootNamespace>CloakBrowser.Examples</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\CloakBrowser\CloakBrowser.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,570 @@
|
||||
using CloakBrowser;
|
||||
using CloakBrowser.Human;
|
||||
using Microsoft.Playwright;
|
||||
|
||||
// CloakBrowser .NET examples. Run a specific example by name:
|
||||
// dotnet run --project examples/CloakBrowser.Examples -- basic
|
||||
// dotnet run --project examples/CloakBrowser.Examples -- humanize
|
||||
// dotnet run --project examples/CloakBrowser.Examples -- context
|
||||
// dotnet run --project examples/CloakBrowser.Examples -- persistent
|
||||
// dotnet run --project examples/CloakBrowser.Examples -- proxy-geoip
|
||||
// dotnet run --project examples/CloakBrowser.Examples -- visual
|
||||
|
||||
string which = args.Length > 0 ? args[0] : "basic";
|
||||
|
||||
switch (which)
|
||||
{
|
||||
case "basic": await Basic(); break;
|
||||
case "humanize": await Humanize(); break;
|
||||
case "context": await Context(); break;
|
||||
case "persistent": await Persistent(); break;
|
||||
case "proxy-geoip": await ProxyGeoip(); break;
|
||||
case "bottest": await BotTest(); break;
|
||||
case "behavioral": await Behavioral(); break;
|
||||
case "visual": await Visual(); break;
|
||||
case "proxytest": await ProxyTest(); break;
|
||||
case "persisttest": await PersistTest(); break;
|
||||
case "webrtctest": await WebRtcTest(); break;
|
||||
case "trusted": await TrustedTest(); break;
|
||||
case "timeout": await TimeoutTest(); break;
|
||||
default:
|
||||
Console.Error.WriteLine($"Unknown example: {which}");
|
||||
Console.Error.WriteLine("Available: basic, humanize, context, persistent, proxy-geoip, bottest, behavioral, visual");
|
||||
Environment.Exit(2);
|
||||
break;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Basic launch - open a page and print the title.
|
||||
// ---------------------------------------------------------------------------
|
||||
static async Task Basic()
|
||||
{
|
||||
await using var browser = await CloakLauncher.LaunchAsync(new LaunchOptions
|
||||
{
|
||||
Headless = true,
|
||||
});
|
||||
var page = await browser.NewPageAsync();
|
||||
await page.GotoAsync("https://bot.incolumitas.com/");
|
||||
Console.WriteLine($"Title: {await page.TitleAsync()}");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Humanized interaction - Bezier mouse, human typing, scroll, actionability.
|
||||
// ---------------------------------------------------------------------------
|
||||
static async Task Humanize()
|
||||
{
|
||||
await using var browser = await CloakLauncher.LaunchAsync(new LaunchOptions
|
||||
{
|
||||
Headless = false,
|
||||
Humanize = true,
|
||||
HumanPreset = HumanPreset.Careful,
|
||||
HumanConfig = new Dictionary<string, object>
|
||||
{
|
||||
["typing_delay"] = 90.0,
|
||||
["mouse_overshoot_chance"] = 0.2,
|
||||
},
|
||||
});
|
||||
|
||||
// NewHumanPageAsync returns a HumanPage wrapper with the configured behavior.
|
||||
HumanPage human = await browser.NewHumanPageAsync();
|
||||
await human.GotoAsync("https://example.com/");
|
||||
|
||||
// Humanized actions go through actionability checks + Bezier movement.
|
||||
await human.ClickAsync("a");
|
||||
// await human.FillAsync("#search", "hello world");
|
||||
// await human.PressAsync("#search", "Enter");
|
||||
|
||||
Console.WriteLine($"Title: {await human.Page.TitleAsync()}");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Context with viewport, user agent, locale, timezone.
|
||||
// ---------------------------------------------------------------------------
|
||||
static async Task Context()
|
||||
{
|
||||
await using var ctx = await CloakLauncher.LaunchContextAsync(new LaunchContextOptions
|
||||
{
|
||||
Headless = true,
|
||||
Locale = "en-US",
|
||||
Timezone = "America/New_York",
|
||||
Viewport = (1280, 800),
|
||||
ColorScheme = "dark",
|
||||
});
|
||||
var page = await ctx.NewPageAsync();
|
||||
await page.GotoAsync("https://example.com/");
|
||||
Console.WriteLine($"Title: {await page.TitleAsync()}");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Persistent profile - cookies/localStorage survive across runs.
|
||||
// ---------------------------------------------------------------------------
|
||||
static async Task Persistent()
|
||||
{
|
||||
await using var ctx = await CloakLauncher.LaunchPersistentContextAsync(
|
||||
"./cloak-profile",
|
||||
new LaunchContextOptions { Headless = true });
|
||||
var page = await ctx.NewPageAsync();
|
||||
await page.GotoAsync("https://example.com/");
|
||||
Console.WriteLine($"Title: {await page.TitleAsync()} (profile saved to ./cloak-profile)");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Proxy + GeoIP - timezone/locale auto-detected, WebRTC IP spoofed to exit IP.
|
||||
// ---------------------------------------------------------------------------
|
||||
static async Task ProxyGeoip()
|
||||
{
|
||||
await using var browser = await CloakLauncher.LaunchAsync(new LaunchOptions
|
||||
{
|
||||
Headless = true,
|
||||
Proxy = "http://user:pass@proxy.example.com:8080",
|
||||
GeoIp = true, // resolves timezone/locale + WebRTC exit IP from the proxy
|
||||
Args = new List<string> { "--fingerprint-webrtc-ip=auto" },
|
||||
});
|
||||
var page = await browser.NewPageAsync();
|
||||
await page.GotoAsync("https://ipinfo.io/json");
|
||||
Console.WriteLine(await page.InnerTextAsync("body"));
|
||||
}
|
||||
|
||||
static async Task BotTest()
|
||||
{
|
||||
await using var browser = await CloakLauncher.LaunchAsync(new LaunchOptions
|
||||
{
|
||||
Headless = false, // detectors often flag headless
|
||||
Humanize = true, // transparent decorator - the KEY feature
|
||||
});
|
||||
|
||||
var page = await browser.NewPageAsync(); // should return a wrapped IPage
|
||||
|
||||
// verify runtime types: is the wrapper actually in place?
|
||||
Console.WriteLine($"Page type: {page.GetType().Name}");
|
||||
Console.WriteLine($"Mouse type: {page.Mouse.GetType().Name}");
|
||||
Console.WriteLine($"Keyboard type: {page.Keyboard.GetType().Name}");
|
||||
|
||||
await page.GotoAsync("https://deviceandbrowserinfo.com/are_you_a_bot");
|
||||
await page.WaitForTimeoutAsync(2000);
|
||||
|
||||
// real humanized actions
|
||||
await page.Mouse.MoveAsync(300, 300);
|
||||
await page.Mouse.WheelAsync(0, 500);
|
||||
await page.WaitForTimeoutAsync(1500);
|
||||
|
||||
await page.ScreenshotAsync(new() { Path = "bot-result.png", FullPage = true });
|
||||
Console.WriteLine("Saved bot-result.png");
|
||||
await page.WaitForTimeoutAsync(4000); // leave time to inspect it visually
|
||||
}
|
||||
|
||||
static async Task Behavioral()
|
||||
{
|
||||
await using var browser = await CloakLauncher.LaunchAsync(new LaunchOptions
|
||||
{
|
||||
Headless = false,
|
||||
Humanize = true,
|
||||
});
|
||||
var page = await browser.NewPageAsync();
|
||||
|
||||
await page.GotoAsync("https://deviceandbrowserinfo.com/are_you_a_bot_interactions",
|
||||
new() { WaitUntil = WaitUntilState.DOMContentLoaded });
|
||||
await page.WaitForTimeoutAsync(3000);
|
||||
|
||||
var t0 = DateTime.UtcNow;
|
||||
|
||||
await page.Locator("#email").ClickAsync();
|
||||
await page.WaitForTimeoutAsync(300);
|
||||
await page.Locator("#email").FillAsync("test@example.com");
|
||||
await page.WaitForTimeoutAsync(500);
|
||||
|
||||
await page.Locator("#password").ClickAsync();
|
||||
await page.WaitForTimeoutAsync(300);
|
||||
await page.Locator("#password").FillAsync("SecurePass!123");
|
||||
await page.WaitForTimeoutAsync(500);
|
||||
|
||||
await page.Locator("#loginForm button[type=\"submit\"]").ClickAsync();
|
||||
|
||||
var elapsedMs = (DateTime.UtcNow - t0).TotalMilliseconds;
|
||||
await page.WaitForTimeoutAsync(5000);
|
||||
|
||||
var body = await page.Locator("body").TextContentAsync() ?? "";
|
||||
|
||||
bool superHuman = body.Contains("\"superHumanSpeed\": true");
|
||||
bool suspicious = body.Contains("\"suspiciousClientSideBehavior\": true");
|
||||
|
||||
Console.WriteLine($"--- BEHAVIORAL RESULT ---");
|
||||
Console.WriteLine($"Form fill elapsed: {elapsedMs:F0} ms (Python requires > 3000)");
|
||||
Console.WriteLine($"superHumanSpeed: {superHuman} (expected False)");
|
||||
Console.WriteLine($"suspiciousClientSideBehavior: {suspicious} (expected False)");
|
||||
Console.WriteLine(superHuman || suspicious || elapsedMs < 3000
|
||||
? ">>> FAIL - the port behaves non-humanly"
|
||||
: ">>> PASS - humanization works just like Python");
|
||||
|
||||
await page.ScreenshotAsync(new() { Path = "behavioral-result.png", FullPage = true });
|
||||
Console.WriteLine("Screenshot: behavioral-result.png");
|
||||
}
|
||||
|
||||
static async Task Visual()
|
||||
{
|
||||
const string cursorJs = @"
|
||||
() => {
|
||||
if (document.getElementById('__hc')) return;
|
||||
const el = document.createElement('div');
|
||||
el.id = '__hc';
|
||||
el.style.cssText = 'width:14px;height:14px;background:red;border:2px solid darkred;border-radius:50%;position:fixed;z-index:2147483647;pointer-events:none;display:none;transition:background 0.05s;';
|
||||
document.body.appendChild(el);
|
||||
const trail = document.createElement('div');
|
||||
trail.id = '__hcTrail';
|
||||
trail.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;z-index:2147483646;pointer-events:none;overflow:hidden;';
|
||||
document.body.appendChild(trail);
|
||||
let dotCount = 0; const maxDots = 500;
|
||||
function updatePos(x, y) {
|
||||
el.style.display = 'block';
|
||||
el.style.left = (x - 9) + 'px'; el.style.top = (y - 9) + 'px';
|
||||
if (dotCount < maxDots) {
|
||||
const dot = document.createElement('div');
|
||||
dot.style.cssText = 'width:3px;height:3px;background:rgba(255,0,0,0.3);border-radius:50%;position:fixed;pointer-events:none;left:'+(x-1)+'px;top:'+(y-1)+'px;';
|
||||
trail.appendChild(dot); dotCount++;
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousemove', e => updatePos(e.clientX, e.clientY));
|
||||
document.addEventListener('drag', e => { if (e.clientX > 0) updatePos(e.clientX, e.clientY); });
|
||||
document.addEventListener('dragover', e => { if (e.clientX > 0) updatePos(e.clientX, e.clientY); });
|
||||
document.addEventListener('mousedown', () => { el.style.background = 'yellow'; });
|
||||
document.addEventListener('mouseup', () => { el.style.background = 'red'; });
|
||||
document.addEventListener('dragend', () => { el.style.background = 'red'; });
|
||||
}";
|
||||
|
||||
var results = new List<(string Name, bool Passed, string Detail)>();
|
||||
void Check(string name, bool ok, string detail = "")
|
||||
{
|
||||
results.Add((name, ok, detail));
|
||||
Console.WriteLine($" [{(ok ? "PASS" : "FAIL")}] {name}{(detail != "" ? " - " + detail : "")}");
|
||||
}
|
||||
|
||||
await using var browser = await CloakLauncher.LaunchAsync(new LaunchOptions
|
||||
{
|
||||
Headless = false,
|
||||
Humanize = true,
|
||||
});
|
||||
var page = await browser.NewPageAsync();
|
||||
|
||||
async Task Inject()
|
||||
{
|
||||
try { await page.EvaluateAsync(cursorJs); } catch { }
|
||||
await page.WaitForTimeoutAsync(300);
|
||||
}
|
||||
|
||||
static long Ms(DateTime t0) => (long)(DateTime.UtcNow - t0).TotalMilliseconds;
|
||||
|
||||
Console.WriteLine(new string('=', 70));
|
||||
Console.WriteLine(" HUMAN-LIKE BEHAVIOR VISUAL TEST");
|
||||
Console.WriteLine(" Red dot = cursor, yellow = button held, trail = path");
|
||||
Console.WriteLine(new string('=', 70));
|
||||
|
||||
// SCENARIO 1: Wikipedia search
|
||||
Console.WriteLine("\n=== Wikipedia - navigation and search ===");
|
||||
await page.GotoAsync("https://www.wikipedia.org");
|
||||
await page.WaitForTimeoutAsync(2000); await Inject(); await page.WaitForTimeoutAsync(1000);
|
||||
|
||||
var t = DateTime.UtcNow;
|
||||
await page.Locator("#searchInput").ClickAsync();
|
||||
Check("click search input", Ms(t) > 200, $"{Ms(t)} ms");
|
||||
await page.WaitForTimeoutAsync(500);
|
||||
|
||||
t = DateTime.UtcNow;
|
||||
await page.Locator("#searchInput").FillAsync("Python programming language");
|
||||
var v = await page.Locator("#searchInput").InputValueAsync();
|
||||
Check("fill search box", v == "Python programming language" && Ms(t) > 2000, $"{Ms(t)} ms, '{v}'");
|
||||
await page.WaitForTimeoutAsync(500);
|
||||
|
||||
t = DateTime.UtcNow;
|
||||
await page.Locator("#searchInput").DblClickAsync();
|
||||
var sel = (await page.EvaluateAsync<string>("() => window.getSelection().toString().trim()")) ?? "";
|
||||
Check("dblclick selects word", sel.Length > 0 && Ms(t) > 200, $"{Ms(t)} ms, '{sel}'");
|
||||
await page.WaitForTimeoutAsync(500);
|
||||
|
||||
t = DateTime.UtcNow;
|
||||
await page.Locator("#searchInput").FillAsync("Artificial intelligence");
|
||||
var v2 = await page.Locator("#searchInput").InputValueAsync();
|
||||
Check("fill replaces text", v2 == "Artificial intelligence" && Ms(t) > 1500, $"{Ms(t)} ms, '{v2}'");
|
||||
await page.WaitForTimeoutAsync(500);
|
||||
|
||||
t = DateTime.UtcNow;
|
||||
await page.Locator("button[type=\"submit\"]").HoverAsync();
|
||||
Check("hover submit", Ms(t) > 100, $"{Ms(t)} ms");
|
||||
await page.WaitForTimeoutAsync(1000);
|
||||
|
||||
// SCENARIO 2: Checkboxes
|
||||
Console.WriteLine("\n=== Checkboxes - check/uncheck ===");
|
||||
await page.GotoAsync("https://the-internet.herokuapp.com/checkboxes");
|
||||
await page.WaitForTimeoutAsync(2000); await Inject(); await page.WaitForTimeoutAsync(1000);
|
||||
|
||||
var cb1 = page.Locator("input[type=\"checkbox\"]").Nth(0);
|
||||
var cb2 = page.Locator("input[type=\"checkbox\"]").Nth(1);
|
||||
if (await cb1.IsCheckedAsync()) { await cb1.UncheckAsync(); await page.WaitForTimeoutAsync(500); }
|
||||
t = DateTime.UtcNow;
|
||||
await cb1.CheckAsync();
|
||||
Check("check checkbox 1", await cb1.IsCheckedAsync() && Ms(t) > 200, $"{Ms(t)} ms");
|
||||
await page.WaitForTimeoutAsync(500);
|
||||
if (!await cb2.IsCheckedAsync()) { await cb2.CheckAsync(); await page.WaitForTimeoutAsync(500); }
|
||||
t = DateTime.UtcNow;
|
||||
await cb2.UncheckAsync();
|
||||
Check("uncheck checkbox 2", !await cb2.IsCheckedAsync() && Ms(t) > 200, $"{Ms(t)} ms");
|
||||
await page.WaitForTimeoutAsync(1000);
|
||||
|
||||
// SCENARIO 3: Dropdown
|
||||
Console.WriteLine("\n=== Dropdown - select ===");
|
||||
await page.GotoAsync("https://the-internet.herokuapp.com/dropdown");
|
||||
await page.WaitForTimeoutAsync(2000); await Inject(); await page.WaitForTimeoutAsync(1000);
|
||||
t = DateTime.UtcNow;
|
||||
await page.Locator("#dropdown").SelectOptionAsync("1");
|
||||
Check("select option 1", await page.Locator("#dropdown").InputValueAsync() == "1" && Ms(t) > 100, $"{Ms(t)} ms");
|
||||
await page.WaitForTimeoutAsync(500);
|
||||
t = DateTime.UtcNow;
|
||||
await page.Locator("#dropdown").SelectOptionAsync("2");
|
||||
Check("select option 2", await page.Locator("#dropdown").InputValueAsync() == "2" && Ms(t) > 100, $"{Ms(t)} ms");
|
||||
await page.WaitForTimeoutAsync(1000);
|
||||
|
||||
// SCENARIO 4: Drag and drop
|
||||
Console.WriteLine("\n=== Drag and Drop - A -> B ===");
|
||||
await page.GotoAsync("https://the-internet.herokuapp.com/drag_and_drop");
|
||||
await page.WaitForTimeoutAsync(2000); await Inject(); await page.WaitForTimeoutAsync(1000);
|
||||
var beforeA = (await page.Locator("#column-a header").TextContentAsync())?.Trim();
|
||||
t = DateTime.UtcNow;
|
||||
await page.Locator("#column-a").DragToAsync(page.Locator("#column-b"));
|
||||
await page.WaitForTimeoutAsync(1000);
|
||||
var afterA = (await page.Locator("#column-a header").TextContentAsync())?.Trim();
|
||||
Check("drag A to B", beforeA != afterA && Ms(t) > 300, $"{Ms(t)} ms, swapped={beforeA != afterA}");
|
||||
await page.WaitForTimeoutAsync(1000);
|
||||
|
||||
// SCENARIO 5: Text editing
|
||||
Console.WriteLine("\n=== Text editing - type/press/clear/sequential ===");
|
||||
await page.GotoAsync("https://www.wikipedia.org");
|
||||
await page.WaitForTimeoutAsync(2000); await Inject(); await page.WaitForTimeoutAsync(1000);
|
||||
t = DateTime.UtcNow;
|
||||
await page.Locator("#searchInput").PressSequentiallyAsync("Hello World");
|
||||
var hv = await page.Locator("#searchInput").InputValueAsync();
|
||||
Check("type 'Hello World'", hv == "Hello World" && Ms(t) > 1000, $"{Ms(t)} ms, '{hv}'");
|
||||
await page.WaitForTimeoutAsync(500);
|
||||
t = DateTime.UtcNow;
|
||||
await page.Locator("#searchInput").PressAsync("End");
|
||||
await page.Locator("#searchInput").PressAsync("!");
|
||||
var pv = await page.Locator("#searchInput").InputValueAsync();
|
||||
Check("press '!' at end", (pv ?? "").Contains("!") && Ms(t) > 100, $"{Ms(t)} ms, '{pv}'");
|
||||
await page.WaitForTimeoutAsync(500);
|
||||
t = DateTime.UtcNow;
|
||||
await page.Locator("#searchInput").ClearAsync();
|
||||
var cv = await page.Locator("#searchInput").InputValueAsync();
|
||||
Check("clear field", cv == "" && Ms(t) > 100, $"{Ms(t)} ms");
|
||||
await page.WaitForTimeoutAsync(500);
|
||||
t = DateTime.UtcNow;
|
||||
await page.Locator("#searchInput").PressSequentiallyAsync("Sequential");
|
||||
var sv = await page.Locator("#searchInput").InputValueAsync();
|
||||
Check("press_sequentially", sv == "Sequential" && Ms(t) > 500, $"{Ms(t)} ms, '{sv}'");
|
||||
await page.WaitForTimeoutAsync(1000);
|
||||
|
||||
// SCENARIO 6: Mouse precision + raw keyboard
|
||||
Console.WriteLine("\n=== Mouse precision + keyboard ===");
|
||||
t = DateTime.UtcNow;
|
||||
await page.Mouse.MoveAsync(600, 400);
|
||||
Check("mouse.move (600,400)", Ms(t) > 100, $"{Ms(t)} ms");
|
||||
await page.WaitForTimeoutAsync(500);
|
||||
t = DateTime.UtcNow;
|
||||
await page.Mouse.ClickAsync(200, 200);
|
||||
Check("mouse.click (200,200)", Ms(t) > 100, $"{Ms(t)} ms");
|
||||
await page.WaitForTimeoutAsync(500);
|
||||
await page.Locator("#searchInput").ClickAsync();
|
||||
await page.WaitForTimeoutAsync(300);
|
||||
t = DateTime.UtcNow;
|
||||
await page.Keyboard.TypeAsync("Direct keyboard");
|
||||
Check("keyboard.type", Ms(t) > 500, $"{Ms(t)} ms");
|
||||
await page.WaitForTimeoutAsync(1000);
|
||||
|
||||
// SUMMARY
|
||||
Console.WriteLine("\n" + new string('=', 70));
|
||||
int passed = results.Count(r => r.Passed);
|
||||
foreach (var r in results)
|
||||
Console.WriteLine($" [{(r.Passed ? "OK" : "XX")}] {r.Name}");
|
||||
Console.WriteLine($"\n {passed}/{results.Count} passed, {results.Count - passed} failed");
|
||||
if (passed == results.Count) Console.WriteLine(" *** ALL VISUAL TESTS PASSED ***");
|
||||
Console.WriteLine(new string('=', 70));
|
||||
|
||||
Console.WriteLine("\nPress Enter to close the browser...");
|
||||
Console.ReadLine();
|
||||
}
|
||||
static async Task ProxyTest()
|
||||
{
|
||||
var proxy = Environment.GetEnvironmentVariable("CLOAK_TEST_PROXY_SOCKS5");
|
||||
if (string.IsNullOrEmpty(proxy))
|
||||
{
|
||||
Console.WriteLine("Set CLOAK_TEST_PROXY_SOCKS5=socks5://user:pass@host:port to run this example");
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
Console.WriteLine($"Testing proxy: {proxy}");
|
||||
await using var browser = await CloakLauncher.LaunchAsync(new LaunchOptions
|
||||
{
|
||||
Headless = true,
|
||||
Proxy = proxy,
|
||||
GeoIp = true, // timezone/locale + WebRTC from the exit IP
|
||||
Args = new List<string> { "--fingerprint-webrtc-ip=auto" },
|
||||
});
|
||||
var page = await browser.NewPageAsync();
|
||||
|
||||
// 1. is the traffic actually going through the proxy
|
||||
await page.GotoAsync("https://ipinfo.io/json");
|
||||
var body = await page.InnerTextAsync("body");
|
||||
Console.WriteLine(body);
|
||||
|
||||
// 2. which timezone/locale actually got applied in the browser
|
||||
var tz = await page.EvaluateAsync<string>(
|
||||
"() => Intl.DateTimeFormat().resolvedOptions().timeZone");
|
||||
var locale = await page.EvaluateAsync<string>("() => navigator.language");
|
||||
Console.WriteLine($"\nBrowser timezone: {tz}");
|
||||
Console.WriteLine($"Browser locale: {locale}");
|
||||
}
|
||||
static async Task PersistTest()
|
||||
{
|
||||
const string profile = "./cloak-persist-test";
|
||||
const string url = "https://example.com/";
|
||||
|
||||
Console.WriteLine("=== Run 1: write data into the profile ===");
|
||||
await using (var ctx = await CloakLauncher.LaunchPersistentContextAsync(
|
||||
profile, new LaunchContextOptions { Headless = true }))
|
||||
{
|
||||
var page = await ctx.NewPageAsync();
|
||||
await page.GotoAsync(url);
|
||||
await page.EvaluateAsync(@"() => {
|
||||
localStorage.setItem('cloakTest', 'survived-42');
|
||||
document.cookie = 'cloak_cookie=persist-ok; path=/; max-age=86400';
|
||||
}");
|
||||
Console.WriteLine("Wrote: localStorage cloakTest=survived-42, cookie cloak_cookie=persist-ok");
|
||||
}
|
||||
|
||||
Console.WriteLine("\n=== Run 2: read back from the same profile ===");
|
||||
await using (var ctx = await CloakLauncher.LaunchPersistentContextAsync(
|
||||
profile, new LaunchContextOptions { Headless = true }))
|
||||
{
|
||||
var page = await ctx.NewPageAsync();
|
||||
await page.GotoAsync(url);
|
||||
var ls = await page.EvaluateAsync<string?>("() => localStorage.getItem('cloakTest')");
|
||||
var ck = await page.EvaluateAsync<string?>(
|
||||
"() => (document.cookie.match(/cloak_cookie=([^;]+)/) || [null,null])[1]");
|
||||
|
||||
Console.WriteLine($"localStorage cloakTest: {ls ?? "(null)"} (expected 'survived-42')");
|
||||
Console.WriteLine($"cookie cloak_cookie: {ck ?? "(null)"} (expected 'persist-ok')");
|
||||
Console.WriteLine(ls == "survived-42" && ck == "persist-ok"
|
||||
? ">>> PASS - the profile survives a restart"
|
||||
: ">>> FAIL - data was not persisted");
|
||||
}
|
||||
}
|
||||
|
||||
static async Task WebRtcTest()
|
||||
{
|
||||
var proxy = Environment.GetEnvironmentVariable("CLOAK_TEST_PROXY");
|
||||
if (string.IsNullOrEmpty(proxy))
|
||||
{
|
||||
Console.WriteLine("Set CLOAK_TEST_PROXY=http://user:pass@host:port to run this example");
|
||||
return;
|
||||
}
|
||||
|
||||
await using var browser = await CloakLauncher.LaunchAsync(new LaunchOptions
|
||||
{
|
||||
Headless = true,
|
||||
Proxy = proxy,
|
||||
GeoIp = true,
|
||||
Args = new List<string> { "--fingerprint-webrtc-ip=auto" },
|
||||
});
|
||||
var page = await browser.NewPageAsync();
|
||||
|
||||
await page.GotoAsync("https://browserleaks.com/webrtc");
|
||||
await page.WaitForTimeoutAsync(6000); // give WebRTC time to run
|
||||
|
||||
var text = await page.InnerTextAsync("body");
|
||||
Console.WriteLine("--- browserleaks WebRTC (looking for IPs) ---");
|
||||
// pull out lines that look like an IP
|
||||
foreach (var line in text.Split('\n'))
|
||||
if (System.Text.RegularExpressions.Regex.IsMatch(line, @"\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}"))
|
||||
Console.WriteLine(" " + line.Trim());
|
||||
|
||||
await page.ScreenshotAsync(new() { Path = "webrtc-result.png", FullPage = true });
|
||||
Console.WriteLine("\nScreenshot: webrtc-result.png");
|
||||
}
|
||||
static async Task TrustedTest()
|
||||
{
|
||||
await using var browser = await CloakLauncher.LaunchAsync(new LaunchOptions
|
||||
{
|
||||
Headless = false, Humanize = true,
|
||||
});
|
||||
var page = await browser.NewPageAsync();
|
||||
await page.GotoAsync("https://www.wikipedia.org");
|
||||
await page.WaitForTimeoutAsync(1500);
|
||||
|
||||
// detector: catch untrusted keydown + querySelector from the evaluate context
|
||||
await page.EvaluateAsync(@"() => {
|
||||
window.__untrusted = [];
|
||||
window.__evalLeaks = [];
|
||||
const input = document.querySelector('#searchInput');
|
||||
input.addEventListener('keydown', e => {
|
||||
if (!e.isTrusted) window.__untrusted.push(e.key);
|
||||
}, true);
|
||||
const origQS = document.querySelector.bind(document);
|
||||
document.querySelector = function(sel){
|
||||
try { throw new Error(); } catch(e){
|
||||
if (e.stack && /:\d+:\d+/.test(e.stack) && e.stack.includes('eval')) {
|
||||
window.__evalLeaks.push(sel);
|
||||
}
|
||||
}
|
||||
return origQS(sel);
|
||||
};
|
||||
}");
|
||||
|
||||
await page.Locator("#searchInput").ClickAsync();
|
||||
await page.WaitForTimeoutAsync(300);
|
||||
await page.Keyboard.TypeAsync("Hello!@#$%^&*()");
|
||||
await page.WaitForTimeoutAsync(800);
|
||||
|
||||
var untrusted = await page.EvaluateAsync<string[]>("() => window.__untrusted");
|
||||
var leaks = await page.EvaluateAsync<string[]>("() => window.__evalLeaks");
|
||||
|
||||
Console.WriteLine($"Untrusted keydown events: {untrusted.Length} -> {string.Join(",", untrusted)}");
|
||||
Console.WriteLine($"evaluate querySelector leaks: {leaks.Length}");
|
||||
Console.WriteLine(untrusted.Length == 0
|
||||
? ">>> PASS - all shift symbols are isTrusted=true, no evaluate leaks"
|
||||
: ">>> FAIL - untrusted events found (a detector would catch them)");
|
||||
}
|
||||
static async Task TimeoutTest()
|
||||
{
|
||||
const double timeoutMs = 2000;
|
||||
const double budgetMultiplier = 1.8; // matches Python TestTimeoutBudget307
|
||||
|
||||
await using var browser = await CloakLauncher.LaunchAsync(new LaunchOptions
|
||||
{
|
||||
Headless = true,
|
||||
Humanize = true,
|
||||
});
|
||||
var page = await browser.NewPageAsync();
|
||||
await page.GotoAsync("https://example.com/");
|
||||
|
||||
Console.WriteLine($"Clicking a non-existent selector with timeout={timeoutMs}ms...");
|
||||
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
try
|
||||
{
|
||||
await page.ClickAsync("#this-element-does-not-exist",
|
||||
new() { Timeout = (float)timeoutMs });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Expected exception: {ex.GetType().Name}");
|
||||
}
|
||||
sw.Stop();
|
||||
|
||||
double elapsed = sw.Elapsed.TotalMilliseconds;
|
||||
double limit = timeoutMs * budgetMultiplier;
|
||||
|
||||
Console.WriteLine("--- TIMEOUT BUDGET (issue #307) ---");
|
||||
Console.WriteLine($"Elapsed: {elapsed:F0} ms");
|
||||
Console.WriteLine($"Limit (1.8x): {limit:F0} ms");
|
||||
Console.WriteLine(elapsed < limit
|
||||
? ">>> PASS - timeout budget is shared, not multiplied"
|
||||
: ">>> FAIL - timeout multiplied (each step took the full budget)");
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<AssemblyName>cloakbrowser-cli</AssemblyName>
|
||||
<RootNamespace>CloakBrowser.Cli</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\CloakBrowser\CloakBrowser.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,253 @@
|
||||
using System.Text.Json;
|
||||
using CloakBrowser;
|
||||
|
||||
// CLI for cloakbrowser - download and manage the stealth Chromium binary.
|
||||
// Direct port of Python cloakbrowser/__main__.py.
|
||||
//
|
||||
// Usage:
|
||||
// cloakbrowser install # Download binary (with progress)
|
||||
// cloakbrowser info # Environment + binary diagnostics (--quick, --json)
|
||||
// cloakbrowser doctor # Alias for info
|
||||
// cloakbrowser update # Check for and download newer binary
|
||||
// cloakbrowser clear-cache # Remove cached binaries
|
||||
|
||||
const string UpgradeHint =
|
||||
"→ Try the latest Pro binary (Chromium 148) free for 7 days: https://cloakbrowser.dev";
|
||||
|
||||
// Route CloakBrowser logs to stderr at Info level (clean output).
|
||||
CloakLog.MinLevel = CloakLogLevel.Info;
|
||||
|
||||
string? command = args.Length > 0 ? args[0] : null;
|
||||
string[] rest = args.Length > 1 ? args[1..] : Array.Empty<string>();
|
||||
|
||||
if (string.IsNullOrEmpty(command) || command is "-h" or "--help" or "help")
|
||||
{
|
||||
PrintHelp();
|
||||
return string.IsNullOrEmpty(command) ? 2 : 0;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
switch (command)
|
||||
{
|
||||
case "install":
|
||||
await CmdInstall();
|
||||
break;
|
||||
case "info":
|
||||
case "doctor":
|
||||
CmdInfo(rest);
|
||||
break;
|
||||
case "update":
|
||||
await CmdUpdate();
|
||||
break;
|
||||
case "clear-cache":
|
||||
CmdClearCache();
|
||||
break;
|
||||
default:
|
||||
Console.Error.WriteLine($"Unknown command: {command}");
|
||||
PrintHelp();
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return 130;
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Console.Error.WriteLine($"Error: {e.Message}");
|
||||
return 1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
|
||||
static async Task CmdInstall()
|
||||
{
|
||||
string path = await Download.EnsureBinaryAsync().ConfigureAwait(false);
|
||||
Console.WriteLine(path);
|
||||
}
|
||||
|
||||
static void CmdInfo(string[] flags)
|
||||
{
|
||||
bool quick = flags.Contains("--quick") || flags.Contains("--no-launch");
|
||||
bool asJson = flags.Contains("--json");
|
||||
|
||||
var diag = Diagnostics.Collect(quick);
|
||||
|
||||
if (asJson)
|
||||
{
|
||||
Console.WriteLine(JsonSerializer.Serialize(diag, new JsonSerializerOptions { WriteIndented = true }));
|
||||
}
|
||||
else
|
||||
{
|
||||
PrintDiagnostics(diag);
|
||||
}
|
||||
}
|
||||
|
||||
static void PrintDiagnostics(Dictionary<string, object?> diag)
|
||||
{
|
||||
var env = (Dictionary<string, object?>)diag["environment"]!;
|
||||
Console.WriteLine("CloakBrowser diagnostics");
|
||||
Console.WriteLine($".NET: {env["dotnet"]}");
|
||||
Console.WriteLine($"OS: {env["os"]} {env["arch"]}");
|
||||
Console.WriteLine($"Platform: {env.GetValueOrDefault("platform_tag") ?? "unknown"}");
|
||||
|
||||
var binary = (Dictionary<string, object?>)diag["binary"]!;
|
||||
if (binary.ContainsKey("error"))
|
||||
{
|
||||
Console.WriteLine($"Binary: unavailable ({binary["error"]})");
|
||||
}
|
||||
else
|
||||
{
|
||||
if ((string)binary["tier"]! == "override")
|
||||
Console.WriteLine("Version: set via CLOAKBROWSER_BINARY_PATH (see Launch line)");
|
||||
else if (binary.TryGetValue("latest_version", out var lv) && lv is string latest && !string.IsNullOrEmpty(latest))
|
||||
{
|
||||
// Pro: show what launches now AND the server's latest, so the two can't diverge.
|
||||
Console.WriteLine($"Version: {binary["version"]} ({binary["tier"]}) — will launch");
|
||||
if (latest == binary["version"] as string)
|
||||
Console.WriteLine($"Latest: {latest} (up to date)");
|
||||
else if (binary.TryGetValue("pinned", out var p) && p is true)
|
||||
Console.WriteLine($"Latest: {latest} (available — pinned; unset CLOAKBROWSER_VERSION to upgrade)");
|
||||
else
|
||||
Console.WriteLine($"Latest: {latest} (available — next launch upgrades)");
|
||||
}
|
||||
else if (binary["version"] is null)
|
||||
// Pro with no cached build and no server answer (e.g. offline).
|
||||
Console.WriteLine($"Version: not downloaded yet ({binary["tier"]}) — next launch downloads the latest");
|
||||
else
|
||||
Console.WriteLine($"Version: {binary["version"]} ({binary["tier"]})");
|
||||
Console.WriteLine($"Binary: {binary["path"]}");
|
||||
Console.WriteLine($"Installed: {binary["installed"]}");
|
||||
if (binary["cache_dir"] is string cd && !string.IsNullOrEmpty(cd))
|
||||
Console.WriteLine($"Cache: {cd}");
|
||||
if (binary["override"] is string ov && !string.IsNullOrEmpty(ov))
|
||||
Console.WriteLine($"Override: {ov} (CLOAKBROWSER_BINARY_PATH)");
|
||||
}
|
||||
|
||||
var launch = (Dictionary<string, object?>)diag["launch"]!;
|
||||
if (launch["tested"] is not true)
|
||||
{
|
||||
Console.WriteLine($"Launch: {launch["reason"]}");
|
||||
}
|
||||
else if (launch["ok"] is true)
|
||||
{
|
||||
Console.WriteLine($"Launch: ✓ {launch["version"]}");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine($"Launch: ✗ failed — {launch["error"]}");
|
||||
var libs = launch.GetValueOrDefault("missing_libs") as List<string> ?? new();
|
||||
foreach (var lib in libs)
|
||||
Console.WriteLine($" missing: {lib}");
|
||||
if (libs.Count > 0)
|
||||
Console.WriteLine(" → install the missing system libraries (e.g. apt-get install)");
|
||||
}
|
||||
|
||||
if (diag.TryGetValue("fonts", out var fontsObj) && fontsObj is Dictionary<string, object?> fonts)
|
||||
{
|
||||
if (fonts["windows"] is int[] win)
|
||||
{
|
||||
int n = win[0], total = win[1];
|
||||
string verdict = n == total ? "ok" : n == 0 ? "missing" : "partial";
|
||||
Console.WriteLine($"Win fonts: {verdict} ({n}/{total})");
|
||||
if (n < total)
|
||||
Console.WriteLine(" → incomplete Windows font set; copy real Windows fonts (Segoe UI, Calibri, Consolas)");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine("Win fonts: unknown (fc-list unavailable)");
|
||||
}
|
||||
// Office is informational only — no Office pack is a normal Windows
|
||||
// persona (~53% of real machines have none), so no install nudge.
|
||||
if (fonts.TryGetValue("office", out var officeObj) && officeObj is int[] office)
|
||||
{
|
||||
int n = office[0], total = office[1];
|
||||
string verdict = n == total ? "ok" : n == 0 ? "absent" : "partial";
|
||||
Console.WriteLine($"Office fonts: {verdict} ({n}/{total})");
|
||||
}
|
||||
}
|
||||
|
||||
var lic = (Dictionary<string, object?>)diag["license"]!;
|
||||
string tier = (string)lic["tier"]!;
|
||||
if (tier == "free")
|
||||
{
|
||||
Console.WriteLine("License: Free");
|
||||
Console.WriteLine($" {UpgradeHint}");
|
||||
}
|
||||
else if (lic.ContainsKey("error"))
|
||||
{
|
||||
Console.WriteLine($"License: {tier} ({lic["error"]})");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine($"License: {tier}");
|
||||
}
|
||||
|
||||
var geoip = (Dictionary<string, object?>)diag["geoip"]!;
|
||||
Console.WriteLine($"GeoIP DB: {(geoip["db_present"] is true ? "present" : "not downloaded (optional)")}");
|
||||
|
||||
if (diag.TryGetValue("modules", out var modulesObj) && modulesObj is Dictionary<string, object?> modules)
|
||||
{
|
||||
Console.WriteLine("Modules:");
|
||||
foreach (var kv in modules)
|
||||
Console.WriteLine($" {kv.Key}: {(kv.Value is true ? "ok" : "missing")}");
|
||||
}
|
||||
}
|
||||
|
||||
static async Task CmdUpdate()
|
||||
{
|
||||
CloakLog.Info("Checking for updates...");
|
||||
|
||||
// A valid Pro license updates the Pro binary; everyone else updates free.
|
||||
// Mirrors Diagnostics.ResolveLicense: a custom download URL disables Pro routing.
|
||||
string? key = License.ResolveLicenseKey();
|
||||
if (!string.IsNullOrEmpty(Environment.GetEnvironmentVariable("CLOAKBROWSER_DOWNLOAD_URL"))) key = null;
|
||||
bool entitledPro = false;
|
||||
if (!string.IsNullOrEmpty(key))
|
||||
{
|
||||
try { entitledPro = License.ValidateLicense(key!)?.Valid == true; }
|
||||
catch { entitledPro = false; }
|
||||
}
|
||||
|
||||
string? newVersion;
|
||||
string label;
|
||||
if (entitledPro)
|
||||
{
|
||||
newVersion = await Download.CheckForProUpdateAsync(key!).ConfigureAwait(false);
|
||||
label = "Pro Chromium";
|
||||
}
|
||||
else
|
||||
{
|
||||
newVersion = await Download.CheckForUpdateAsync().ConfigureAwait(false);
|
||||
label = "Chromium";
|
||||
}
|
||||
Console.WriteLine(newVersion != null
|
||||
? $"Updated to {label} {newVersion}"
|
||||
: "Already up to date.");
|
||||
}
|
||||
|
||||
static void CmdClearCache()
|
||||
{
|
||||
if (!Directory.Exists(Config.GetCacheDir()))
|
||||
{
|
||||
Console.WriteLine("No cache to clear.");
|
||||
return;
|
||||
}
|
||||
Download.ClearCache();
|
||||
Console.WriteLine("Cache cleared.");
|
||||
}
|
||||
|
||||
static void PrintHelp()
|
||||
{
|
||||
Console.WriteLine("usage: cloakbrowser <command>");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("Manage the CloakBrowser stealth Chromium binary.");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("commands:");
|
||||
Console.WriteLine(" install Download the Chromium binary");
|
||||
Console.WriteLine(" info Environment + binary diagnostics (--quick, --json)");
|
||||
Console.WriteLine(" doctor Alias for info");
|
||||
Console.WriteLine(" update Check for and download a newer binary");
|
||||
Console.WriteLine(" clear-cache Remove all cached binaries");
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<!-- Source generators must target netstandard2.0 to load in the Roslyn host. -->
|
||||
<TargetFramework>netstandard2.0</TargetFramework>
|
||||
<ImplicitUsings>disable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<IsRoslynComponent>true</IsRoslynComponent>
|
||||
<EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
|
||||
<IncludeBuildOutput>false</IncludeBuildOutput>
|
||||
<SuppressDependenciesWhenPacking>true</SuppressDependenciesWhenPacking>
|
||||
<AssemblyName>CloakBrowser.Generators</AssemblyName>
|
||||
<RootNamespace>CloakBrowser.Generators</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.8.0" PrivateAssets="all" />
|
||||
<PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="3.3.4" PrivateAssets="all" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,424 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using Microsoft.CodeAnalysis;
|
||||
using Microsoft.CodeAnalysis.CSharp.Syntax;
|
||||
using Microsoft.CodeAnalysis.Text;
|
||||
|
||||
namespace CloakBrowser.Generators;
|
||||
|
||||
/// <summary>
|
||||
/// Roslyn source generator that auto-implements the boilerplate "delegate everything
|
||||
/// to the wrapped object" members for a decorator class.
|
||||
///
|
||||
/// Usage: annotate a <c>partial class</c> with
|
||||
/// <c>[GenerateInterfaceDelegation(typeof(IPage))]</c> and provide a field/property
|
||||
/// named <c>_inner</c> (or the name passed to the attribute) of the interface type.
|
||||
/// The generator emits delegating implementations for every interface member the
|
||||
/// class does NOT already declare itself. Members you declare manually (the
|
||||
/// "intercepted" / humanized ones) are left untouched.
|
||||
///
|
||||
/// This lets us hand-write only the handful of methods we want to humanize while the
|
||||
/// remaining ~hundreds of interface members are forwarded verbatim with full static
|
||||
/// typing, correct async signatures, optional parameters, and no reflection.
|
||||
/// </summary>
|
||||
[Generator(Microsoft.CodeAnalysis.LanguageNames.CSharp)]
|
||||
public sealed class InterfaceDelegationGenerator : IIncrementalGenerator
|
||||
{
|
||||
private const string AttributeNamespace = "CloakBrowser.Wrappers";
|
||||
private const string AttributeName = "GenerateInterfaceDelegationAttribute";
|
||||
private const string AttributeFullName = AttributeNamespace + "." + AttributeName;
|
||||
|
||||
private const string AttributeSource = @"// <auto-generated/>
|
||||
#nullable enable
|
||||
namespace CloakBrowser.Wrappers
|
||||
{
|
||||
/// <summary>
|
||||
/// Marks a partial decorator class for automatic generation of delegating members
|
||||
/// for the given interface. The class must expose a backing member (default name
|
||||
/// <c>_inner</c>) of the interface type that calls are forwarded to.
|
||||
/// </summary>
|
||||
[System.AttributeUsage(System.AttributeTargets.Class, AllowMultiple = true, Inherited = false)]
|
||||
internal sealed class GenerateInterfaceDelegationAttribute : System.Attribute
|
||||
{
|
||||
public GenerateInterfaceDelegationAttribute(System.Type interfaceType, string innerMemberName = ""_inner"")
|
||||
{
|
||||
InterfaceType = interfaceType;
|
||||
InnerMemberName = innerMemberName;
|
||||
}
|
||||
|
||||
public System.Type InterfaceType { get; }
|
||||
public string InnerMemberName { get; }
|
||||
}
|
||||
}
|
||||
";
|
||||
|
||||
public void Initialize(IncrementalGeneratorInitializationContext context)
|
||||
{
|
||||
// Emit the marker attribute into the consuming compilation.
|
||||
context.RegisterPostInitializationOutput(ctx =>
|
||||
ctx.AddSource("GenerateInterfaceDelegationAttribute.g.cs",
|
||||
SourceText.From(AttributeSource, Encoding.UTF8)));
|
||||
|
||||
var candidates = context.SyntaxProvider
|
||||
.CreateSyntaxProvider(
|
||||
predicate: static (node, _) =>
|
||||
node is ClassDeclarationSyntax c &&
|
||||
c.AttributeLists.Count > 0 &&
|
||||
c.Modifiers.Any(m => m.IsKind(Microsoft.CodeAnalysis.CSharp.SyntaxKind.PartialKeyword)),
|
||||
transform: static (ctx, _) => (INamedTypeSymbol?)ctx.SemanticModel.GetDeclaredSymbol(ctx.Node))
|
||||
.Where(static s => s is not null)
|
||||
.Select(static (s, _) => s!);
|
||||
|
||||
var compilationAndClasses = context.CompilationProvider.Combine(candidates.Collect());
|
||||
|
||||
context.RegisterSourceOutput(compilationAndClasses, static (spc, source) =>
|
||||
Execute(spc, source.Left, source.Right));
|
||||
}
|
||||
|
||||
private static void Execute(SourceProductionContext spc, Compilation compilation,
|
||||
System.Collections.Immutable.ImmutableArray<INamedTypeSymbol> classes)
|
||||
{
|
||||
var attrSymbol = compilation.GetTypeByMetadataName(AttributeFullName);
|
||||
if (attrSymbol is null)
|
||||
return;
|
||||
|
||||
var seen = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var cls in classes.Distinct(SymbolEqualityComparer.Default).Cast<INamedTypeSymbol>())
|
||||
{
|
||||
var attrs = cls.GetAttributes()
|
||||
.Where(a => SymbolEqualityComparer.Default.Equals(a.AttributeClass, attrSymbol))
|
||||
.ToList();
|
||||
if (attrs.Count == 0)
|
||||
continue;
|
||||
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("// <auto-generated/>");
|
||||
sb.AppendLine("#nullable enable");
|
||||
sb.AppendLine("#pragma warning disable CS0108 // member hides inherited member");
|
||||
sb.AppendLine("#pragma warning disable CS0612 // member is obsolete (delegation is intentional)");
|
||||
sb.AppendLine("#pragma warning disable CS0618 // member is obsolete (delegation is intentional)");
|
||||
sb.AppendLine();
|
||||
|
||||
string? ns = cls.ContainingNamespace.IsGlobalNamespace
|
||||
? null
|
||||
: cls.ContainingNamespace.ToDisplayString();
|
||||
if (ns is not null)
|
||||
{
|
||||
sb.Append("namespace ").Append(ns).AppendLine();
|
||||
sb.AppendLine("{");
|
||||
}
|
||||
|
||||
sb.Append(" partial class ").Append(cls.Name).AppendLine();
|
||||
sb.AppendLine(" {");
|
||||
|
||||
// Collect members the class already declares (by signature) so we don't
|
||||
// re-emit (and conflict with) hand-written intercepted methods.
|
||||
var declared = BuildDeclaredSignatureSet(cls);
|
||||
|
||||
foreach (var attr in attrs)
|
||||
{
|
||||
if (attr.ConstructorArguments.Length < 1)
|
||||
continue;
|
||||
if (attr.ConstructorArguments[0].Value is not INamedTypeSymbol iface)
|
||||
continue;
|
||||
string innerName = attr.ConstructorArguments.Length >= 2 &&
|
||||
attr.ConstructorArguments[1].Value is string s
|
||||
? s
|
||||
: "_inner";
|
||||
|
||||
EmitForInterface(sb, iface, innerName, declared);
|
||||
}
|
||||
|
||||
sb.AppendLine(" }");
|
||||
if (ns is not null)
|
||||
sb.AppendLine("}");
|
||||
|
||||
string hint = (ns is null ? "" : ns + ".") + cls.Name + ".Delegation.g.cs";
|
||||
// De-dup hint names across partials.
|
||||
if (!seen.Add(hint))
|
||||
hint = hint + "." + Guid.NewGuid().ToString("N").Substring(0, 6);
|
||||
spc.AddSource(hint, SourceText.From(sb.ToString(), Encoding.UTF8));
|
||||
}
|
||||
}
|
||||
|
||||
private static HashSet<string> BuildDeclaredSignatureSet(INamedTypeSymbol cls)
|
||||
{
|
||||
var set = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var m in cls.GetMembers())
|
||||
{
|
||||
switch (m)
|
||||
{
|
||||
case IMethodSymbol method when method.MethodKind == MethodKind.Ordinary:
|
||||
set.Add(MethodSignature(method));
|
||||
break;
|
||||
case IPropertySymbol prop when prop.IsIndexer:
|
||||
set.Add(IndexerSignature(prop));
|
||||
break;
|
||||
case IPropertySymbol prop:
|
||||
set.Add("prop:" + prop.Name);
|
||||
break;
|
||||
case IEventSymbol evt:
|
||||
set.Add("event:" + evt.Name);
|
||||
break;
|
||||
}
|
||||
}
|
||||
return set;
|
||||
}
|
||||
|
||||
private static void EmitForInterface(StringBuilder sb, INamedTypeSymbol iface,
|
||||
string innerName, HashSet<string> declared)
|
||||
{
|
||||
// Walk the interface and every interface it inherits.
|
||||
var allIfaces = new List<INamedTypeSymbol> { iface };
|
||||
allIfaces.AddRange(iface.AllInterfaces);
|
||||
|
||||
var emitted = new HashSet<string>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var i in allIfaces)
|
||||
{
|
||||
foreach (var member in i.GetMembers())
|
||||
{
|
||||
switch (member)
|
||||
{
|
||||
case IMethodSymbol method when method.MethodKind == MethodKind.Ordinary:
|
||||
{
|
||||
string sig = MethodSignature(method);
|
||||
if (declared.Contains(sig) || !emitted.Add(sig))
|
||||
continue;
|
||||
EmitMethod(sb, method, innerName);
|
||||
break;
|
||||
}
|
||||
case IPropertySymbol prop when prop.IsIndexer:
|
||||
{
|
||||
string sig = IndexerSignature(prop);
|
||||
if (declared.Contains(sig) || !emitted.Add(sig))
|
||||
continue;
|
||||
EmitIndexer(sb, prop, innerName);
|
||||
break;
|
||||
}
|
||||
case IPropertySymbol prop:
|
||||
{
|
||||
string key = "prop:" + prop.Name;
|
||||
if (declared.Contains(key) || !emitted.Add(key))
|
||||
continue;
|
||||
EmitProperty(sb, prop, innerName);
|
||||
break;
|
||||
}
|
||||
case IEventSymbol evt:
|
||||
{
|
||||
string key = "event:" + evt.Name;
|
||||
if (declared.Contains(key) || !emitted.Add(key))
|
||||
continue;
|
||||
EmitEvent(sb, evt, innerName);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Emitters
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
// Marker on every generated (delegating) member, so consumers/tests can tell a
|
||||
// generator-delegated member apart from a hand-written (intercepted) one.
|
||||
private const string GenAttr =
|
||||
" [global::System.CodeDom.Compiler.GeneratedCode(\"CloakBrowser.Generators\", \"1.0\")]";
|
||||
|
||||
private static void EmitMethod(StringBuilder sb, IMethodSymbol method, string innerName)
|
||||
{
|
||||
string ret = method.ReturnType.ToDisplayString(FullyQualified);
|
||||
string retKeyword = method.ReturnsVoid ? "void" : ret;
|
||||
|
||||
var pars = new List<string>();
|
||||
var args = new List<string>();
|
||||
foreach (var p in method.Parameters)
|
||||
BuildParam(p, pars, args);
|
||||
|
||||
string typeParams = method.IsGenericMethod
|
||||
? "<" + string.Join(", ", method.TypeParameters.Select(t => t.Name)) + ">"
|
||||
: "";
|
||||
string constraints = BuildConstraints(method);
|
||||
|
||||
sb.AppendLine(GenAttr);
|
||||
sb.Append(" public ")
|
||||
.Append(retKeyword).Append(' ')
|
||||
.Append(method.Name).Append(typeParams).Append('(')
|
||||
.Append(string.Join(", ", pars)).Append(')')
|
||||
.Append(constraints)
|
||||
.Append(" => ")
|
||||
.Append(innerName).Append('.').Append(method.Name).Append(typeParams)
|
||||
.Append('(').Append(string.Join(", ", args)).Append(");")
|
||||
.AppendLine();
|
||||
}
|
||||
|
||||
private static void EmitProperty(StringBuilder sb, IPropertySymbol prop, string innerName)
|
||||
{
|
||||
string type = prop.Type.ToDisplayString(FullyQualified);
|
||||
sb.AppendLine(GenAttr);
|
||||
sb.Append(" public ").Append(type).Append(' ').Append(prop.Name).Append(" { ");
|
||||
if (prop.GetMethod is not null)
|
||||
sb.Append("get => ").Append(innerName).Append('.').Append(prop.Name).Append("; ");
|
||||
if (prop.SetMethod is not null)
|
||||
sb.Append("set => ").Append(innerName).Append('.').Append(prop.Name).Append(" = value; ");
|
||||
sb.AppendLine("}");
|
||||
}
|
||||
|
||||
private static void EmitIndexer(StringBuilder sb, IPropertySymbol prop, string innerName)
|
||||
{
|
||||
string type = prop.Type.ToDisplayString(FullyQualified);
|
||||
var pars = new List<string>();
|
||||
var args = new List<string>();
|
||||
foreach (var p in prop.Parameters)
|
||||
BuildParam(p, pars, args);
|
||||
|
||||
sb.AppendLine(GenAttr);
|
||||
sb.Append(" public ").Append(type).Append(" this[")
|
||||
.Append(string.Join(", ", pars)).Append("] { ");
|
||||
if (prop.GetMethod is not null)
|
||||
sb.Append("get => ").Append(innerName).Append('[').Append(string.Join(", ", args)).Append("]; ");
|
||||
if (prop.SetMethod is not null)
|
||||
sb.Append("set => ").Append(innerName).Append('[').Append(string.Join(", ", args)).Append("] = value; ");
|
||||
sb.AppendLine("}");
|
||||
}
|
||||
|
||||
private static void EmitEvent(StringBuilder sb, IEventSymbol evt, string innerName)
|
||||
{
|
||||
string type = evt.Type.ToDisplayString(FullyQualified);
|
||||
sb.AppendLine(GenAttr);
|
||||
sb.Append(" public event ").Append(type).Append(' ').Append(evt.Name).AppendLine();
|
||||
sb.AppendLine(" {");
|
||||
sb.Append(" add => ").Append(innerName).Append('.').Append(evt.Name).AppendLine(" += value;");
|
||||
sb.Append(" remove => ").Append(innerName).Append('.').Append(evt.Name).AppendLine(" -= value;");
|
||||
sb.AppendLine(" }");
|
||||
}
|
||||
|
||||
private static void BuildParam(IParameterSymbol p, List<string> pars, List<string> args)
|
||||
{
|
||||
string refKind = p.RefKind switch
|
||||
{
|
||||
RefKind.Ref => "ref ",
|
||||
RefKind.Out => "out ",
|
||||
RefKind.In => "in ",
|
||||
_ => "",
|
||||
};
|
||||
string ptype = p.Type.ToDisplayString(FullyQualified);
|
||||
// Special types (object, string, ...) sometimes drop their nullable annotation
|
||||
// in the display string; re-attach it so `object? arg = null` overrides match
|
||||
// and we don't trip CS8625.
|
||||
if (p.NullableAnnotation == NullableAnnotation.Annotated &&
|
||||
!p.Type.IsValueType && !ptype.EndsWith("?"))
|
||||
{
|
||||
ptype += "?";
|
||||
}
|
||||
string pname = SafeName(p.Name);
|
||||
|
||||
string paramsKw = p.IsParams ? "params " : "";
|
||||
string decl = paramsKw + refKind + ptype + " " + pname;
|
||||
|
||||
if (p.HasExplicitDefaultValue)
|
||||
decl += " = " + FormatDefault(p);
|
||||
|
||||
pars.Add(decl);
|
||||
args.Add(refKind + pname);
|
||||
}
|
||||
|
||||
private static string FormatDefault(IParameterSymbol p)
|
||||
{
|
||||
var v = p.ExplicitDefaultValue;
|
||||
if (v is null)
|
||||
{
|
||||
// For a null default, value types use the literal `default`; reference
|
||||
// types use `default!` so we never trip CS8625 regardless of whether the
|
||||
// interface was compiled in a nullable-aware context.
|
||||
return p.Type.IsValueType ? "default" : "default!";
|
||||
}
|
||||
return v switch
|
||||
{
|
||||
bool b => b ? "true" : "false",
|
||||
string s => "\"" + s.Replace("\\", "\\\\").Replace("\"", "\\\"") + "\"",
|
||||
char c => "'" + c + "'",
|
||||
float f => f.ToString(System.Globalization.CultureInfo.InvariantCulture) + "f",
|
||||
double d => d.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||||
_ when p.Type.TypeKind == TypeKind.Enum =>
|
||||
"(" + p.Type.ToDisplayString(FullyQualified) + ")" + v,
|
||||
_ => System.Convert.ToString(v, System.Globalization.CultureInfo.InvariantCulture) ?? "default",
|
||||
};
|
||||
}
|
||||
|
||||
private static string BuildConstraints(IMethodSymbol method)
|
||||
{
|
||||
if (!method.IsGenericMethod)
|
||||
return "";
|
||||
var sb = new StringBuilder();
|
||||
foreach (var tp in method.TypeParameters)
|
||||
{
|
||||
var parts = new List<string>();
|
||||
if (tp.HasReferenceTypeConstraint) parts.Add("class");
|
||||
if (tp.HasValueTypeConstraint) parts.Add("struct");
|
||||
if (tp.HasNotNullConstraint) parts.Add("notnull");
|
||||
if (tp.HasUnmanagedTypeConstraint) parts.Add("unmanaged");
|
||||
foreach (var ct in tp.ConstraintTypes)
|
||||
parts.Add(ct.ToDisplayString(FullyQualified));
|
||||
if (tp.HasConstructorConstraint) parts.Add("new()");
|
||||
if (parts.Count > 0)
|
||||
sb.Append(" where ").Append(tp.Name).Append(" : ").Append(string.Join(", ", parts));
|
||||
}
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Signature helpers (must match how we detect already-declared members)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private static string MethodSignature(IMethodSymbol m)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.Append(m.Name);
|
||||
sb.Append('`').Append(m.TypeParameters.Length);
|
||||
sb.Append('(');
|
||||
sb.Append(string.Join(",", m.Parameters.Select(p =>
|
||||
(p.RefKind == RefKind.None ? "" : p.RefKind.ToString().ToLowerInvariant() + " ")
|
||||
+ p.Type.ToDisplayString(SignatureFormat))));
|
||||
sb.Append(')');
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string IndexerSignature(IPropertySymbol p)
|
||||
{
|
||||
return "this[" + string.Join(",", p.Parameters.Select(x =>
|
||||
x.Type.ToDisplayString(SignatureFormat))) + "]";
|
||||
}
|
||||
|
||||
private static string SafeName(string name)
|
||||
{
|
||||
return SyntaxFactsKeywords.Contains(name) ? "@" + name : name;
|
||||
}
|
||||
|
||||
private static readonly HashSet<string> SyntaxFactsKeywords = new(StringComparer.Ordinal)
|
||||
{
|
||||
"abstract","as","base","bool","break","byte","case","catch","char","checked","class","const",
|
||||
"continue","decimal","default","delegate","do","double","else","enum","event","explicit","extern",
|
||||
"false","finally","fixed","float","for","foreach","goto","if","implicit","in","int","interface",
|
||||
"internal","is","lock","long","namespace","new","null","object","operator","out","override","params",
|
||||
"private","protected","public","readonly","ref","return","sbyte","sealed","short","sizeof","stackalloc",
|
||||
"static","string","struct","switch","this","throw","true","try","typeof","uint","ulong","unchecked",
|
||||
"unsafe","ushort","using","virtual","void","volatile","while",
|
||||
};
|
||||
|
||||
private static readonly SymbolDisplayFormat FullyQualified =
|
||||
SymbolDisplayFormat.FullyQualifiedFormat
|
||||
.WithMiscellaneousOptions(
|
||||
SymbolDisplayMiscellaneousOptions.UseSpecialTypes |
|
||||
SymbolDisplayMiscellaneousOptions.IncludeNullableReferenceTypeModifier |
|
||||
SymbolDisplayMiscellaneousOptions.EscapeKeywordIdentifiers);
|
||||
|
||||
// For signature comparison we want stable type names without nullable annotations,
|
||||
// so the manual override matches the interface member regardless of NRT modifiers.
|
||||
private static readonly SymbolDisplayFormat SignatureFormat =
|
||||
SymbolDisplayFormat.FullyQualifiedFormat
|
||||
.WithMiscellaneousOptions(SymbolDisplayMiscellaneousOptions.UseSpecialTypes);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<AssemblyName>CloakBrowser</AssemblyName>
|
||||
<RootNamespace>CloakBrowser</RootNamespace>
|
||||
|
||||
<!-- NuGet packaging metadata -->
|
||||
<PackageId>CloakBrowser</PackageId>
|
||||
<Version>0.4.10</Version>
|
||||
<Authors>CloakHQ</Authors>
|
||||
<Description>Stealth Chromium that passes every bot detection test. Drop-in Playwright (.NET) replacement with source-level fingerprint patches.</Description>
|
||||
<PackageLicenseExpression>MIT</PackageLicenseExpression>
|
||||
<PackageProjectUrl>https://github.com/CloakHQ/CloakBrowser</PackageProjectUrl>
|
||||
<RepositoryUrl>https://github.com/CloakHQ/CloakBrowser</RepositoryUrl>
|
||||
<PackageTags>stealth;browser;chromium;playwright;scraping;web-scraping;anti-detect;antidetect;undetected;bot-detection;fingerprint;recaptcha;cloudflare;turnstile;datadome;captcha;headless;automation</PackageTags>
|
||||
<PackageReadmeFile>README.md</PackageReadmeFile>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<NoWarn>$(NoWarn);CS1591</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- dotnet/README.md feeds the NuGet package page. -->
|
||||
<None Include="..\..\README.md" Pack="true" PackagePath="\" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Playwright" Version="1.49.0" />
|
||||
<PackageReference Include="MaxMind.GeoIP2" Version="5.2.0" />
|
||||
<!-- Ed25519 signature verification for downloaded binaries (.NET 8 has no
|
||||
built-in Ed25519). Mirrors Python's `cryptography` / JS's node:crypto. -->
|
||||
<PackageReference Include="BouncyCastle.Cryptography" Version="2.4.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Roslyn source generator that emits the delegating wrapper members. -->
|
||||
<ProjectReference Include="..\CloakBrowser.Generators\CloakBrowser.Generators.csproj"
|
||||
OutputItemType="Analyzer"
|
||||
ReferenceOutputAssembly="false" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="CloakBrowser.Tests" />
|
||||
<InternalsVisibleTo Include="cloakbrowser-cli" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,553 @@
|
||||
using System.Diagnostics;
|
||||
using System.Runtime.InteropServices;
|
||||
using CloakBrowser.Human;
|
||||
using Microsoft.Playwright;
|
||||
|
||||
namespace CloakBrowser;
|
||||
|
||||
/// <summary>
|
||||
/// Core browser launch functions for CloakBrowser - thin wrappers around Playwright
|
||||
/// that use the patched stealth Chromium binary instead of stock Chromium.
|
||||
///
|
||||
/// Direct port of Python <c>cloakbrowser/browser.py</c>. Because .NET Playwright is
|
||||
/// async-only, only the async launch surface is provided.
|
||||
/// </summary>
|
||||
public static class CloakLauncher
|
||||
{
|
||||
// -----------------------------------------------------------------------
|
||||
// launch - returns a Browser handle
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <summary>Launch a stealth Chromium browser. Returns a <see cref="CloakBrowserHandle"/>.</summary>
|
||||
public static async Task<CloakBrowserHandle> LaunchAsync(LaunchOptions? options = null)
|
||||
{
|
||||
options ??= new LaunchOptions();
|
||||
|
||||
string binaryPath = await Download.EnsureBinaryAsync(options.LicenseKey, options.BrowserVersion).ConfigureAwait(false);
|
||||
var (timezone, locale, exitIp) = await MaybeResolveGeoIpAsync(
|
||||
options.GeoIp, options.Proxy, options.Timezone, options.Locale).ConfigureAwait(false);
|
||||
var proxyResolution = ProxyResolver.Resolve(options.Proxy, options.BrowserVersion, options.LicenseKey);
|
||||
var args = await ResolveWebRtcArgsAsync(options.Args, options.Proxy).ConfigureAwait(false);
|
||||
args = MaybeAppendWebRtcExitIp(args, exitIp);
|
||||
|
||||
var combined = new List<string>(args ?? new List<string>());
|
||||
combined.AddRange(proxyResolution.ExtraArgs);
|
||||
var chromeArgs = BuildArgs(options.StealthArgs, combined, timezone, locale, options.Headless, options.ExtensionPaths,
|
||||
startMaximized: Config.BinarySupportsMaximizedWindow(options.LicenseKey, options.BrowserVersion)
|
||||
&& !options.SuppressMaximize);
|
||||
MaybeWarnWindowsFonts(chromeArgs);
|
||||
|
||||
CloakLog.Debug($"Launching stealth Chromium (headless={options.Headless}, args={chromeArgs.Count})");
|
||||
|
||||
var playwright = await Playwright.CreateAsync().ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
var browser = await playwright.Chromium.LaunchAsync(new BrowserTypeLaunchOptions
|
||||
{
|
||||
ExecutablePath = binaryPath,
|
||||
Headless = options.Headless,
|
||||
Args = chromeArgs,
|
||||
IgnoreDefaultArgs = Config.IgnoreDefaultArgs,
|
||||
Proxy = proxyResolution.PlaywrightProxy,
|
||||
Env = License.BuildLaunchEnv(options.LicenseKey),
|
||||
}).ConfigureAwait(false);
|
||||
|
||||
var humanCfg = options.Humanize
|
||||
? HumanConfigFactory.Resolve(options.HumanPreset, options.HumanConfig)
|
||||
: null;
|
||||
// Pass headless so headed handles default new pages/contexts to NoViewport
|
||||
// (track the real window - see CloakBrowserHandle.ApplyDefaultNoViewport).
|
||||
// headlessNoViewport extends that default to headless on newer binaries.
|
||||
bool headlessNoViewport =
|
||||
Config.BinarySupportsHeadlessNoViewport(options.LicenseKey, options.BrowserVersion);
|
||||
return new CloakBrowserHandle(
|
||||
playwright, browser, options.Humanize, humanCfg, options.Headless, headlessNoViewport);
|
||||
}
|
||||
catch
|
||||
{
|
||||
playwright.Dispose();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// launch_context - returns a Context handle (browser owned)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <summary>Launch a stealth browser and return a <see cref="CloakContextHandle"/> with common options pre-set.</summary>
|
||||
public static async Task<CloakContextHandle> LaunchContextAsync(LaunchContextOptions? options = null)
|
||||
{
|
||||
options ??= new LaunchContextOptions();
|
||||
|
||||
// Resolve geoip before launch so resolved values flow to binary flags.
|
||||
var (timezone, locale, exitIp) = await MaybeResolveGeoIpAsync(
|
||||
options.GeoIp, options.Proxy, options.Timezone, options.Locale).ConfigureAwait(false);
|
||||
var args = options.Args;
|
||||
args = MaybeAppendWebRtcExitIp(args, exitIp);
|
||||
|
||||
var browserHandle = await LaunchAsync(new LaunchOptions
|
||||
{
|
||||
Headless = options.Headless,
|
||||
Proxy = options.Proxy,
|
||||
Args = args,
|
||||
StealthArgs = options.StealthArgs,
|
||||
Timezone = timezone,
|
||||
Locale = locale,
|
||||
ExtensionPaths = options.ExtensionPaths,
|
||||
LicenseKey = options.LicenseKey,
|
||||
BrowserVersion = options.BrowserVersion,
|
||||
// geoip already resolved above; don't re-resolve.
|
||||
GeoIp = false,
|
||||
// Caller chose a viewport geometry → don't also auto-maximize the
|
||||
// window (mirrors the persistent-context path + Python/JS).
|
||||
SuppressMaximize = options.Viewport != null || options.NoViewport,
|
||||
}).ConfigureAwait(false);
|
||||
|
||||
try
|
||||
{
|
||||
var ctxOptions = BuildContextOptions(options);
|
||||
var context = await browserHandle.Browser.NewContextAsync(ctxOptions).ConfigureAwait(false);
|
||||
|
||||
var humanCfg = options.Humanize
|
||||
? HumanConfigFactory.Resolve(options.HumanPreset, options.HumanConfig)
|
||||
: null;
|
||||
|
||||
// The context handle owns the browser; reuse the same Playwright instance.
|
||||
return new CloakContextHandle(
|
||||
GetPlaywright(browserHandle), browserHandle.Browser, context, options.Humanize, humanCfg);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await browserHandle.CloseAsync().ConfigureAwait(false);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// launch_persistent_context - returns a Context handle (no separate browser)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <summary>Launch a stealth browser with a persistent profile; returns a <see cref="CloakContextHandle"/>.</summary>
|
||||
public static async Task<CloakContextHandle> LaunchPersistentContextAsync(
|
||||
string userDataDir, LaunchContextOptions? options = null)
|
||||
{
|
||||
options ??= new LaunchContextOptions();
|
||||
|
||||
string binaryPath = await Download.EnsureBinaryAsync(options.LicenseKey, options.BrowserVersion).ConfigureAwait(false);
|
||||
var (timezone, locale, exitIp) = await MaybeResolveGeoIpAsync(
|
||||
options.GeoIp, options.Proxy, options.Timezone, options.Locale).ConfigureAwait(false);
|
||||
var proxyResolution = ProxyResolver.Resolve(options.Proxy, options.BrowserVersion, options.LicenseKey);
|
||||
var args = await ResolveWebRtcArgsAsync(options.Args, options.Proxy).ConfigureAwait(false);
|
||||
args = MaybeAppendWebRtcExitIp(args, exitIp);
|
||||
|
||||
var combined = new List<string>(args ?? new List<string>());
|
||||
combined.AddRange(proxyResolution.ExtraArgs);
|
||||
var chromeArgs = BuildArgs(options.StealthArgs, combined, timezone, locale, options.Headless, options.ExtensionPaths,
|
||||
startMaximized: Config.BinarySupportsMaximizedWindow(options.LicenseKey, options.BrowserVersion)
|
||||
&& !options.NoViewport && options.Viewport == null);
|
||||
MaybeWarnWindowsFonts(chromeArgs);
|
||||
|
||||
CloakLog.Debug($"Launching persistent stealth Chromium (headless={options.Headless}, user_data_dir={userDataDir})");
|
||||
|
||||
// Seed the Widevine CDM hint (Linux-only; no-op elsewhere).
|
||||
Widevine.SeedWidevineHint(userDataDir, binaryPath);
|
||||
|
||||
var playwright = await Playwright.CreateAsync().ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
var ctxLaunchOptions = new BrowserTypeLaunchPersistentContextOptions
|
||||
{
|
||||
ExecutablePath = binaryPath,
|
||||
Headless = options.Headless,
|
||||
Args = chromeArgs,
|
||||
IgnoreDefaultArgs = Config.IgnoreDefaultArgs,
|
||||
Proxy = proxyResolution.PlaywrightProxy,
|
||||
Env = License.BuildLaunchEnv(options.LicenseKey),
|
||||
};
|
||||
ApplyContextEmulation(ctxLaunchOptions, options);
|
||||
|
||||
var context = await playwright.Chromium.LaunchPersistentContextAsync(
|
||||
userDataDir, ctxLaunchOptions).ConfigureAwait(false);
|
||||
|
||||
var humanCfg = options.Humanize
|
||||
? HumanConfigFactory.Resolve(options.HumanPreset, options.HumanConfig)
|
||||
: null;
|
||||
return new CloakContextHandle(playwright, null, context, options.Humanize, humanCfg);
|
||||
}
|
||||
catch
|
||||
{
|
||||
playwright.Dispose();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// GeoIP resolution
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Auto-fill timezone/locale from the egress IP when geoip is enabled. Returns
|
||||
/// (timezone, locale, exitIp). The exit IP is a free bonus used for WebRTC spoofing.
|
||||
/// With a proxy the egress IP is the proxy's exit IP; with no proxy it is the
|
||||
/// machine's own public IP, so geoip works proxy-free too.
|
||||
/// </summary>
|
||||
public static async Task<(string? Timezone, string? Locale, string? ExitIp)> MaybeResolveGeoIpAsync(
|
||||
bool geoip, object? proxy, string? timezone, string? locale)
|
||||
{
|
||||
if (!geoip)
|
||||
return (timezone, locale, null);
|
||||
|
||||
// null when no proxy -> echo services resolve the machine's own public IP.
|
||||
string? proxyUrl = proxy != null ? ProxyResolver.ExtractProxyUrl(proxy) : null;
|
||||
|
||||
// When both tz/locale are explicit, resolve the exit IP for WebRTC — but only
|
||||
// with a proxy. With no proxy the WebRTC IP would just be the real connection
|
||||
// IP the site already sees (a no-op), so skip the third-party echo call.
|
||||
if (timezone != null && locale != null)
|
||||
{
|
||||
string? exitIpOnly = proxyUrl != null
|
||||
? await GeoIp.ResolveProxyExitIpAsync(proxyUrl).ConfigureAwait(false)
|
||||
: null;
|
||||
return (timezone, locale, exitIpOnly);
|
||||
}
|
||||
|
||||
var (geoTz, geoLocale, exitIp) = await GeoIp.ResolveProxyGeoWithIpAsync(proxyUrl).ConfigureAwait(false);
|
||||
return (timezone ?? geoTz, locale ?? geoLocale, exitIp);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// WebRTC args
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <summary>Replace <c>--fingerprint-webrtc-ip=auto</c> with the resolved proxy exit IP.</summary>
|
||||
public static async Task<List<string>?> ResolveWebRtcArgsAsync(List<string>? args, object? proxy)
|
||||
{
|
||||
if (args == null || args.Count == 0)
|
||||
return args;
|
||||
int idx = args.FindIndex(a => a == "--fingerprint-webrtc-ip=auto");
|
||||
if (idx < 0)
|
||||
return args;
|
||||
|
||||
string? proxyUrl = ProxyResolver.ExtractProxyUrl(proxy);
|
||||
var result = new List<string>(args);
|
||||
if (string.IsNullOrEmpty(proxyUrl))
|
||||
{
|
||||
CloakLog.Warning("--fingerprint-webrtc-ip=auto requires a proxy; removing flag");
|
||||
result.RemoveAt(idx);
|
||||
return result;
|
||||
}
|
||||
|
||||
string? exitIp;
|
||||
try { exitIp = await GeoIp.ResolveProxyExitIpAsync(proxyUrl).ConfigureAwait(false); }
|
||||
catch (Exception)
|
||||
{
|
||||
CloakLog.Warning("Failed to resolve proxy exit IP for WebRTC spoofing; removing --fingerprint-webrtc-ip=auto");
|
||||
result.RemoveAt(idx);
|
||||
return result;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(exitIp))
|
||||
result[idx] = $"--fingerprint-webrtc-ip={exitIp}";
|
||||
else
|
||||
{
|
||||
CloakLog.Warning("Could not resolve proxy exit IP for WebRTC spoofing; removing --fingerprint-webrtc-ip=auto");
|
||||
result.RemoveAt(idx);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static List<string>? MaybeAppendWebRtcExitIp(List<string>? args, string? exitIp)
|
||||
{
|
||||
if (string.IsNullOrEmpty(exitIp))
|
||||
return args;
|
||||
bool alreadySet = args != null && args.Any(a => a.StartsWith("--fingerprint-webrtc-ip"));
|
||||
if (alreadySet)
|
||||
return args;
|
||||
var result = new List<string>(args ?? new List<string>())
|
||||
{
|
||||
$"--fingerprint-webrtc-ip={exitIp}",
|
||||
};
|
||||
return result;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// build_args
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Combine stealth args with user-provided args and locale/timezone flags.
|
||||
/// Deduplicates by flag key (everything before <c>=</c>).
|
||||
/// Priority: stealth defaults < user args < dedicated params (timezone/locale).
|
||||
/// </summary>
|
||||
public static List<string> BuildArgs(
|
||||
bool stealthArgs,
|
||||
List<string>? extraArgs,
|
||||
string? timezone = null,
|
||||
string? locale = null,
|
||||
bool headless = true,
|
||||
List<string>? extensionPaths = null,
|
||||
bool startMaximized = false)
|
||||
{
|
||||
// Preserve insertion order while deduping by key.
|
||||
var seen = new Dictionary<string, string>();
|
||||
var order = new List<string>();
|
||||
|
||||
void Set(string key, string value)
|
||||
{
|
||||
if (seen.ContainsKey(key))
|
||||
CloakLog.Debug($"Arg override: {seen[key]} -> {value}");
|
||||
else
|
||||
order.Add(key);
|
||||
seen[key] = value;
|
||||
}
|
||||
|
||||
if (stealthArgs)
|
||||
{
|
||||
foreach (var arg in Config.GetDefaultStealthArgs())
|
||||
Set(arg.Split('=', 2)[0], arg);
|
||||
}
|
||||
|
||||
// GPU blocklist bypass in headed mode (all platforms) or on Windows (all modes).
|
||||
if (!headless || RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
Set("--ignore-gpu-blocklist", "--ignore-gpu-blocklist");
|
||||
|
||||
if (extraArgs != null)
|
||||
{
|
||||
foreach (var arg in extraArgs)
|
||||
Set(arg.Split('=', 2)[0], arg);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(timezone))
|
||||
Set("--fingerprint-timezone", $"--fingerprint-timezone={timezone}");
|
||||
|
||||
if (!string.IsNullOrEmpty(locale))
|
||||
{
|
||||
Set("--lang", $"--lang={locale}");
|
||||
Set("--fingerprint-locale", $"--fingerprint-locale={locale}");
|
||||
}
|
||||
|
||||
if (extensionPaths != null && extensionPaths.Count > 0)
|
||||
{
|
||||
var absPaths = extensionPaths.Select(Path.GetFullPath);
|
||||
string extVal = string.Join(",", absPaths);
|
||||
Set("--load-extension", $"--load-extension={extVal}");
|
||||
Set("--disable-extensions-except", $"--disable-extensions-except={extVal}");
|
||||
}
|
||||
|
||||
// Open maximized (real Chrome overwhelmingly runs maximized) so the window
|
||||
// fills the spoofed screen. Skipped if the caller chose a window geometry.
|
||||
// Gated to binaries where this stays coherent (see BinarySupportsMaximizedWindow)
|
||||
// — below the gate it would make outerWidth < innerWidth.
|
||||
if (startMaximized
|
||||
&& !seen.ContainsKey("--start-maximized")
|
||||
&& !seen.ContainsKey("--window-size")
|
||||
&& !seen.ContainsKey("--window-position"))
|
||||
{
|
||||
Set("--start-maximized", "--start-maximized");
|
||||
}
|
||||
|
||||
return order.Select(k => seen[k]).ToList();
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Windows-font mismatch warning (Linux only)
|
||||
//
|
||||
// On Linux the binary spoofs the Windows platform by default, but fonts come
|
||||
// from the host OS. A font-less Linux box contradicts the Windows claim and
|
||||
// font-fingerprinting anti-bot systems flag the mismatch. Warn once per
|
||||
// environment. See docs/chrome40-fpjs-font-minimum-set-investigation.md.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
// Windows OS fonts — ship with Windows itself, so their absence on a
|
||||
// Windows-spoofing Linux host degrades results. The two monospace fonts
|
||||
// (Consolas + Courier New) are part of the recommended set so the generic
|
||||
// `monospace` family resolves to a Windows font. See issue #395.
|
||||
internal static readonly string[] WindowsFontTells =
|
||||
{
|
||||
"Segoe UI", "Segoe UI Light", "Calibri", "Marlett", "MS UI Gothic",
|
||||
"Franklin Gothic", "Consolas", "Courier New",
|
||||
};
|
||||
|
||||
// MS Office supplemental fonts, installed as one atomic block by every Office
|
||||
// install. Roughly half of real Windows machines have this pack and half do
|
||||
// not, so its absence is a perfectly normal Windows setup, NOT a problem —
|
||||
// reported as an informational signal only, never a warning.
|
||||
internal static readonly string[] OfficeFontTells =
|
||||
{
|
||||
"MT Extra", "Century", "Century Gothic", "MS Reference Specialty",
|
||||
"Wingdings 2", "Wingdings 3", "Book Antiqua", "Bookshelf Symbol 7",
|
||||
"Monotype Corsiva", "Bookman Old Style",
|
||||
};
|
||||
|
||||
internal static bool _fontWarningChecked;
|
||||
|
||||
/// <summary>
|
||||
/// Count how many tell-tale fonts are installed, via fc-list. Returns the
|
||||
/// number present (0..tells.Length), or null if it can't be determined
|
||||
/// (fc-list missing or errored). Callers must NOT treat null as zero — null
|
||||
/// means "unknown", 0 means "genuinely none installed".
|
||||
/// </summary>
|
||||
internal static int? CountFontsPresent(string[] tells)
|
||||
{
|
||||
string output;
|
||||
try
|
||||
{
|
||||
using var proc = new Process
|
||||
{
|
||||
StartInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = "fc-list",
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
},
|
||||
};
|
||||
if (!proc.Start()) return null;
|
||||
// Drain both streams concurrently so a full stderr buffer can't block
|
||||
// the stdout read, and bound the WHOLE probe with the 5s ceiling
|
||||
// (a synchronous ReadToEnd would run unbounded before WaitForExit).
|
||||
var stdoutTask = proc.StandardOutput.ReadToEndAsync();
|
||||
_ = proc.StandardError.ReadToEndAsync();
|
||||
if (!proc.WaitForExit(5000))
|
||||
{
|
||||
try { proc.Kill(); } catch { /* best-effort */ }
|
||||
return null;
|
||||
}
|
||||
// Process exited within budget; the read completes once stdout closes.
|
||||
// Bound the join too in case the stream lingers after exit.
|
||||
if (!stdoutTask.Wait(1000)) return null;
|
||||
output = stdoutTask.Result;
|
||||
if (proc.ExitCode != 0) return null;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
var listing = output.ToLowerInvariant();
|
||||
return tells.Count(f => listing.Contains(f.ToLowerInvariant()));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True if ALL Windows OS fonts are installed, false if any are missing, null
|
||||
/// if unknown. Strict: a partial set is treated as incomplete, since the font
|
||||
/// install is atomic and a missing font degrades the Windows persona.
|
||||
/// </summary>
|
||||
internal static bool? WindowsFontsPresent()
|
||||
{
|
||||
var n = CountFontsPresent(WindowsFontTells);
|
||||
return n is null ? null : n == WindowsFontTells.Length;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Warn once when spoofing Windows on a Linux host without the full Windows
|
||||
/// font set. Best-effort and silent on error — never throws. Gated by an
|
||||
/// in-process flag plus a cache-dir marker so it fires at most once per
|
||||
/// environment. Suppress entirely with CLOAKBROWSER_SUPPRESS_FONT_WARNING.
|
||||
/// </summary>
|
||||
internal static void MaybeWarnWindowsFonts(IReadOnlyList<string> chromeArgs)
|
||||
{
|
||||
if (_fontWarningChecked) return;
|
||||
_fontWarningChecked = true;
|
||||
try
|
||||
{
|
||||
if (!string.IsNullOrEmpty(Environment.GetEnvironmentVariable("CLOAKBROWSER_SUPPRESS_FONT_WARNING"))) return;
|
||||
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) return;
|
||||
// Effective platform = the last --fingerprint-platform in the final argv
|
||||
// (BuildArgs dedups, so at most one). null => no Windows spoof.
|
||||
string? effectivePlatform = null;
|
||||
const string prefix = "--fingerprint-platform=";
|
||||
foreach (var arg in chromeArgs)
|
||||
{
|
||||
if (arg.StartsWith(prefix, StringComparison.Ordinal))
|
||||
effectivePlatform = arg.Substring(prefix.Length).Trim().ToLowerInvariant();
|
||||
}
|
||||
if (effectivePlatform != "windows") return;
|
||||
var marker = Path.Combine(Config.GetCacheDir(), ".font_warning_shown");
|
||||
if (File.Exists(marker)) return;
|
||||
var present = WindowsFontsPresent();
|
||||
if (present != false) return; // true (full set) or null (undeterminable)
|
||||
CloakLog.Warning(
|
||||
"[cloakbrowser] Incomplete Windows font set — installing the full " +
|
||||
"set is strongly advised for best results when spoofing Windows on " +
|
||||
"Linux. https://github.com/CloakHQ/cloakbrowser#font-setup-on-linux " +
|
||||
"(silence: CLOAKBROWSER_SUPPRESS_FONT_WARNING=1)");
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(marker)!);
|
||||
File.WriteAllText(marker, "");
|
||||
}
|
||||
catch (IOException) { /* non-fatal */ }
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Best-effort — never throw from a warning.
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Context option helpers
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Resolve the viewport for a context. Headed: no emulated viewport so the page
|
||||
/// tracks the real window (CDP viewport emulation forces outerWidth < innerWidth =
|
||||
/// a physically impossible window = bot tell). Headless: a fixed DEFAULT_VIEWPORT
|
||||
/// stays coherent (outer == inner) and keeps dimensions deterministic. An explicit
|
||||
/// <see cref="LaunchContextOptions.NoViewport"/> or <see cref="LaunchContextOptions.Viewport"/>
|
||||
/// is always honored. Port of Python <c>_resolve_context_viewport</c>.
|
||||
/// </summary>
|
||||
internal static ViewportSize? ResolveContextViewport(LaunchContextOptions options)
|
||||
{
|
||||
if (options.NoViewport)
|
||||
return ViewportSize.NoViewport;
|
||||
if (options.Viewport != null)
|
||||
return new ViewportSize { Width = options.Viewport.Value.Width, Height = options.Viewport.Value.Height };
|
||||
// Viewport unset: headed tracks the real window; headless on a newer binary also
|
||||
// tracks it (coherent dimensions natively), older headless gets the fixed default.
|
||||
bool headlessNoViewport = options.Headless
|
||||
&& Config.BinarySupportsHeadlessNoViewport(options.LicenseKey, options.BrowserVersion);
|
||||
return options.Headless && !headlessNoViewport
|
||||
? new ViewportSize { Width = Config.DefaultViewportWidth, Height = Config.DefaultViewportHeight }
|
||||
: ViewportSize.NoViewport;
|
||||
}
|
||||
|
||||
private static BrowserNewContextOptions BuildContextOptions(LaunchContextOptions options)
|
||||
{
|
||||
var ctx = new BrowserNewContextOptions();
|
||||
if (!string.IsNullOrEmpty(options.UserAgent))
|
||||
ctx.UserAgent = options.UserAgent;
|
||||
|
||||
ctx.ViewportSize = ResolveContextViewport(options);
|
||||
|
||||
if (!string.IsNullOrEmpty(options.ColorScheme))
|
||||
ctx.ColorScheme = ParseColorScheme(options.ColorScheme);
|
||||
if (!string.IsNullOrEmpty(options.StorageStatePath))
|
||||
ctx.StorageStatePath = options.StorageStatePath;
|
||||
return ctx;
|
||||
}
|
||||
|
||||
private static void ApplyContextEmulation(
|
||||
BrowserTypeLaunchPersistentContextOptions ctx, LaunchContextOptions options)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(options.UserAgent))
|
||||
ctx.UserAgent = options.UserAgent;
|
||||
|
||||
ctx.ViewportSize = ResolveContextViewport(options);
|
||||
|
||||
if (!string.IsNullOrEmpty(options.ColorScheme))
|
||||
ctx.ColorScheme = ParseColorScheme(options.ColorScheme);
|
||||
}
|
||||
|
||||
private static ColorScheme ParseColorScheme(string s) => s.ToLowerInvariant() switch
|
||||
{
|
||||
"light" => ColorScheme.Light,
|
||||
"dark" => ColorScheme.Dark,
|
||||
"no-preference" => ColorScheme.NoPreference,
|
||||
_ => ColorScheme.Light,
|
||||
};
|
||||
|
||||
// Access the private Playwright instance of a browser handle via reflection-free shim.
|
||||
// CloakBrowserHandle exposes the browser; we need the same IPlaywright for the context
|
||||
// handle. Stored when we created it - expose through an internal accessor.
|
||||
private static IPlaywright GetPlaywright(CloakBrowserHandle handle) => handle.PlaywrightInstance;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
namespace CloakBrowser;
|
||||
|
||||
/// <summary>
|
||||
/// Log severity levels for <see cref="CloakLog"/>. By default only Info/Warning/Error are
|
||||
/// written to stderr; set <c>CloakLog.MinLevel</c> to <see cref="Debug"/> for verbose output,
|
||||
/// or <see cref="None"/> to silence everything.
|
||||
/// </summary>
|
||||
public enum CloakLogLevel
|
||||
{
|
||||
Debug = 0,
|
||||
Info = 1,
|
||||
Warning = 2,
|
||||
Error = 3,
|
||||
None = 4,
|
||||
}
|
||||
|
||||
/// <summary>Logging facade used across the library.</summary>
|
||||
public static class CloakLog
|
||||
{
|
||||
/// <summary>Minimum level that will be emitted. Defaults to <see cref="CloakLogLevel.Info"/>.</summary>
|
||||
public static CloakLogLevel MinLevel { get; set; } = CloakLogLevel.Info;
|
||||
|
||||
/// <summary>Optional custom sink. Receives (level, message). Defaults to writing to stderr.</summary>
|
||||
public static Action<CloakLogLevel, string>? Sink { get; set; }
|
||||
|
||||
public static void Debug(string message) => Emit(CloakLogLevel.Debug, message);
|
||||
public static void Info(string message) => Emit(CloakLogLevel.Info, message);
|
||||
public static void Warning(string message) => Emit(CloakLogLevel.Warning, message);
|
||||
public static void Error(string message) => Emit(CloakLogLevel.Error, message);
|
||||
|
||||
public static void Debug(string format, params object?[] args) => Emit(CloakLogLevel.Debug, Fmt(format, args));
|
||||
public static void Info(string format, params object?[] args) => Emit(CloakLogLevel.Info, Fmt(format, args));
|
||||
public static void Warning(string format, params object?[] args) => Emit(CloakLogLevel.Warning, Fmt(format, args));
|
||||
|
||||
private static string Fmt(string format, object?[] args)
|
||||
{
|
||||
try { return args is { Length: > 0 } ? string.Format(format, args) : format; }
|
||||
catch (FormatException) { return format; }
|
||||
}
|
||||
|
||||
private static void Emit(CloakLogLevel level, string message)
|
||||
{
|
||||
if (level < MinLevel) return;
|
||||
if (Sink != null) { Sink(level, message); return; }
|
||||
Console.Error.WriteLine($"[cloakbrowser:{level.ToString().ToLowerInvariant()}] {message}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace CloakBrowser;
|
||||
|
||||
/// <summary>Wrapper version (mirrors Python <c>cloakbrowser/_version.py</c>).</summary>
|
||||
public static class CloakVersion
|
||||
{
|
||||
/// <summary>The CloakBrowser .NET wrapper version.</summary>
|
||||
public const string Version = "0.4.10";
|
||||
}
|
||||
@@ -0,0 +1,532 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace CloakBrowser;
|
||||
|
||||
/// <summary>
|
||||
/// Stealth configuration and platform detection for CloakBrowser.
|
||||
/// Direct port of Python <c>cloakbrowser/config.py</c>.
|
||||
/// </summary>
|
||||
public static class Config
|
||||
{
|
||||
// -----------------------------------------------------------------------
|
||||
// Chromium version shipped with this release.
|
||||
// Different platforms may ship different versions during transition periods.
|
||||
// ChromiumVersion is the latest across all platforms (for display/reference).
|
||||
// Use GetChromiumVersion() for the current platform's actual version.
|
||||
// -----------------------------------------------------------------------
|
||||
public const string ChromiumVersion = "146.0.7680.177.5";
|
||||
|
||||
public static readonly IReadOnlyDictionary<string, string> PlatformChromiumVersions =
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
["linux-x64"] = "146.0.7680.177.5",
|
||||
["linux-arm64"] = "146.0.7680.177.3",
|
||||
["darwin-arm64"] = "145.0.7632.109.2",
|
||||
["darwin-x64"] = "145.0.7632.109.2",
|
||||
["windows-x64"] = "146.0.7680.177.5",
|
||||
};
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Ed25519 public keys for verifying downloaded binaries.
|
||||
//
|
||||
// Each release publishes SHA256SUMS and a detached signature SHA256SUMS.sig.
|
||||
// The wrapper verifies that signature against the keys below before trusting
|
||||
// any hash in the manifest, so the download origin alone cannot certify a
|
||||
// tampered binary. Values are base64 of the 32-byte raw public key. Multiple
|
||||
// entries are accepted to allow key rotation.
|
||||
// -----------------------------------------------------------------------
|
||||
public static readonly IReadOnlyList<string> BinarySigningPubkeys = new[]
|
||||
{
|
||||
"MKFKwIhUcKWq5xTuNA0Ovg99njcDEcEJvmWYYhApvaU=",
|
||||
};
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Playwright default args to suppress - these leak automation signals.
|
||||
// --enable-automation: exposes navigator.webdriver = true
|
||||
// --enable-unsafe-swiftshader: forces software WebGL rendering via SwiftShader,
|
||||
// producing a distinctive renderer string that no real user browser has.
|
||||
// -----------------------------------------------------------------------
|
||||
public static readonly string[] IgnoreDefaultArgs =
|
||||
{ "--enable-automation", "--enable-unsafe-swiftshader" };
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Default viewport - used for HEADLESS only (headed launches use no_viewport
|
||||
// so the page tracks the real window). Headless has no window chrome, so a
|
||||
// fixed viewport stays coherent (outer == inner) and gives deterministic
|
||||
// dimensions. Models a maximized Chrome on 1080p Windows: screen=1920x1080,
|
||||
// innerHeight=947 (minus ~85px Chrome UI: tabs + address bar + bookmarks).
|
||||
// -----------------------------------------------------------------------
|
||||
public const int DefaultViewportWidth = 1920;
|
||||
public const int DefaultViewportHeight = 947;
|
||||
|
||||
private static readonly Random _rng = new();
|
||||
|
||||
/// <summary>
|
||||
/// Build stealth args with a random fingerprint seed per launch.
|
||||
/// On macOS, skips platform/GPU spoofing - runs as a native Mac browser.
|
||||
/// Spoofing Windows on Mac creates detectable mismatches (fonts, GPU, etc.).
|
||||
/// </summary>
|
||||
public static List<string> GetDefaultStealthArgs()
|
||||
{
|
||||
int seed;
|
||||
lock (_rng) { seed = _rng.Next(10000, 100000); }
|
||||
|
||||
var baseArgs = new List<string>
|
||||
{
|
||||
"--no-sandbox",
|
||||
$"--fingerprint={seed}",
|
||||
};
|
||||
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
|
||||
{
|
||||
// Tell the fingerprint patches we're on macOS so GPU/UA match natively.
|
||||
baseArgs.Add("--fingerprint-platform=macos");
|
||||
return baseArgs;
|
||||
}
|
||||
|
||||
// Linux/Windows: Windows fingerprint profile.
|
||||
// Screen and window size come from the real display, not this flag (verified:
|
||||
// identical across seeds), so the wrapper must not emulate a viewport on top in
|
||||
// headed mode - that would break outerWidth >= innerWidth coherence.
|
||||
baseArgs.Add("--fingerprint-platform=windows");
|
||||
return baseArgs;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Platform detection
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <summary>Platforms with pre-built binaries available for download.</summary>
|
||||
public static IReadOnlySet<string> AvailablePlatforms =>
|
||||
new HashSet<string>(PlatformChromiumVersions.Keys);
|
||||
|
||||
/// <summary>Return the Chromium version for the current platform.</summary>
|
||||
public static string GetChromiumVersion()
|
||||
{
|
||||
var tag = GetPlatformTag();
|
||||
return PlatformChromiumVersions.TryGetValue(tag, out var v) ? v : ChromiumVersion;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Version pinning
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private static readonly Regex VersionPinRe = new(
|
||||
@"^[0-9]+(?:\.[0-9]+){3,4}$",
|
||||
RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
||||
|
||||
/// <summary>
|
||||
/// Return an explicit Chromium version pin from arg/env, or null.
|
||||
///
|
||||
/// The explicit argument wins over <c>CLOAKBROWSER_VERSION</c>. Only numeric dotted
|
||||
/// versions are accepted because the value is interpolated into cache paths
|
||||
/// and download URLs. Port of Python <c>normalize_requested_version()</c>.
|
||||
/// </summary>
|
||||
public static string? NormalizeRequestedVersion(string? version = null)
|
||||
{
|
||||
var raw = version ?? Environment.GetEnvironmentVariable("CLOAKBROWSER_VERSION");
|
||||
if (raw == null)
|
||||
return null;
|
||||
var normalized = raw.Trim();
|
||||
if (normalized.Length == 0)
|
||||
return null;
|
||||
if (!VersionPinRe.IsMatch(normalized))
|
||||
throw new ArgumentException(
|
||||
"Invalid browser version pin. Use a full numeric Chromium version, " +
|
||||
"e.g. '148.0.7778.215.2'.");
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Return the platform tag for binary download (e.g. <c>linux-x64</c>, <c>darwin-arm64</c>).
|
||||
/// </summary>
|
||||
public static string GetPlatformTag()
|
||||
{
|
||||
var arch = RuntimeInformation.OSArchitecture;
|
||||
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
|
||||
{
|
||||
return arch switch
|
||||
{
|
||||
Architecture.X64 => "linux-x64",
|
||||
Architecture.Arm64 => "linux-arm64",
|
||||
_ => throw Unsupported("Linux", arch),
|
||||
};
|
||||
}
|
||||
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
|
||||
{
|
||||
return arch switch
|
||||
{
|
||||
Architecture.Arm64 => "darwin-arm64",
|
||||
Architecture.X64 => "darwin-x64",
|
||||
_ => throw Unsupported("Darwin", arch),
|
||||
};
|
||||
}
|
||||
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
{
|
||||
return arch switch
|
||||
{
|
||||
Architecture.X64 => "windows-x64",
|
||||
_ => throw Unsupported("Windows", arch),
|
||||
};
|
||||
}
|
||||
|
||||
throw new PlatformNotSupportedException(
|
||||
$"Unsupported platform: {RuntimeInformation.OSDescription} {arch}");
|
||||
}
|
||||
|
||||
private static PlatformNotSupportedException Unsupported(string system, Architecture arch) =>
|
||||
new($"Unsupported platform: {system} {arch}. " +
|
||||
"Supported: linux-x64, linux-arm64, darwin-arm64, darwin-x64, windows-x64");
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Binary cache paths
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Return the cache directory for downloaded binaries.
|
||||
/// Override with the <c>CLOAKBROWSER_CACHE_DIR</c> env var. Default: <c>~/.cloakbrowser/</c>.
|
||||
/// </summary>
|
||||
public static string GetCacheDir()
|
||||
{
|
||||
var custom = Environment.GetEnvironmentVariable("CLOAKBROWSER_CACHE_DIR");
|
||||
if (!string.IsNullOrEmpty(custom))
|
||||
return custom;
|
||||
var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
|
||||
return Path.Combine(home, ".cloakbrowser");
|
||||
}
|
||||
|
||||
/// <summary>Return the directory for a Chromium version binary.</summary>
|
||||
/// <param name="version">Version string, or null for the platform default.</param>
|
||||
/// <param name="pro">When true, use the Pro-specific cache dir (<c>chromium-{v}-pro</c>).</param>
|
||||
public static string GetBinaryDir(string? version = null, bool pro = false)
|
||||
{
|
||||
var v = version ?? GetChromiumVersion();
|
||||
var suffix = pro ? "-pro" : "";
|
||||
return Path.Combine(GetCacheDir(), $"chromium-{v}{suffix}");
|
||||
}
|
||||
|
||||
/// <summary>Return the expected path to the chrome executable.</summary>
|
||||
public static string GetBinaryPath(string? version = null, bool pro = false)
|
||||
{
|
||||
var binaryDir = GetBinaryDir(version, pro);
|
||||
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
|
||||
return Path.Combine(binaryDir, "Chromium.app", "Contents", "MacOS", "Chromium");
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
return Path.Combine(binaryDir, "chrome.exe");
|
||||
return Path.Combine(binaryDir, "chrome");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Raise a clear error if no pre-built binary exists for this platform.
|
||||
/// Skipped when <c>CLOAKBROWSER_BINARY_PATH</c> is set (user has their own build).
|
||||
/// </summary>
|
||||
public static void CheckPlatformAvailable()
|
||||
{
|
||||
if (GetLocalBinaryOverride() != null)
|
||||
return;
|
||||
|
||||
var tag = GetPlatformTag(); // throws if platform unsupported entirely
|
||||
if (!AvailablePlatforms.Contains(tag))
|
||||
{
|
||||
var available = string.Join(", ", AvailablePlatforms.OrderBy(x => x));
|
||||
throw new PlatformNotSupportedException(
|
||||
$"\nCloakBrowser - Pre-built binaries are currently only available for: {available}.\n\n" +
|
||||
"To use CloakBrowser now, set CLOAKBROWSER_BINARY_PATH to a local Chromium binary.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Return the best available version: auto-updated if available, else platform default.
|
||||
/// Reads a platform-scoped marker file from the cache directory.
|
||||
/// When <paramref name="pro"/> is true, reads from the Pro-specific marker and returns
|
||||
/// <c>null</c> when no cached Pro binary matches it. A valid Pro license must NEVER fall
|
||||
/// back to the free binary, so there is deliberately no free-version fallback for Pro —
|
||||
/// callers treat <c>null</c> as "resolve the latest Pro version from the server".
|
||||
/// </summary>
|
||||
public static string? GetEffectiveVersion(bool pro = false)
|
||||
{
|
||||
var baseVersion = GetChromiumVersion();
|
||||
var cache = GetCacheDir();
|
||||
|
||||
if (pro)
|
||||
{
|
||||
var proMarker = Path.Combine(cache, $"latest_pro_version_{GetPlatformTag()}");
|
||||
if (File.Exists(proMarker))
|
||||
{
|
||||
try
|
||||
{
|
||||
var version = File.ReadAllText(proMarker).Trim();
|
||||
// Match launch's ProBinaryReady (exists AND executable) so `info`
|
||||
// never reports a build that launch would reject.
|
||||
if (!string.IsNullOrEmpty(version) && IsExecutableFile(GetBinaryPath(version, pro: true)))
|
||||
return version;
|
||||
}
|
||||
catch (Exception ex) when (ex is FormatException or IOException) { }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach (var name in new[] { $"latest_version_{GetPlatformTag()}", "latest_version" })
|
||||
{
|
||||
var marker = Path.Combine(cache, name);
|
||||
if (File.Exists(marker))
|
||||
{
|
||||
try
|
||||
{
|
||||
var version = File.ReadAllText(marker).Trim();
|
||||
if (!string.IsNullOrEmpty(version) && VersionNewer(version, baseVersion))
|
||||
{
|
||||
var binary = GetBinaryPath(version);
|
||||
if (File.Exists(binary))
|
||||
return version;
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is FormatException or IOException) { }
|
||||
}
|
||||
}
|
||||
return baseVersion;
|
||||
}
|
||||
|
||||
/// <summary>True when a binary exists and is executable. Canonical check shared with Download.</summary>
|
||||
internal static bool IsExecutableFile(string path)
|
||||
{
|
||||
if (!File.Exists(path)) return false;
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) return true;
|
||||
var mode = File.GetUnixFileMode(path);
|
||||
return (mode & (UnixFileMode.UserExecute | UnixFileMode.GroupExecute | UnixFileMode.OtherExecute)) != 0;
|
||||
}
|
||||
|
||||
/// <summary>Parse "145.0.7718.0" into (145, 0, 7718, 0) for comparison.</summary>
|
||||
public static int[] VersionTuple(string v) =>
|
||||
v.Split('.').Select(int.Parse).ToArray();
|
||||
|
||||
/// <summary>Return true if version a is strictly newer than version b.</summary>
|
||||
public static bool VersionNewer(string a, string b)
|
||||
{
|
||||
var ta = VersionTuple(a);
|
||||
var tb = VersionTuple(b);
|
||||
int len = Math.Max(ta.Length, tb.Length);
|
||||
for (int i = 0; i < len; i++)
|
||||
{
|
||||
int va = i < ta.Length ? ta[i] : 0;
|
||||
int vb = i < tb.Length ? tb[i] : 0;
|
||||
if (va != vb) return va > vb;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Download URL
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
public static string DownloadBaseUrl =>
|
||||
Environment.GetEnvironmentVariable("CLOAKBROWSER_DOWNLOAD_URL") ?? "https://cloakbrowser.dev";
|
||||
|
||||
public const string GitHubApiUrl = "https://api.github.com/repos/CloakHQ/cloakbrowser/releases";
|
||||
|
||||
public const string GitHubDownloadBaseUrl =
|
||||
"https://github.com/CloakHQ/cloakbrowser/releases/download";
|
||||
|
||||
/// <summary>Return the archive extension for the current platform (.zip for Windows, .tar.gz otherwise).</summary>
|
||||
public static string GetArchiveExt() =>
|
||||
RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? ".zip" : ".tar.gz";
|
||||
|
||||
/// <summary>Return the archive filename for a platform tag (e.g. 'cloakbrowser-linux-x64.tar.gz').</summary>
|
||||
public static string GetArchiveName(string? tag = null)
|
||||
{
|
||||
var t = tag ?? GetPlatformTag();
|
||||
return $"cloakbrowser-{t}{GetArchiveExt()}";
|
||||
}
|
||||
|
||||
/// <summary>Return the full download URL for the current platform's binary archive.</summary>
|
||||
public static string GetDownloadUrl(string? version = null)
|
||||
{
|
||||
var v = version ?? GetChromiumVersion();
|
||||
return $"{DownloadBaseUrl}/chromium-v{v}/{GetArchiveName()}";
|
||||
}
|
||||
|
||||
/// <summary>Return the GitHub Releases fallback URL for the binary archive.</summary>
|
||||
public static string GetFallbackDownloadUrl(string? version = null)
|
||||
{
|
||||
var v = version ?? GetChromiumVersion();
|
||||
return $"{GitHubDownloadBaseUrl}/chromium-v{v}/{GetArchiveName()}";
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// CloakBrowser Pro download URLs (cloakbrowser.dev, license-key authed)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Return the Pro binary download URL for an explicit version. The version is
|
||||
/// requested explicitly so the served archive matches the signed Pro manifest.
|
||||
/// </summary>
|
||||
public static string GetProDownloadUrl(string version) =>
|
||||
$"{DownloadBaseUrl}/api/download/{version}";
|
||||
|
||||
/// <summary>Return the base URL for the Pro signed manifest (SHA256SUMS + .sig) of a version.</summary>
|
||||
public static string GetProManifestBaseUrl(string version) =>
|
||||
$"{DownloadBaseUrl}/releases/pro/chromium-v{version}";
|
||||
|
||||
/// <summary>Return the "latest Pro" display download URL (shown by binary_info for Pro installs).</summary>
|
||||
public static string GetProLatestDownloadUrl() =>
|
||||
$"{DownloadBaseUrl}/api/download/latest";
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Local binary override (skip download, use your own build)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Check if the user has set a local binary path via the <c>CLOAKBROWSER_BINARY_PATH</c> env var.
|
||||
/// </summary>
|
||||
public static string? GetLocalBinaryOverride() =>
|
||||
Environment.GetEnvironmentVariable("CLOAKBROWSER_BINARY_PATH");
|
||||
|
||||
// First Chromium build that reports coherent headless dimensions without an
|
||||
// emulated viewport. On these binaries the wrapper launches headless with no
|
||||
// viewport; older binaries need a fixed default viewport to stay coherent.
|
||||
// null => not shipped yet; feature off, behavior byte-identical to today.
|
||||
// TODO: set to the chromium version string that first ships it.
|
||||
public static readonly string? HeadlessNoViewportMinVersion = "148.0.7778.215.4";
|
||||
|
||||
/// <summary>
|
||||
/// Whether headless can launch without an emulated viewport on the resolved binary.
|
||||
/// Only binaries at or above <see cref="HeadlessNoViewportMinVersion"/> qualify; older
|
||||
/// ones keep the fixed default viewport. A local override binary
|
||||
/// (<c>CLOAKBROWSER_BINARY_PATH</c>) is unknown-version, so stay on the safe path.
|
||||
/// </summary>
|
||||
public static bool BinarySupportsHeadlessNoViewport(string? licenseKey = null, string? browserVersion = null)
|
||||
{
|
||||
if (HeadlessNoViewportMinVersion == null)
|
||||
return false;
|
||||
// A declared version (browserVersion arg OR CLOAKBROWSER_VERSION env) wins even
|
||||
// under a local override — the caller asserts the version (also how internal builds
|
||||
// opt in). Only an override with no declared version stays on the safe path.
|
||||
string? declared;
|
||||
try
|
||||
{
|
||||
declared = NormalizeRequestedVersion(browserVersion);
|
||||
}
|
||||
catch
|
||||
{
|
||||
declared = null;
|
||||
}
|
||||
string? version;
|
||||
if (!string.IsNullOrEmpty(declared))
|
||||
{
|
||||
version = declared!;
|
||||
}
|
||||
else if (GetLocalBinaryOverride() != null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
else
|
||||
{
|
||||
bool pro = !string.IsNullOrEmpty(License.ResolveLicenseKey(licenseKey));
|
||||
version = GetEffectiveVersion(pro);
|
||||
}
|
||||
// No cached Pro build resolvable (GetEffectiveVersion returned null) → fail safe.
|
||||
if (version == null) return false;
|
||||
try
|
||||
{
|
||||
return !VersionNewer(HeadlessNoViewportMinVersion, version);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether the wrapper may auto-add <c>--start-maximized</c>. Gated on the same
|
||||
/// threshold as the no_viewport shim: only binaries whose headless surface-fix +
|
||||
/// headed screen-clamp make a maximized window coherent (<c>outer == screen</c>).
|
||||
/// Below it, maximizing headless while the CDP viewport stays at 1280x720 yields
|
||||
/// <c>outerWidth < innerWidth</c> — a bot tell — so the flag must NOT be added.
|
||||
/// Shares <see cref="HeadlessNoViewportMinVersion"/>; own name so the two can
|
||||
/// diverge later. Python, JS and .NET mirror this gate.
|
||||
/// </summary>
|
||||
public static bool BinarySupportsMaximizedWindow(string? licenseKey = null, string? browserVersion = null)
|
||||
=> BinarySupportsHeadlessNoViewport(licenseKey, browserVersion);
|
||||
|
||||
// First Chromium build, per platform, whose binary can take inline HTTP proxy
|
||||
// credentials (Chromium-native --proxy-server=http://user:pass@host). Below the
|
||||
// floor the wrapper MUST route credentialed HTTP/HTTPS proxies through
|
||||
// Playwright's proxy object — an older binary treats the user:pass@ authority as
|
||||
// an invalid proxy host and drops proxy auth (#182). A single global threshold
|
||||
// can't model this: the capability landed in different build lineages at different
|
||||
// points (linux-x64/windows-x64 at 146.0.7680.177.5, but the free macOS 145.x and
|
||||
// linux-arm64 146.0.7680.177.3 floors predate it, qualifying only once they resolve
|
||||
// to a Pro/newer 148+ build). Platforms absent from this map are never-inline.
|
||||
public static readonly IReadOnlyDictionary<string, string> HttpProxyInlineAuthMinVersion =
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
["linux-x64"] = "146.0.7680.177.5",
|
||||
["windows-x64"] = "146.0.7680.177.5",
|
||||
["linux-arm64"] = "148.0.7778.215.3",
|
||||
["darwin-arm64"] = "148.0.7778.215.3",
|
||||
["darwin-x64"] = "148.0.7778.215.3",
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Whether the resolved binary accepts inline HTTP proxy credentials. The
|
||||
/// capability is a function of the binary version; <paramref name="licenseKey"/>
|
||||
/// only resolves which version launches (Pro build vs free default), exactly like
|
||||
/// <see cref="BinarySupportsHeadlessNoViewport"/>. A declared pin wins, else a valid
|
||||
/// Pro license resolves to the Pro build (which ships the patch), else the free
|
||||
/// per-platform default — then it compares to this platform's floor. Below the
|
||||
/// floor, credentialed HTTP proxies fall back to Playwright's proxy object. A local
|
||||
/// override with no declared version is unknown-version, so stay on the safe
|
||||
/// fallback. Python, JS and .NET mirror this gate.
|
||||
/// </summary>
|
||||
public static bool BinarySupportsHttpProxyInlineAuth(string? licenseKey = null, string? browserVersion = null)
|
||||
{
|
||||
string tag;
|
||||
try
|
||||
{
|
||||
tag = GetPlatformTag();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if (!HttpProxyInlineAuthMinVersion.TryGetValue(tag, out var floor))
|
||||
return false;
|
||||
string? declared;
|
||||
try
|
||||
{
|
||||
declared = NormalizeRequestedVersion(browserVersion);
|
||||
}
|
||||
catch
|
||||
{
|
||||
declared = null;
|
||||
}
|
||||
string? version;
|
||||
if (!string.IsNullOrEmpty(declared))
|
||||
{
|
||||
version = declared!;
|
||||
}
|
||||
else if (GetLocalBinaryOverride() != null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
else
|
||||
{
|
||||
bool pro = !string.IsNullOrEmpty(License.ResolveLicenseKey(licenseKey));
|
||||
version = GetEffectiveVersion(pro);
|
||||
}
|
||||
if (version == null) return false;
|
||||
try
|
||||
{
|
||||
return !VersionNewer(floor, version);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
using System.Diagnostics;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace CloakBrowser;
|
||||
|
||||
/// <summary>
|
||||
/// Environment + binary diagnostics gathering for the `info` / `doctor` CLI.
|
||||
/// Returns a plain dictionary so the CLI can render it as text or JSON without
|
||||
/// the two output paths drifting apart. Never triggers a binary download.
|
||||
/// </summary>
|
||||
internal static class Diagnostics
|
||||
{
|
||||
internal static Dictionary<string, object?> Collect(bool quick)
|
||||
{
|
||||
var diag = new Dictionary<string, object?>();
|
||||
|
||||
var env = new Dictionary<string, object?>
|
||||
{
|
||||
["dotnet"] = RuntimeInformation.FrameworkDescription,
|
||||
["os"] = RuntimeInformation.OSDescription,
|
||||
["arch"] = RuntimeInformation.OSArchitecture.ToString(),
|
||||
};
|
||||
diag["environment"] = env;
|
||||
|
||||
// Resolve the license up front — it decides which binary actually
|
||||
// launches (EnsureBinary only uses the Pro binary when a key validates).
|
||||
var (license, entitledPro) = ResolveLicense();
|
||||
|
||||
try { env["platform_tag"] = Config.GetPlatformTag(); }
|
||||
catch (Exception ex) { env["platform_tag"] = $"unavailable ({ex.Message})"; }
|
||||
|
||||
Dictionary<string, object?> binary;
|
||||
try { binary = EffectiveBinary(entitledPro, quick); }
|
||||
catch (Exception ex) { binary = new Dictionary<string, object?> { ["error"] = ex.Message }; }
|
||||
diag["binary"] = binary;
|
||||
|
||||
// Launch test (skipped by --quick or when the binary is not installed).
|
||||
string? binPath = binary.TryGetValue("path", out var p) ? p as string : null;
|
||||
bool installed = binary.TryGetValue("installed", out var i) && i is true;
|
||||
if (quick)
|
||||
{
|
||||
diag["launch"] = new Dictionary<string, object?> { ["tested"] = false, ["reason"] = "skipped (--quick)" };
|
||||
}
|
||||
else if (string.IsNullOrEmpty(binPath) || !(installed || File.Exists(binPath)))
|
||||
{
|
||||
diag["launch"] = new Dictionary<string, object?> { ["tested"] = false, ["reason"] = "binary not installed" };
|
||||
}
|
||||
else
|
||||
{
|
||||
var (ok, version, error) = BinaryVersion(binPath!);
|
||||
var launch = new Dictionary<string, object?>
|
||||
{
|
||||
["tested"] = true,
|
||||
["ok"] = ok,
|
||||
["version"] = version,
|
||||
["error"] = error,
|
||||
};
|
||||
if (!ok) launch["missing_libs"] = MissingSharedLibs(binPath!);
|
||||
diag["launch"] = launch;
|
||||
}
|
||||
|
||||
// Windows-font probe — only meaningful on a Linux host spoofing Windows.
|
||||
// Omitted entirely off Linux, where it carries no signal.
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
|
||||
{
|
||||
// Strict count, not "any one present" — real font installs are atomic
|
||||
// (you have the whole pack or none), so report how complete the set is.
|
||||
int? winN = CloakLauncher.CountFontsPresent(CloakLauncher.WindowsFontTells);
|
||||
int? officeN = CloakLauncher.CountFontsPresent(CloakLauncher.OfficeFontTells);
|
||||
diag["fonts"] = new Dictionary<string, object?>
|
||||
{
|
||||
["windows"] = winN is null ? null : new[] { winN.Value, CloakLauncher.WindowsFontTells.Length },
|
||||
["office"] = officeN is null ? null : new[] { officeN.Value, CloakLauncher.OfficeFontTells.Length },
|
||||
};
|
||||
}
|
||||
|
||||
diag["license"] = license;
|
||||
|
||||
// GeoIP DB — presence only, never downloads.
|
||||
string dbPath = Path.Combine(Config.GetCacheDir(), "geoip", "GeoLite2-City.mmdb");
|
||||
diag["geoip"] = new Dictionary<string, object?> { ["db_present"] = File.Exists(dbPath), ["path"] = dbPath };
|
||||
|
||||
// Dependency assemblies — mirrors the Python/JS modules section. These are
|
||||
// hard NuGet references, so "missing" here means a broken deployment.
|
||||
diag["modules"] = new Dictionary<string, object?>
|
||||
{
|
||||
["playwright"] = ModuleAvailable("Microsoft.Playwright"),
|
||||
["geoip2"] = ModuleAvailable("MaxMind.GeoIP2"),
|
||||
};
|
||||
|
||||
return diag;
|
||||
}
|
||||
|
||||
// Resolve + validate the license the way EnsureBinary does.
|
||||
private static (Dictionary<string, object?> license, bool entitledPro) ResolveLicense()
|
||||
{
|
||||
string? key = License.ResolveLicenseKey();
|
||||
// EnsureBinary disables Pro routing when a custom download URL is set, so the
|
||||
// diagnostic must report free too (matches Download.cs).
|
||||
if (!string.IsNullOrEmpty(Environment.GetEnvironmentVariable("CLOAKBROWSER_DOWNLOAD_URL")))
|
||||
key = null;
|
||||
if (string.IsNullOrEmpty(key))
|
||||
return (new Dictionary<string, object?> { ["tier"] = "free" }, false);
|
||||
try
|
||||
{
|
||||
LicenseInfo? lic = License.ValidateLicense(key!);
|
||||
if (lic is null)
|
||||
return (new Dictionary<string, object?> { ["tier"] = "unknown", ["error"] = "could not validate" }, false);
|
||||
if (lic.Valid)
|
||||
return (new Dictionary<string, object?> { ["tier"] = lic.Plan, ["valid"] = true, ["expires"] = lic.Expires }, true);
|
||||
return (new Dictionary<string, object?> { ["tier"] = "invalid", ["valid"] = false }, false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return (new Dictionary<string, object?> { ["tier"] = "unknown", ["error"] = ex.Message }, false);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool ModuleAvailable(string assemblyName)
|
||||
{
|
||||
try { System.Reflection.Assembly.Load(assemblyName); return true; }
|
||||
catch { return false; }
|
||||
}
|
||||
|
||||
// Describe the binary EnsureBinary would actually launch (no download).
|
||||
// Unlike Download.BinaryInfo(), a Pro binary on disk is only reported when
|
||||
// the license entitles Pro — so a keyless run shows the free binary.
|
||||
private static Dictionary<string, object?> EffectiveBinary(bool entitledPro, bool quick = false)
|
||||
{
|
||||
string? over = Config.GetLocalBinaryOverride();
|
||||
if (!string.IsNullOrEmpty(over))
|
||||
{
|
||||
return new Dictionary<string, object?>
|
||||
{
|
||||
["version"] = null,
|
||||
["latest_version"] = null,
|
||||
["pinned"] = false,
|
||||
["tier"] = "override",
|
||||
["bundled_version"] = Config.ChromiumVersion,
|
||||
["path"] = over,
|
||||
["installed"] = File.Exists(over),
|
||||
["cache_dir"] = null,
|
||||
["override"] = over,
|
||||
};
|
||||
}
|
||||
string? requested = Config.NormalizeRequestedVersion();
|
||||
|
||||
// For a Pro license, surface the server's latest separately from the version
|
||||
// that will actually launch, so `info` can never silently diverge from launch
|
||||
// (the divergence a customer hit: info showed latest, launch ran a stale cache).
|
||||
// --quick keeps `info` fully network-free (skip the server latest lookup).
|
||||
string? latestVersion = (entitledPro && !quick) ? License.GetProLatestVersion() : null;
|
||||
|
||||
string? version;
|
||||
if (!string.IsNullOrEmpty(requested))
|
||||
version = requested!;
|
||||
else if (entitledPro)
|
||||
// "Will launch now" is the cached Pro build; if none is cached, the next
|
||||
// launch downloads latestVersion. GetEffectiveVersion(true) returns null
|
||||
// (never the free base) when nothing is cached.
|
||||
version = Config.GetEffectiveVersion(true) ?? latestVersion;
|
||||
else
|
||||
version = Config.GetEffectiveVersion(false);
|
||||
string? path = version != null ? Config.GetBinaryPath(version, entitledPro) : null;
|
||||
return new Dictionary<string, object?>
|
||||
{
|
||||
["version"] = version,
|
||||
["latest_version"] = latestVersion,
|
||||
["pinned"] = !string.IsNullOrEmpty(requested),
|
||||
["tier"] = entitledPro ? "pro" : "free",
|
||||
["bundled_version"] = Config.ChromiumVersion,
|
||||
["path"] = path,
|
||||
["installed"] = path != null && File.Exists(path),
|
||||
["cache_dir"] = version != null ? Config.GetBinaryDir(version, entitledPro) : null,
|
||||
["override"] = null,
|
||||
};
|
||||
}
|
||||
|
||||
// Launch `<binary> --version` to prove it runs.
|
||||
private static (bool ok, string version, string error) BinaryVersion(string binaryPath)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var proc = new Process
|
||||
{
|
||||
StartInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = binaryPath,
|
||||
Arguments = "--version",
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
},
|
||||
};
|
||||
proc.Start();
|
||||
// Read both pipes asynchronously so a full pipe buffer can't deadlock
|
||||
// the parent, and so WaitForExit's timeout is the real wall-clock bound.
|
||||
var stdoutTask = proc.StandardOutput.ReadToEndAsync();
|
||||
var stderrTask = proc.StandardError.ReadToEndAsync();
|
||||
if (!proc.WaitForExit(10000))
|
||||
{
|
||||
try { proc.Kill(true); } catch { /* best-effort */ }
|
||||
return (false, "", "timed out");
|
||||
}
|
||||
proc.WaitForExit(); // flush async readers now that the process has exited
|
||||
string stdout = stdoutTask.GetAwaiter().GetResult();
|
||||
string stderr = stderrTask.GetAwaiter().GetResult();
|
||||
if (proc.ExitCode != 0)
|
||||
return (false, "", (string.IsNullOrWhiteSpace(stderr) ? stdout : stderr).Trim());
|
||||
return (true, stdout.Trim(), "");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return (false, "", ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
// Linux-only: ldd the binary and return missing .so names.
|
||||
private static List<string> MissingSharedLibs(string binaryPath)
|
||||
{
|
||||
var missing = new List<string>();
|
||||
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) return missing;
|
||||
try
|
||||
{
|
||||
// ArgumentList passes the path as a single argv entry, so a path
|
||||
// containing spaces is not split into multiple ldd arguments.
|
||||
var psi = new ProcessStartInfo
|
||||
{
|
||||
FileName = "ldd",
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
};
|
||||
psi.ArgumentList.Add("--"); // so a path starting with - isn't read as a flag
|
||||
psi.ArgumentList.Add(binaryPath);
|
||||
using var proc = new Process { StartInfo = psi };
|
||||
proc.Start();
|
||||
var stdoutTask = proc.StandardOutput.ReadToEndAsync();
|
||||
if (!proc.WaitForExit(10000))
|
||||
{
|
||||
try { proc.Kill(true); } catch { /* best-effort */ }
|
||||
return missing;
|
||||
}
|
||||
proc.WaitForExit();
|
||||
string stdout = stdoutTask.GetAwaiter().GetResult();
|
||||
foreach (var line in stdout.Split('\n'))
|
||||
if (line.Contains("=> not found"))
|
||||
missing.Add(line.Split("=>")[0].Trim());
|
||||
}
|
||||
catch { /* best-effort */ }
|
||||
return missing;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,415 @@
|
||||
using System.Globalization;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.Sockets;
|
||||
using MaxMind.GeoIP2;
|
||||
|
||||
namespace CloakBrowser;
|
||||
|
||||
/// <summary>
|
||||
/// GeoIP-based timezone and locale detection from a proxy IP.
|
||||
/// Downloads GeoLite2-City.mmdb (~70 MB) on first use, caches in
|
||||
/// <c>~/.cloakbrowser/geoip/</c>. Background re-download after 30 days.
|
||||
/// Direct port of Python <c>cloakbrowser/geoip.py</c>.
|
||||
/// </summary>
|
||||
public static class GeoIp
|
||||
{
|
||||
// P3TERX mirror of MaxMind GeoLite2-City - no license key needed.
|
||||
private const string GeoIpDbUrl =
|
||||
"https://github.com/P3TERX/GeoLite.mmdb/raw/download/GeoLite2-City.mmdb";
|
||||
private const string GeoIpDbFilename = "GeoLite2-City.mmdb";
|
||||
private const long GeoIpUpdateInterval = 30L * 86_400; // 30 days (seconds)
|
||||
private const double DefaultGeoIpTimeoutSeconds = 5.0;
|
||||
private const string GeoIpTimeoutEnv = "CLOAKBROWSER_GEOIP_TIMEOUT_SECONDS";
|
||||
|
||||
// IP echo services - fast, no auth, return just the IP.
|
||||
private static readonly string[] IpEchoUrls =
|
||||
{
|
||||
"https://api.ipify.org",
|
||||
"https://checkip.amazonaws.com",
|
||||
"https://ifconfig.me/ip",
|
||||
};
|
||||
|
||||
/// <summary>Country ISO code -> BCP 47 locale (covers ~90% of proxy traffic).</summary>
|
||||
public static readonly IReadOnlyDictionary<string, string> CountryLocaleMap =
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
["US"] = "en-US", ["GB"] = "en-GB", ["AU"] = "en-AU", ["CA"] = "en-CA", ["NZ"] = "en-NZ",
|
||||
["IE"] = "en-IE", ["ZA"] = "en-ZA", ["SG"] = "en-SG",
|
||||
["DE"] = "de-DE", ["AT"] = "de-AT", ["CH"] = "de-CH",
|
||||
["FR"] = "fr-FR", ["BE"] = "fr-BE",
|
||||
["ES"] = "es-ES", ["MX"] = "es-MX", ["AR"] = "es-AR", ["CO"] = "es-CO", ["CL"] = "es-CL",
|
||||
["BR"] = "pt-BR", ["PT"] = "pt-PT",
|
||||
["IT"] = "it-IT", ["NL"] = "nl-NL",
|
||||
["JP"] = "ja-JP", ["KR"] = "ko-KR", ["CN"] = "zh-CN", ["TW"] = "zh-TW", ["HK"] = "zh-HK",
|
||||
["RU"] = "ru-RU", ["UA"] = "uk-UA", ["PL"] = "pl-PL", ["CZ"] = "cs-CZ", ["RO"] = "ro-RO",
|
||||
["IL"] = "he-IL", ["TR"] = "tr-TR", ["SA"] = "ar-SA", ["AE"] = "ar-AE", ["EG"] = "ar-EG",
|
||||
["IN"] = "hi-IN", ["ID"] = "id-ID", ["PH"] = "en-PH",
|
||||
["TH"] = "th-TH", ["VN"] = "vi-VN", ["MY"] = "ms-MY",
|
||||
["SE"] = "sv-SE", ["NO"] = "nb-NO", ["DK"] = "da-DK", ["FI"] = "fi-FI",
|
||||
["GR"] = "el-GR", ["HU"] = "hu-HU", ["BG"] = "bg-BG",
|
||||
// Extended coverage - common residential/mobile proxy exits
|
||||
["SI"] = "sl-SI", ["SK"] = "sk-SK", ["HR"] = "hr-HR", ["RS"] = "sr-RS", ["LT"] = "lt-LT",
|
||||
["LV"] = "lv-LV", ["EE"] = "et-EE", ["IS"] = "is-IS", ["LU"] = "fr-LU", ["MT"] = "en-MT",
|
||||
["CY"] = "el-CY", ["MD"] = "ro-MD", ["BY"] = "ru-BY", ["GE"] = "ka-GE", ["AL"] = "sq-AL",
|
||||
["MK"] = "mk-MK", ["BA"] = "bs-BA",
|
||||
["PE"] = "es-PE", ["VE"] = "es-VE", ["EC"] = "es-EC", ["UY"] = "es-UY", ["CR"] = "es-CR",
|
||||
["DO"] = "es-DO", ["GT"] = "es-GT", ["BO"] = "es-BO", ["PY"] = "es-PY",
|
||||
["PK"] = "en-PK", ["BD"] = "bn-BD", ["LK"] = "si-LK", ["KZ"] = "ru-KZ", ["IR"] = "fa-IR",
|
||||
["IQ"] = "ar-IQ", ["JO"] = "ar-JO", ["LB"] = "ar-LB", ["KW"] = "ar-KW", ["QA"] = "ar-QA",
|
||||
["OM"] = "ar-OM", ["BH"] = "ar-BH",
|
||||
["NG"] = "en-NG", ["KE"] = "en-KE", ["MA"] = "fr-MA", ["DZ"] = "ar-DZ", ["TN"] = "ar-TN",
|
||||
["GH"] = "en-GH",
|
||||
["AM"] = "hy-AM", ["AZ"] = "az-AZ", ["UZ"] = "uz-UZ", ["KG"] = "ky-KG", ["TJ"] = "tg-TJ",
|
||||
["TM"] = "tk-TM",
|
||||
["ME"] = "sr-ME", ["XK"] = "sq-XK", ["LI"] = "de-LI", ["MC"] = "fr-MC", ["AD"] = "ca-AD",
|
||||
["MM"] = "my-MM", ["KH"] = "km-KH", ["LA"] = "lo-LA", ["MN"] = "mn-MN", ["BN"] = "ms-BN",
|
||||
["MO"] = "zh-MO",
|
||||
["YE"] = "ar-YE", ["SY"] = "ar-SY", ["PS"] = "ar-PS", ["LY"] = "ar-LY",
|
||||
["ET"] = "am-ET", ["TZ"] = "sw-TZ", ["UG"] = "en-UG", ["SN"] = "fr-SN", ["CI"] = "fr-CI",
|
||||
["CM"] = "fr-CM", ["AO"] = "pt-AO", ["MZ"] = "pt-MZ", ["ZM"] = "en-ZM", ["ZW"] = "en-ZW",
|
||||
["HN"] = "es-HN", ["NI"] = "es-NI", ["SV"] = "es-SV", ["PA"] = "es-PA", ["JM"] = "en-JM",
|
||||
["TT"] = "en-TT", ["PR"] = "es-PR",
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Resolve timezone and locale from a proxy's IP address.
|
||||
/// Returns (timezone, locale) - either or both may be null on failure. Never throws.
|
||||
/// </summary>
|
||||
public static async Task<(string? Timezone, string? Locale)> ResolveProxyGeoAsync(
|
||||
string? proxyUrl, CancellationToken ct = default)
|
||||
{
|
||||
var (tz, locale, _) = await ResolveProxyGeoWithIpAsync(proxyUrl, ct).ConfigureAwait(false);
|
||||
return (tz, locale);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolve timezone, locale, and exit IP from a proxy.
|
||||
/// The exit IP is a free bonus from the lookup - reused for WebRTC spoofing
|
||||
/// without an extra HTTP call. When <paramref name="proxyUrl"/> is null/empty,
|
||||
/// the machine's own public IP is used (echo services queried directly), so
|
||||
/// geoip works proxy-free too.
|
||||
/// </summary>
|
||||
public static async Task<(string? Timezone, string? Locale, string? ExitIp)> ResolveProxyGeoWithIpAsync(
|
||||
string? proxyUrl, CancellationToken ct = default)
|
||||
{
|
||||
// Ensure the DB first - the download must NOT be bounded by the resolution
|
||||
// timeout (a first-use ~70MB fetch legitimately outlasts it).
|
||||
var dbPath = await EnsureGeoIpDbAsync(ct).ConfigureAwait(false);
|
||||
|
||||
var timeout = GetGeoIpTimeoutSeconds();
|
||||
var deadline = DeadlineFromTimeout(timeout);
|
||||
|
||||
// Exit IP (through proxy, or the machine's own public IP when proxyUrl is
|
||||
// null/empty) is most accurate - gateway DNS may differ from exit. Resolved
|
||||
// even when the DB is unavailable: the IP does not need the DB, and dropping
|
||||
// it on a DB hiccup would let WebRTC fall back to the real IP behind a proxy
|
||||
// while the connection shows the proxy IP - a real deanonymization.
|
||||
var ip = await ResolveExitIpAsync(proxyUrl, RemainingSeconds(deadline), ct).ConfigureAwait(false);
|
||||
// Hostname fallback only applies to a proxy; no proxy -> echo services only.
|
||||
if (ip == null && !string.IsNullOrEmpty(proxyUrl) && !DeadlineExpired(deadline))
|
||||
ip = ResolveProxyIp(proxyUrl);
|
||||
if (ip == null || DeadlineExpired(deadline))
|
||||
{
|
||||
if (deadline != null && DeadlineExpired(deadline))
|
||||
CloakLog.Warning("GeoIP resolution timed out after {0:0.0}s; continuing without GeoIP", timeout);
|
||||
return (null, null, null);
|
||||
}
|
||||
|
||||
// DB only drives tz/locale; a missing/failed DB still returns the exit IP.
|
||||
if (dbPath == null)
|
||||
return (null, null, ip);
|
||||
|
||||
try
|
||||
{
|
||||
using var reader = new DatabaseReader(dbPath);
|
||||
var resp = reader.City(ip);
|
||||
var timezone = resp.Location?.TimeZone;
|
||||
var country = resp.Country?.IsoCode;
|
||||
string? locale = country != null && CountryLocaleMap.TryGetValue(country, out var l) ? l : null;
|
||||
CloakLog.Debug("GeoIP: {0} -> tz={1}, country={2}, locale={3}", ip, timezone, country, locale);
|
||||
return (timezone, locale, ip);
|
||||
}
|
||||
catch (Exception exc)
|
||||
{
|
||||
CloakLog.Warning("GeoIP lookup failed for {0}: {1}", ip, exc.Message);
|
||||
return (null, null, ip);
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Proxy IP resolution
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private static string? ResolveProxyIp(string proxyUrl)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!Uri.TryCreate(proxyUrl, UriKind.Absolute, out var uri))
|
||||
return null;
|
||||
var hostname = uri.Host;
|
||||
if (string.IsNullOrEmpty(hostname))
|
||||
return null;
|
||||
|
||||
// Already a literal IP?
|
||||
if (IPAddress.TryParse(hostname, out var literal))
|
||||
return literal.ToString();
|
||||
|
||||
// DNS resolve (returns first result, handles both v4/v6).
|
||||
var results = Dns.GetHostAddresses(hostname);
|
||||
if (results.Length > 0)
|
||||
{
|
||||
var ip = results[0].ToString();
|
||||
CloakLog.Debug("Resolved proxy {0} -> {1}", hostname, ip);
|
||||
return ip;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
catch (Exception exc)
|
||||
{
|
||||
CloakLog.Warning("Failed to resolve proxy hostname: {0}", exc.Message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Check if an IP address is private/internal (not routable on the internet).</summary>
|
||||
public static bool IsPrivateIp(string ip)
|
||||
{
|
||||
if (!IPAddress.TryParse(ip, out var addr)) return false;
|
||||
if (addr.AddressFamily == AddressFamily.InterNetwork)
|
||||
{
|
||||
var b = addr.GetAddressBytes();
|
||||
return b[0] == 10
|
||||
|| (b[0] == 172 && b[1] >= 16 && b[1] <= 31)
|
||||
|| (b[0] == 192 && b[1] == 168)
|
||||
|| b[0] == 127
|
||||
|| (b[0] == 169 && b[1] == 254);
|
||||
}
|
||||
return IPAddress.IsLoopback(addr) || addr.IsIPv6LinkLocal || addr.IsIPv6SiteLocal;
|
||||
}
|
||||
|
||||
private static double GetGeoIpTimeoutSeconds()
|
||||
{
|
||||
var raw = Environment.GetEnvironmentVariable(GeoIpTimeoutEnv);
|
||||
if (string.IsNullOrEmpty(raw)) return DefaultGeoIpTimeoutSeconds;
|
||||
if (!double.TryParse(raw, NumberStyles.Float, CultureInfo.InvariantCulture, out var timeout)
|
||||
|| double.IsNaN(timeout) || double.IsInfinity(timeout))
|
||||
{
|
||||
CloakLog.Warning("Invalid {0}={1}; using {2:0.0}s", GeoIpTimeoutEnv, raw, DefaultGeoIpTimeoutSeconds);
|
||||
return DefaultGeoIpTimeoutSeconds;
|
||||
}
|
||||
return Math.Max(timeout, 0.0);
|
||||
}
|
||||
|
||||
private static double? DeadlineFromTimeout(double timeout) =>
|
||||
timeout <= 0 ? null : Now() + timeout;
|
||||
|
||||
private static double? RemainingSeconds(double? deadline) =>
|
||||
deadline == null ? null : Math.Max(deadline.Value - Now(), 0.0);
|
||||
|
||||
private static bool DeadlineExpired(double? deadline) =>
|
||||
deadline != null && Now() >= deadline.Value;
|
||||
|
||||
private static double Now() =>
|
||||
System.Diagnostics.Stopwatch.GetTimestamp() / (double)System.Diagnostics.Stopwatch.Frequency;
|
||||
|
||||
/// <summary>
|
||||
/// Resolve the egress IP, bounded by the GeoIP timeout. With a proxy this is
|
||||
/// the proxy's exit IP; with no proxy it is the machine's own public IP
|
||||
/// (echo services queried directly).
|
||||
/// </summary>
|
||||
public static async Task<string?> ResolveProxyExitIpAsync(string? proxyUrl, CancellationToken ct = default)
|
||||
{
|
||||
var timeout = GetGeoIpTimeoutSeconds();
|
||||
var deadline = DeadlineFromTimeout(timeout);
|
||||
var ip = await ResolveExitIpAsync(proxyUrl, timeout, ct).ConfigureAwait(false);
|
||||
if (ip == null && DeadlineExpired(deadline))
|
||||
CloakLog.Warning("GeoIP resolution timed out after {0:0.0}s; continuing without GeoIP", timeout);
|
||||
return ip;
|
||||
}
|
||||
|
||||
private static async Task<string?> ResolveExitIpAsync(string? proxyUrl, double? timeout, CancellationToken ct)
|
||||
{
|
||||
var deadline = DeadlineFromTimeout(timeout ?? 0);
|
||||
var direct = string.IsNullOrEmpty(proxyUrl);
|
||||
|
||||
HttpClient client;
|
||||
try
|
||||
{
|
||||
if (direct)
|
||||
{
|
||||
// No proxy: query the echo services directly -> the machine's own public IP.
|
||||
client = new HttpClient();
|
||||
}
|
||||
else
|
||||
{
|
||||
var handler = new HttpClientHandler
|
||||
{
|
||||
Proxy = new WebProxy(NormalizeProxyForWebProxy(proxyUrl!)),
|
||||
UseProxy = true,
|
||||
};
|
||||
var creds = ExtractProxyCredentials(proxyUrl!);
|
||||
if (creds != null)
|
||||
handler.Proxy.Credentials = creds;
|
||||
client = new HttpClient(handler);
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
CloakLog.Warning("SOCKS5 proxy requires a SOCKS-capable transport; cannot resolve exit IP");
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
foreach (var url in IpEchoUrls)
|
||||
{
|
||||
try
|
||||
{
|
||||
var remaining = RemainingSeconds(deadline);
|
||||
if (remaining != null && remaining <= 0)
|
||||
return null;
|
||||
var requestTimeout = remaining != null
|
||||
? TimeSpan.FromSeconds(Math.Min(10.0, remaining.Value))
|
||||
: TimeSpan.FromSeconds(10.0);
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
cts.CancelAfter(requestTimeout);
|
||||
var resp = await client.GetAsync(url, cts.Token).ConfigureAwait(false);
|
||||
resp.EnsureSuccessStatusCode();
|
||||
var ip = (await resp.Content.ReadAsStringAsync(cts.Token).ConfigureAwait(false)).Trim();
|
||||
if (IPAddress.TryParse(ip, out _))
|
||||
{
|
||||
CloakLog.Debug("Exit IP via {0}: {1}", url, ip);
|
||||
return ip;
|
||||
}
|
||||
}
|
||||
catch (Exception) { /* try next */ }
|
||||
}
|
||||
CloakLog.Warning(direct ? "Failed to discover public IP" : "Failed to discover exit IP through proxy");
|
||||
return null;
|
||||
}
|
||||
finally
|
||||
{
|
||||
client.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private static string NormalizeProxyForWebProxy(string proxyUrl)
|
||||
{
|
||||
// WebProxy wants scheme://host:port without credentials.
|
||||
if (!Uri.TryCreate(proxyUrl.Contains("://") ? proxyUrl : "http://" + proxyUrl,
|
||||
UriKind.Absolute, out var uri))
|
||||
return proxyUrl;
|
||||
var builder = new UriBuilder(uri.Scheme, uri.Host, uri.Port) { Path = uri.AbsolutePath };
|
||||
return builder.Uri.ToString();
|
||||
}
|
||||
|
||||
private static NetworkCredential? ExtractProxyCredentials(string proxyUrl)
|
||||
{
|
||||
if (!Uri.TryCreate(proxyUrl.Contains("://") ? proxyUrl : "http://" + proxyUrl,
|
||||
UriKind.Absolute, out var uri))
|
||||
return null;
|
||||
if (string.IsNullOrEmpty(uri.UserInfo))
|
||||
return null;
|
||||
var parts = uri.UserInfo.Split(':', 2);
|
||||
var user = Uri.UnescapeDataString(parts[0]);
|
||||
var pass = parts.Length > 1 ? Uri.UnescapeDataString(parts[1]) : "";
|
||||
return new NetworkCredential(user, pass);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// GeoIP database management
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private static string GetGeoIpDir() => Path.Combine(Config.GetCacheDir(), "geoip");
|
||||
|
||||
private static async Task<string?> EnsureGeoIpDbAsync(CancellationToken ct)
|
||||
{
|
||||
var dbPath = Path.Combine(GetGeoIpDir(), GeoIpDbFilename);
|
||||
|
||||
if (File.Exists(dbPath))
|
||||
{
|
||||
MaybeTriggerUpdate(dbPath);
|
||||
return dbPath;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await DownloadGeoIpDbAsync(dbPath, ct).ConfigureAwait(false);
|
||||
return dbPath;
|
||||
}
|
||||
catch (Exception exc)
|
||||
{
|
||||
CloakLog.Warning("Failed to download GeoIP database: {0}", exc.Message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task DownloadGeoIpDbAsync(string dest, CancellationToken ct)
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(dest)!);
|
||||
CloakLog.Info("Downloading GeoIP database (~70 MB) ...");
|
||||
|
||||
var tmpPath = dest + "." + Guid.NewGuid().ToString("N") + ".tmp";
|
||||
using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(300) };
|
||||
try
|
||||
{
|
||||
using var resp = await client.GetAsync(GeoIpDbUrl, HttpCompletionOption.ResponseHeadersRead, ct)
|
||||
.ConfigureAwait(false);
|
||||
resp.EnsureSuccessStatusCode();
|
||||
long total = resp.Content.Headers.ContentLength ?? 0;
|
||||
long downloaded = 0;
|
||||
int lastPct = -1;
|
||||
|
||||
await using (var src = await resp.Content.ReadAsStreamAsync(ct).ConfigureAwait(false))
|
||||
await using (var fs = new FileStream(tmpPath, FileMode.Create, FileAccess.Write, FileShare.None))
|
||||
{
|
||||
var buffer = new byte[65_536];
|
||||
int read;
|
||||
while ((read = await src.ReadAsync(buffer, ct).ConfigureAwait(false)) > 0)
|
||||
{
|
||||
await fs.WriteAsync(buffer.AsMemory(0, read), ct).ConfigureAwait(false);
|
||||
downloaded += read;
|
||||
if (total > 0)
|
||||
{
|
||||
int pct = (int)(downloaded * 100 / total);
|
||||
if (pct >= lastPct + 10)
|
||||
{
|
||||
lastPct = pct;
|
||||
CloakLog.Info("GeoIP download: {0} %", pct);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (File.Exists(dest)) File.Delete(dest);
|
||||
File.Move(tmpPath, dest);
|
||||
CloakLog.Info("GeoIP database ready: {0}", dest);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
try { if (File.Exists(tmpPath)) File.Delete(tmpPath); } catch (IOException) { }
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private static void MaybeTriggerUpdate(string dbPath)
|
||||
{
|
||||
try
|
||||
{
|
||||
var age = (DateTime.UtcNow - File.GetLastWriteTimeUtc(dbPath)).TotalSeconds;
|
||||
if (age < GeoIpUpdateInterval)
|
||||
return;
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try { await DownloadGeoIpDbAsync(dbPath, CancellationToken.None).ConfigureAwait(false); }
|
||||
catch (Exception) { CloakLog.Debug("Background GeoIP update failed"); }
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
using CloakBrowser.Human;
|
||||
using CloakBrowser.Wrappers;
|
||||
using Microsoft.Playwright;
|
||||
|
||||
namespace CloakBrowser;
|
||||
|
||||
/// <summary>
|
||||
/// A launched stealth browser. Owns the underlying Playwright instance and disposes
|
||||
/// it on <see cref="CloseAsync"/>. Use <see cref="Browser"/> for the raw Playwright API.
|
||||
/// </summary>
|
||||
public sealed class CloakBrowserHandle : IAsyncDisposable
|
||||
{
|
||||
private readonly IPlaywright _playwright;
|
||||
private readonly bool _humanize;
|
||||
private readonly HumanConfig? _humanCfg;
|
||||
private readonly IBrowser _rawBrowser;
|
||||
private readonly bool _headless;
|
||||
private readonly bool _headlessNoViewport;
|
||||
|
||||
/// <summary>
|
||||
/// The Playwright browser. When humanize is enabled this is a transparent
|
||||
/// humanizing wrapper: every page/context it produces uses human-like mouse,
|
||||
/// keyboard, and scrolling automatically while exposing the full standard
|
||||
/// <see cref="IBrowser"/> API. Use <see cref="RawBrowser"/> for the un-wrapped
|
||||
/// browser.
|
||||
/// </summary>
|
||||
public IBrowser Browser { get; }
|
||||
|
||||
/// <summary>The original, un-humanized Playwright browser (escape hatch).</summary>
|
||||
public IBrowser RawBrowser => _rawBrowser;
|
||||
|
||||
/// <summary>The owning Playwright instance (used internally to transfer ownership to a context handle).</summary>
|
||||
internal IPlaywright PlaywrightInstance => _playwright;
|
||||
|
||||
internal CloakBrowserHandle(IPlaywright playwright, IBrowser browser, bool humanize, HumanConfig? humanCfg,
|
||||
bool headless = true, bool headlessNoViewport = false)
|
||||
{
|
||||
_playwright = playwright;
|
||||
_rawBrowser = browser;
|
||||
_humanize = humanize;
|
||||
_humanCfg = humanCfg;
|
||||
_headless = headless;
|
||||
_headlessNoViewport = headlessNoViewport;
|
||||
// Wrap the whole browser so the entire object graph (contexts, pages, mice,
|
||||
// keyboards, locators, frames) is humanized transparently. The wrapper is
|
||||
// headless-aware so the headed no-viewport default also applies when pages are
|
||||
// created through the humanized browser (parity with Python's _default_no_viewport,
|
||||
// which patches the raw browser so the default holds on every path).
|
||||
Browser = humanize
|
||||
? Wrappers.Humanize.Browser(browser, humanCfg ?? new HumanConfig(), headless, headlessNoViewport)
|
||||
: browser;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// On headed launches, default a page/context that the caller didn't give an explicit
|
||||
/// viewport to <c>NoViewport</c> so it tracks the real OS window. Delegates to the
|
||||
/// shared <see cref="ViewportDefaults"/> (the single source of truth shared with the
|
||||
/// humanize wrapper). Port of Python <c>_default_no_viewport</c>.
|
||||
/// </summary>
|
||||
private BrowserNewPageOptions ApplyDefaultNoViewport(BrowserNewPageOptions? options) =>
|
||||
ViewportDefaults.ApplyHeadedNoViewport(options, _headless, _headlessNoViewport);
|
||||
|
||||
private BrowserNewContextOptions ApplyDefaultNoViewport(BrowserNewContextOptions? options) =>
|
||||
ViewportDefaults.ApplyHeadedNoViewport(options, _headless, _headlessNoViewport);
|
||||
|
||||
/// <summary>
|
||||
/// Create a new browser context. On headed launches without an explicit viewport,
|
||||
/// defaults to <c>NoViewport</c> so the page tracks the real window (see
|
||||
/// <see cref="NewPageAsync"/>).
|
||||
/// </summary>
|
||||
public Task<IBrowserContext> NewContextAsync(BrowserNewContextOptions? options = null) =>
|
||||
Browser.NewContextAsync(ApplyDefaultNoViewport(options));
|
||||
|
||||
/// <summary>
|
||||
/// Create a new page. When humanize is enabled the returned <see cref="IPage"/> is a
|
||||
/// transparent humanizing wrapper - your standard Playwright calls
|
||||
/// (<c>page.ClickAsync</c>, <c>page.FillAsync</c>, <c>page.Mouse.MoveAsync</c>, ...)
|
||||
/// are automatically humanized.
|
||||
/// </summary>
|
||||
public Task<IPage> NewPageAsync(BrowserNewPageOptions? options = null) =>
|
||||
Browser.NewPageAsync(ApplyDefaultNoViewport(options));
|
||||
|
||||
/// <summary>
|
||||
/// Create a new page wrapped in an explicit <see cref="HumanPage"/> with this
|
||||
/// browser's humanize config. Prefer <see cref="NewPageAsync"/> for transparent
|
||||
/// humanization; this is the explicit-wrapper API kept for advanced control.
|
||||
/// </summary>
|
||||
public async Task<HumanPage> NewHumanPageAsync(BrowserNewPageOptions? options = null)
|
||||
{
|
||||
// Use the raw browser so we don't build a HumanPage over an already-wrapped page.
|
||||
var page = await _rawBrowser.NewPageAsync(ApplyDefaultNoViewport(options)).ConfigureAwait(false);
|
||||
return await HumanPage.CreateAsync(page, _humanCfg ?? new HumanConfig()).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>Whether the humanize layer is enabled for this browser.</summary>
|
||||
public bool HumanizeEnabled => _humanize;
|
||||
|
||||
/// <summary>Close the browser and stop the underlying Playwright instance.</summary>
|
||||
public async Task CloseAsync()
|
||||
{
|
||||
try { await _rawBrowser.CloseAsync().ConfigureAwait(false); }
|
||||
finally { _playwright.Dispose(); }
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async ValueTask DisposeAsync() => await CloseAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A launched stealth browser context. Owns the underlying Playwright instance (and,
|
||||
/// for non-persistent contexts, the browser) and cleans them up on <see cref="CloseAsync"/>.
|
||||
/// </summary>
|
||||
public sealed class CloakContextHandle : IAsyncDisposable
|
||||
{
|
||||
private readonly IPlaywright _playwright;
|
||||
private readonly IBrowser? _browser; // null for persistent contexts
|
||||
private readonly bool _humanize;
|
||||
private readonly HumanConfig? _humanCfg;
|
||||
private readonly IBrowserContext _rawContext;
|
||||
|
||||
/// <summary>
|
||||
/// The Playwright browser context. When humanize is enabled this is a transparent
|
||||
/// humanizing wrapper: every page it produces uses human-like input automatically
|
||||
/// while exposing the full standard <see cref="IBrowserContext"/> API. Use
|
||||
/// <see cref="RawContext"/> for the un-wrapped context.
|
||||
/// </summary>
|
||||
public IBrowserContext Context { get; }
|
||||
|
||||
/// <summary>The original, un-humanized Playwright context (escape hatch).</summary>
|
||||
public IBrowserContext RawContext => _rawContext;
|
||||
|
||||
internal CloakContextHandle(IPlaywright playwright, IBrowser? browser, IBrowserContext context,
|
||||
bool humanize, HumanConfig? humanCfg)
|
||||
{
|
||||
_playwright = playwright;
|
||||
_browser = browser;
|
||||
_rawContext = context;
|
||||
_humanize = humanize;
|
||||
_humanCfg = humanCfg;
|
||||
Context = humanize ? Wrappers.Humanize.Context(context, humanCfg ?? new HumanConfig()) : context;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create a new page. When humanize is enabled the returned <see cref="IPage"/> is a
|
||||
/// transparent humanizing wrapper - standard Playwright calls are auto-humanized.
|
||||
/// </summary>
|
||||
public Task<IPage> NewPageAsync() => Context.NewPageAsync();
|
||||
|
||||
/// <summary>
|
||||
/// Create a new page wrapped in an explicit <see cref="HumanPage"/> with this
|
||||
/// context's humanize config. Prefer <see cref="NewPageAsync"/> for transparent
|
||||
/// humanization.
|
||||
/// </summary>
|
||||
public async Task<HumanPage> NewHumanPageAsync()
|
||||
{
|
||||
var page = await _rawContext.NewPageAsync().ConfigureAwait(false);
|
||||
return await HumanPage.CreateAsync(page, _humanCfg ?? new HumanConfig()).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>Whether the humanize layer is enabled for this context.</summary>
|
||||
public bool HumanizeEnabled => _humanize;
|
||||
|
||||
/// <summary>Close the context (and browser, if owned) and stop Playwright.</summary>
|
||||
public async Task CloseAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
await _rawContext.CloseAsync().ConfigureAwait(false);
|
||||
if (_browser != null)
|
||||
await _browser.CloseAsync().ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_playwright.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async ValueTask DisposeAsync() => await CloseAsync().ConfigureAwait(false);
|
||||
}
|
||||
@@ -0,0 +1,449 @@
|
||||
using Microsoft.Playwright;
|
||||
|
||||
namespace CloakBrowser.Human;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Error hierarchy
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// <summary>Base for all actionability failures. Mirrors Python <c>ActionabilityError</c>.</summary>
|
||||
public class ActionabilityError : Exception
|
||||
{
|
||||
/// <summary>The selector or label of the element that failed.</summary>
|
||||
public string Selector { get; }
|
||||
|
||||
/// <summary>The name of the check that failed (attached/visible/stable/...).</summary>
|
||||
public string Check { get; }
|
||||
|
||||
public ActionabilityError(string selector, string check, string message)
|
||||
: base($"Element '{selector}' failed {check} check: {message}")
|
||||
{
|
||||
Selector = selector;
|
||||
Check = check;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The element was never attached to the DOM.</summary>
|
||||
public sealed class ElementNotAttachedError : ActionabilityError
|
||||
{
|
||||
public ElementNotAttachedError(string selector)
|
||||
: base(selector, "attached", "element not found in DOM") { }
|
||||
}
|
||||
|
||||
/// <summary>The element is present but not visible.</summary>
|
||||
public sealed class ElementNotVisibleError : ActionabilityError
|
||||
{
|
||||
public ElementNotVisibleError(string selector)
|
||||
: base(selector, "visible", "element is not visible") { }
|
||||
}
|
||||
|
||||
/// <summary>The element's bounding box keeps moving.</summary>
|
||||
public sealed class ElementNotStableError : ActionabilityError
|
||||
{
|
||||
public ElementNotStableError(string selector)
|
||||
: base(selector, "stable", "element position is still changing") { }
|
||||
}
|
||||
|
||||
/// <summary>The element is disabled.</summary>
|
||||
public sealed class ElementNotEnabledError : ActionabilityError
|
||||
{
|
||||
public ElementNotEnabledError(string selector)
|
||||
: base(selector, "enabled", "element is disabled") { }
|
||||
}
|
||||
|
||||
/// <summary>The element is not editable.</summary>
|
||||
public sealed class ElementNotEditableError : ActionabilityError
|
||||
{
|
||||
public ElementNotEditableError(string selector)
|
||||
: base(selector, "editable", "element is not editable") { }
|
||||
}
|
||||
|
||||
/// <summary>The element is covered by another element at the click point.</summary>
|
||||
public sealed class ElementNotReceivingEventsError : ActionabilityError
|
||||
{
|
||||
public ElementNotReceivingEventsError(string selector, string coveringTag = "unknown")
|
||||
: base(selector, "pointer_events", $"element is covered by <{coveringTag}>") { }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Checks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Playwright-style actionability checks for the humanize layer.
|
||||
/// Direct port of Python <c>cloakbrowser/human/actionability.py</c>.
|
||||
/// Checks: attached, visible, stable, enabled, editable, receives pointer events.
|
||||
/// Retry loop with backoff matching Playwright internals: [100, 250, 500, 1000]ms.
|
||||
/// </summary>
|
||||
public static class Actionability
|
||||
{
|
||||
/// <summary>Checks for a click action.</summary>
|
||||
public static readonly IReadOnlySet<string> ChecksClick =
|
||||
new HashSet<string> { "attached", "visible", "enabled", "pointer_events" };
|
||||
|
||||
/// <summary>Checks for a hover action.</summary>
|
||||
public static readonly IReadOnlySet<string> ChecksHover =
|
||||
new HashSet<string> { "attached", "visible", "pointer_events" };
|
||||
|
||||
/// <summary>Checks for a text-input action.</summary>
|
||||
public static readonly IReadOnlySet<string> ChecksInput =
|
||||
new HashSet<string> { "attached", "visible", "enabled", "editable", "pointer_events" };
|
||||
|
||||
/// <summary>Checks for a focus action.</summary>
|
||||
public static readonly IReadOnlySet<string> ChecksFocus =
|
||||
new HashSet<string> { "attached", "visible", "enabled" };
|
||||
|
||||
/// <summary>Checks for a check/uncheck action.</summary>
|
||||
public static readonly IReadOnlySet<string> ChecksCheck =
|
||||
new HashSet<string> { "attached", "visible", "enabled", "pointer_events" };
|
||||
|
||||
private static readonly int[] BackoffMs = { 100, 250, 500, 1000 };
|
||||
|
||||
private static Task BackoffSleepAsync(int attempt)
|
||||
{
|
||||
int idx = Math.Min(attempt, BackoffMs.Length - 1);
|
||||
return Task.Delay(BackoffMs[idx]);
|
||||
}
|
||||
|
||||
private static double NowMs() => Environment.TickCount64;
|
||||
|
||||
/// <summary>
|
||||
/// Milliseconds left until <paramref name="deadline"/> (an <see cref="Environment.TickCount64"/>
|
||||
/// timestamp), clamped at zero. Sequential operations share one deadline so the total
|
||||
/// timeout budget is never multiplied (issue #307). Never returns a negative value.
|
||||
/// </summary>
|
||||
internal static double RemainingMs(double deadline) => Math.Max(0, deadline - NowMs());
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Pre-scroll actionability: attached, visible, enabled, editable
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Wait for the element to pass actionability checks (pre-scroll). Retries
|
||||
/// with backoff until <paramref name="timeoutMs"/> elapsed. Throws a specific
|
||||
/// <see cref="ActionabilityError"/> subclass on failure. Returns immediately
|
||||
/// when <paramref name="force"/> is true.
|
||||
/// </summary>
|
||||
public static async Task EnsureActionableAsync(
|
||||
IPage page,
|
||||
string selector,
|
||||
IReadOnlySet<string> checks,
|
||||
double timeoutMs = 30000,
|
||||
bool force = false)
|
||||
{
|
||||
if (force)
|
||||
return;
|
||||
|
||||
double deadline = NowMs() + timeoutMs;
|
||||
int attempt = 0;
|
||||
ActionabilityError? lastError = null;
|
||||
|
||||
while (true)
|
||||
{
|
||||
double remainingMs = Math.Max(0, deadline - NowMs());
|
||||
if (remainingMs <= 0)
|
||||
{
|
||||
if (lastError != null)
|
||||
throw lastError;
|
||||
throw new ActionabilityError(selector, "timeout", "timeout expired before first check");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var loc = page.Locator(selector).First;
|
||||
|
||||
if (checks.Contains("attached"))
|
||||
{
|
||||
try
|
||||
{
|
||||
await loc.WaitForAsync(new LocatorWaitForOptions
|
||||
{
|
||||
State = WaitForSelectorState.Attached,
|
||||
Timeout = (float)Math.Max(1, Math.Min(remainingMs, 2000)),
|
||||
}).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception) { throw new ElementNotAttachedError(selector); }
|
||||
}
|
||||
|
||||
if (checks.Contains("visible") && !await loc.IsVisibleAsync().ConfigureAwait(false))
|
||||
throw new ElementNotVisibleError(selector);
|
||||
|
||||
if (checks.Contains("enabled") && !await loc.IsEnabledAsync().ConfigureAwait(false))
|
||||
throw new ElementNotEnabledError(selector);
|
||||
|
||||
if (checks.Contains("editable") && !await loc.IsEditableAsync().ConfigureAwait(false))
|
||||
throw new ElementNotEditableError(selector);
|
||||
|
||||
return;
|
||||
}
|
||||
catch (ActionabilityError e)
|
||||
{
|
||||
lastError = e;
|
||||
if (NowMs() >= deadline)
|
||||
throw;
|
||||
await BackoffSleepAsync(attempt).ConfigureAwait(false);
|
||||
attempt++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Post-scroll stability check
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private static bool BoxesDiffer(LocatorBoundingBoxResult a, LocatorBoundingBoxResult b) =>
|
||||
Math.Abs(a.X - b.X) > 1
|
||||
|| Math.Abs(a.Y - b.Y) > 1
|
||||
|| Math.Abs(a.Width - b.Width) > 1
|
||||
|| Math.Abs(a.Height - b.Height) > 1;
|
||||
|
||||
/// <summary>
|
||||
/// Wait for the element's position to stabilize (two samples 100ms apart).
|
||||
/// Only call after a scroll - skip if the element was already in the viewport.
|
||||
/// </summary>
|
||||
public static async Task EnsureStableAsync(IPage page, string selector, double timeoutMs = 5000)
|
||||
{
|
||||
double deadline = NowMs() + timeoutMs;
|
||||
int attempt = 0;
|
||||
|
||||
while (true)
|
||||
{
|
||||
double remainingMs = Math.Max(0, deadline - NowMs());
|
||||
if (remainingMs <= 0)
|
||||
throw new ElementNotStableError(selector);
|
||||
|
||||
var loc = page.Locator(selector).First;
|
||||
var box1 = await loc.BoundingBoxAsync(new LocatorBoundingBoxOptions
|
||||
{
|
||||
Timeout = (float)Math.Max(1, Math.Min(remainingMs, 1000)),
|
||||
}).ConfigureAwait(false);
|
||||
if (box1 == null)
|
||||
throw new ElementNotAttachedError(selector);
|
||||
|
||||
await Task.Delay(100).ConfigureAwait(false);
|
||||
|
||||
var box2 = await loc.BoundingBoxAsync(new LocatorBoundingBoxOptions
|
||||
{
|
||||
Timeout = (float)Math.Max(1, Math.Min(remainingMs, 1000)),
|
||||
}).ConfigureAwait(false);
|
||||
if (box2 == null)
|
||||
throw new ElementNotAttachedError(selector);
|
||||
|
||||
if (!BoxesDiffer(box1, box2))
|
||||
return;
|
||||
|
||||
if (NowMs() >= deadline)
|
||||
throw new ElementNotStableError(selector);
|
||||
|
||||
await BackoffSleepAsync(attempt).ConfigureAwait(false);
|
||||
attempt++;
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Pointer-events check (post-scroll, at actual click coordinates)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
// data.box is page-space (from boundingBox); rect is frame-local. Their delta
|
||||
// is the iframe offset, needed to map page-space click coords into the frame's
|
||||
// own viewport before elementFromPoint. For main-frame elements the offset is 0.
|
||||
internal const string PointerEventsJs = @"(expected, data) => {
|
||||
const rect = expected.getBoundingClientRect();
|
||||
const frameOffsetX = data.box ? data.box.x - rect.x : 0;
|
||||
const frameOffsetY = data.box ? data.box.y - rect.y : 0;
|
||||
const target = document.elementFromPoint(data.x - frameOffsetX, data.y - frameOffsetY);
|
||||
if (!target) return { hit: false, reason: 'no_element_at_point', covering: 'none' };
|
||||
let node = target;
|
||||
while (node) { if (node === expected) return { hit: true }; node = node.parentNode; }
|
||||
if (expected.contains(target)) return { hit: true };
|
||||
return { hit: false, reason: 'covered', covering: target.tagName || 'unknown' };
|
||||
}";
|
||||
|
||||
/// <summary>
|
||||
/// Result of the <c>elementFromPoint</c> pointer-events probe. Internal (not private)
|
||||
/// so unit tests can construct a "covered" result without a live browser.
|
||||
/// </summary>
|
||||
internal sealed class PointerResult
|
||||
{
|
||||
public bool Hit { get; set; }
|
||||
public string? Reason { get; set; }
|
||||
public string? Covering { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Check that <c>elementFromPoint(x, y)</c> hits the expected element. Uses
|
||||
/// <c>locator.evaluate()</c> so all Playwright selector types work. Retries
|
||||
/// with backoff for transient overlays. Fails open when the result can't be
|
||||
/// determined.
|
||||
/// </summary>
|
||||
public static async Task CheckPointerEventsAsync(
|
||||
IPage page,
|
||||
string selector,
|
||||
double x,
|
||||
double y,
|
||||
double timeoutMs = 5000)
|
||||
{
|
||||
double deadline = NowMs() + timeoutMs;
|
||||
int attempt = 0;
|
||||
|
||||
while (true)
|
||||
{
|
||||
PointerResult? result = null;
|
||||
try
|
||||
{
|
||||
var loc = page.Locator(selector).First;
|
||||
var box = await loc.BoundingBoxAsync(new LocatorBoundingBoxOptions
|
||||
{
|
||||
Timeout = (float)Math.Max(1, Math.Min(deadline - NowMs(), 1000)),
|
||||
}).ConfigureAwait(false);
|
||||
var data = new
|
||||
{
|
||||
x,
|
||||
y,
|
||||
box = box == null ? null : new { x = box.X, y = box.Y, width = box.Width, height = box.Height },
|
||||
};
|
||||
result = await loc.EvaluateAsync<PointerResult?>(PointerEventsJs, data).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception exc)
|
||||
{
|
||||
CloakLog.Debug($"pointer_events check failed for '{selector}': {exc.Message}");
|
||||
result = null;
|
||||
}
|
||||
|
||||
// Proceed if the check confirms a hit, or if it could not be determined
|
||||
// (null) - failing closed would block legitimate clicks.
|
||||
if (result == null || result.Hit)
|
||||
return;
|
||||
|
||||
string covering = result.Covering ?? "unknown";
|
||||
|
||||
if (NowMs() >= deadline)
|
||||
throw new ElementNotReceivingEventsError(selector, covering);
|
||||
|
||||
await BackoffSleepAsync(attempt).ConfigureAwait(false);
|
||||
attempt++;
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// ElementHandle variants
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <summary>Actionability checks for an <see cref="IElementHandle"/> (no selector needed).</summary>
|
||||
public static async Task EnsureActionableHandleAsync(
|
||||
IElementHandle el,
|
||||
IReadOnlySet<string> checks,
|
||||
double timeoutMs = 30000,
|
||||
bool force = false)
|
||||
{
|
||||
if (force)
|
||||
return;
|
||||
|
||||
double deadline = NowMs() + timeoutMs;
|
||||
int attempt = 0;
|
||||
ActionabilityError? lastError = null;
|
||||
const string label = "<ElementHandle>";
|
||||
|
||||
while (true)
|
||||
{
|
||||
double remainingMs = Math.Max(0, deadline - NowMs());
|
||||
if (remainingMs <= 0)
|
||||
{
|
||||
if (lastError != null)
|
||||
throw lastError;
|
||||
throw new ActionabilityError(label, "timeout", "timeout expired before first check");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (checks.Contains("visible"))
|
||||
{
|
||||
try
|
||||
{
|
||||
await el.WaitForElementStateAsync(ElementState.Visible, new ElementHandleWaitForElementStateOptions
|
||||
{
|
||||
Timeout = (float)Math.Max(1, Math.Min(remainingMs, 2000)),
|
||||
}).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception) { throw new ElementNotVisibleError(label); }
|
||||
}
|
||||
|
||||
if (checks.Contains("enabled"))
|
||||
{
|
||||
try
|
||||
{
|
||||
await el.WaitForElementStateAsync(ElementState.Enabled, new ElementHandleWaitForElementStateOptions
|
||||
{
|
||||
Timeout = (float)Math.Max(1, Math.Min(remainingMs, 2000)),
|
||||
}).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception) { throw new ElementNotEnabledError(label); }
|
||||
}
|
||||
|
||||
if (checks.Contains("editable"))
|
||||
{
|
||||
try
|
||||
{
|
||||
await el.WaitForElementStateAsync(ElementState.Editable, new ElementHandleWaitForElementStateOptions
|
||||
{
|
||||
Timeout = (float)Math.Max(1, Math.Min(remainingMs, 2000)),
|
||||
}).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception) { throw new ElementNotEditableError(label); }
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
catch (ActionabilityError e)
|
||||
{
|
||||
lastError = e;
|
||||
if (NowMs() >= deadline)
|
||||
throw;
|
||||
await BackoffSleepAsync(attempt).ConfigureAwait(false);
|
||||
attempt++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Pointer-events check for an <see cref="IElementHandle"/>.</summary>
|
||||
public static async Task CheckPointerEventsHandleAsync(
|
||||
IElementHandle el,
|
||||
double x,
|
||||
double y,
|
||||
double timeoutMs = 5000)
|
||||
{
|
||||
double deadline = NowMs() + timeoutMs;
|
||||
int attempt = 0;
|
||||
|
||||
while (true)
|
||||
{
|
||||
PointerResult? result = null;
|
||||
try
|
||||
{
|
||||
var box = await el.BoundingBoxAsync().ConfigureAwait(false);
|
||||
var data = new
|
||||
{
|
||||
x,
|
||||
y,
|
||||
box = box == null ? null : new { x = box.X, y = box.Y, width = box.Width, height = box.Height },
|
||||
};
|
||||
result = await el.EvaluateAsync<PointerResult?>(PointerEventsJs, data).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
result = null;
|
||||
}
|
||||
|
||||
if (result == null || result.Hit)
|
||||
return;
|
||||
|
||||
string covering = result.Covering ?? "unknown";
|
||||
|
||||
if (NowMs() >= deadline)
|
||||
throw new ElementNotReceivingEventsError("<ElementHandle>", covering);
|
||||
|
||||
await BackoffSleepAsync(attempt).ConfigureAwait(false);
|
||||
attempt++;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
namespace CloakBrowser.Human;
|
||||
|
||||
/// <summary>A (min, max) inclusive numeric range, mirroring Python's <c>Range = Tuple[float, float]</c>.</summary>
|
||||
public readonly record struct Range(double Min, double Max)
|
||||
{
|
||||
public static implicit operator Range((double Min, double Max) t) => new(t.Min, t.Max);
|
||||
}
|
||||
|
||||
/// <summary>Humanize behavior preset names.</summary>
|
||||
public enum HumanPreset
|
||||
{
|
||||
Default,
|
||||
Careful,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// All tunable parameters for human-like behavior.
|
||||
/// Direct port of Python <c>cloakbrowser/human/config.py</c> (the <c>HumanConfig</c> dataclass).
|
||||
/// Property names match the Python field names (snake_case keys are accepted by
|
||||
/// <see cref="HumanConfigExtensions.With(HumanConfig, IReadOnlyDictionary{string, object})"/>).
|
||||
/// </summary>
|
||||
public sealed class HumanConfig
|
||||
{
|
||||
// Keyboard
|
||||
public double TypingDelay { get; set; } = 70;
|
||||
public double TypingDelaySpread { get; set; } = 40;
|
||||
public double TypingPauseChance { get; set; } = 0.1;
|
||||
public Range TypingPauseRange { get; set; } = (400, 1000);
|
||||
public Range ShiftDownDelay { get; set; } = (30, 70);
|
||||
public Range ShiftUpDelay { get; set; } = (20, 50);
|
||||
public Range KeyHold { get; set; } = (15, 35);
|
||||
|
||||
// Mistype (typo simulation)
|
||||
public double MistypeChance { get; set; } = 0.02;
|
||||
public Range MistypeDelayNotice { get; set; } = (100, 300);
|
||||
public Range MistypeDelayCorrect { get; set; } = (50, 150);
|
||||
|
||||
public Range FieldSwitchDelay { get; set; } = (800, 1500);
|
||||
|
||||
// Mouse - movement
|
||||
public double MouseStepsDivisor { get; set; } = 8;
|
||||
public int MouseMinSteps { get; set; } = 25;
|
||||
public int MouseMaxSteps { get; set; } = 80;
|
||||
public double MouseWobbleMax { get; set; } = 1.5;
|
||||
public double MouseOvershootChance { get; set; } = 0.15;
|
||||
public Range MouseOvershootPx { get; set; } = (3, 6);
|
||||
public Range MouseBurstSize { get; set; } = (3, 5);
|
||||
public Range MouseBurstPause { get; set; } = (8, 18);
|
||||
|
||||
// Mouse - clicks
|
||||
public Range ClickAimDelayInput { get; set; } = (60, 140);
|
||||
public Range ClickAimDelayButton { get; set; } = (80, 200);
|
||||
public Range ClickHoldInput { get; set; } = (40, 100);
|
||||
public Range ClickHoldButton { get; set; } = (60, 150);
|
||||
public Range ClickInputXRange { get; set; } = (0.05, 0.30);
|
||||
|
||||
// Mouse - idle
|
||||
public double IdleDriftPx { get; set; } = 3;
|
||||
public Range IdlePauseRange { get; set; } = (300, 1000);
|
||||
|
||||
// Scroll
|
||||
public Range ScrollDeltaBase { get; set; } = (80, 130);
|
||||
public double ScrollDeltaVariance { get; set; } = 0.2;
|
||||
public Range ScrollPauseFast { get; set; } = (30, 80);
|
||||
public Range ScrollPauseSlow { get; set; } = (80, 200);
|
||||
public Range ScrollAccelSteps { get; set; } = (2, 3);
|
||||
public Range ScrollDecelSteps { get; set; } = (2, 3);
|
||||
public double ScrollOvershootChance { get; set; } = 0.1;
|
||||
public Range ScrollOvershootPx { get; set; } = (50, 150);
|
||||
public Range ScrollSettleDelay { get; set; } = (300, 600);
|
||||
public Range ScrollTargetZone { get; set; } = (0.20, 0.80);
|
||||
public Range ScrollPreMoveDelay { get; set; } = (100, 300);
|
||||
|
||||
// Initial cursor position (as if coming from the address bar area)
|
||||
public Range InitialCursorX { get; set; } = (400, 700);
|
||||
public Range InitialCursorY { get; set; } = (45, 60);
|
||||
|
||||
// Idle micro-movements between actions (opt-in, adds latency)
|
||||
public bool IdleBetweenActions { get; set; } = false;
|
||||
public Range IdleBetweenDuration { get; set; } = (0.3, 0.8);
|
||||
|
||||
/// <summary>Create a shallow copy of this config.</summary>
|
||||
public HumanConfig Clone() => (HumanConfig)MemberwiseClone();
|
||||
}
|
||||
|
||||
/// <summary>Resolution and merging helpers for <see cref="HumanConfig"/>.</summary>
|
||||
public static class HumanConfigFactory
|
||||
{
|
||||
private static HumanConfig CarefulConfig() => new()
|
||||
{
|
||||
// Keyboard - slower typing
|
||||
TypingDelay = 100,
|
||||
TypingDelaySpread = 50,
|
||||
TypingPauseChance = 0.15,
|
||||
TypingPauseRange = (500, 1200),
|
||||
ShiftDownDelay = (40, 90),
|
||||
ShiftUpDelay = (30, 70),
|
||||
KeyHold = (20, 45),
|
||||
FieldSwitchDelay = (1000, 2000),
|
||||
// Mouse - slower, more precise
|
||||
MouseOvershootChance = 0.10,
|
||||
MouseBurstPause = (12, 25),
|
||||
// Mouse - clicks (longer aiming and holding)
|
||||
ClickAimDelayInput = (80, 180),
|
||||
ClickAimDelayButton = (120, 280),
|
||||
ClickHoldInput = (60, 140),
|
||||
ClickHoldButton = (80, 200),
|
||||
// Scroll - slower
|
||||
ScrollPauseFast = (100, 200),
|
||||
ScrollPauseSlow = (250, 600),
|
||||
ScrollSettleDelay = (400, 800),
|
||||
ScrollPreMoveDelay = (150, 400),
|
||||
// Idle between actions enabled for careful preset
|
||||
IdleBetweenActions = true,
|
||||
IdleBetweenDuration = (0.4, 1.0),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Resolve a preset name + optional overrides into a full <see cref="HumanConfig"/>.
|
||||
/// </summary>
|
||||
public static HumanConfig Resolve(
|
||||
HumanPreset preset = HumanPreset.Default,
|
||||
IReadOnlyDictionary<string, object>? overrides = null)
|
||||
{
|
||||
var baseCfg = preset switch
|
||||
{
|
||||
HumanPreset.Default => new HumanConfig(),
|
||||
HumanPreset.Careful => CarefulConfig(),
|
||||
_ => throw new ArgumentException($"Unknown humanize preset {preset}."),
|
||||
};
|
||||
return overrides == null || overrides.Count == 0
|
||||
? baseCfg
|
||||
: baseCfg.With(overrides);
|
||||
}
|
||||
|
||||
/// <summary>Parse a preset name string ('default'/'careful'), case-insensitive.</summary>
|
||||
public static HumanPreset ParsePreset(string? preset) => (preset ?? "default").ToLowerInvariant() switch
|
||||
{
|
||||
"default" => HumanPreset.Default,
|
||||
"careful" => HumanPreset.Careful,
|
||||
_ => throw new ArgumentException(
|
||||
$"Unknown humanize preset '{preset}'. Valid presets: careful, default"),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Extension helpers for merging override dictionaries onto a <see cref="HumanConfig"/>.</summary>
|
||||
public static class HumanConfigExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Merge a dictionary of overrides (keys may be snake_case like the Python API,
|
||||
/// or PascalCase property names) on top of <paramref name="baseCfg"/>.
|
||||
/// Returns a new config - the base is never mutated. Unknown keys are ignored.
|
||||
/// </summary>
|
||||
public static HumanConfig With(this HumanConfig baseCfg, IReadOnlyDictionary<string, object>? overrides)
|
||||
{
|
||||
if (overrides == null || overrides.Count == 0)
|
||||
return baseCfg.Clone();
|
||||
|
||||
var result = baseCfg.Clone();
|
||||
foreach (var (key, value) in overrides)
|
||||
{
|
||||
var prop = ResolveProperty(key);
|
||||
if (prop == null) continue; // unknown keys ignored silently
|
||||
try
|
||||
{
|
||||
prop.SetValue(result, Coerce(value, prop.PropertyType));
|
||||
}
|
||||
catch (Exception) { /* ignore bad coercions, matching Python's forgiving merge */ }
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static System.Reflection.PropertyInfo? ResolveProperty(string key)
|
||||
{
|
||||
var pascal = SnakeToPascal(key);
|
||||
return typeof(HumanConfig).GetProperty(pascal,
|
||||
System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance);
|
||||
}
|
||||
|
||||
private static string SnakeToPascal(string key)
|
||||
{
|
||||
if (!key.Contains('_'))
|
||||
// Already PascalCase (or single word) - normalise first char to upper.
|
||||
return key.Length == 0 ? key : char.ToUpperInvariant(key[0]) + key[1..];
|
||||
var parts = key.Split('_', StringSplitOptions.RemoveEmptyEntries);
|
||||
return string.Concat(parts.Select(p => char.ToUpperInvariant(p[0]) + p[1..]));
|
||||
}
|
||||
|
||||
private static object Coerce(object value, Type target)
|
||||
{
|
||||
if (target == typeof(Range))
|
||||
{
|
||||
// Accept (double,double) tuple, double[]/object[] of length 2, List, etc.
|
||||
switch (value)
|
||||
{
|
||||
case Range r:
|
||||
return r;
|
||||
case ValueTuple<double, double> vt:
|
||||
return new Range(vt.Item1, vt.Item2);
|
||||
case IEnumerable<object> seq:
|
||||
{
|
||||
var arr = seq.ToArray();
|
||||
if (arr.Length >= 2)
|
||||
return new Range(Convert.ToDouble(arr[0]), Convert.ToDouble(arr[1]));
|
||||
break;
|
||||
}
|
||||
case System.Collections.IEnumerable en and not string:
|
||||
{
|
||||
var list = en.Cast<object>().ToArray();
|
||||
if (list.Length >= 2)
|
||||
return new Range(Convert.ToDouble(list[0]), Convert.ToDouble(list[1]));
|
||||
break;
|
||||
}
|
||||
}
|
||||
throw new InvalidCastException("Cannot convert value to Range");
|
||||
}
|
||||
if (target == typeof(bool)) return Convert.ToBoolean(value);
|
||||
if (target == typeof(int)) return Convert.ToInt32(value);
|
||||
if (target == typeof(double)) return Convert.ToDouble(value);
|
||||
return value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace CloakBrowser.Human;
|
||||
|
||||
/// <summary>
|
||||
/// Minimal keyboard abstraction the humanize layer drives. Mirrors the Python
|
||||
/// <c>RawKeyboard</c> Protocol. Implemented over Playwright's <c>IKeyboard</c>.
|
||||
/// </summary>
|
||||
public interface IRawKeyboard
|
||||
{
|
||||
Task DownAsync(string key);
|
||||
Task UpAsync(string key);
|
||||
Task TypeAsync(string text);
|
||||
Task InsertTextAsync(string text);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A tiny abstraction over a CDP session so the keyboard can dispatch trusted
|
||||
/// key events. Implemented over Playwright's <c>ICDPSession</c>.
|
||||
/// </summary>
|
||||
public interface IRawCdpSession
|
||||
{
|
||||
Task SendAsync(string method, JsonObject? args = null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A tiny abstraction over <c>page.evaluate</c> used by the fallback shift-symbol path.
|
||||
/// </summary>
|
||||
public interface IRawEvaluator
|
||||
{
|
||||
Task EvaluateAsync(string expression, object? arg);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Human-like keyboard input. Direct port of Python
|
||||
/// <c>cloakbrowser/human/keyboard.py</c>.
|
||||
///
|
||||
/// Stealth-aware: when a CDP session is provided, shift symbols are typed via
|
||||
/// CDP <c>Input.dispatchKeyEvent</c> (isTrusted=true, no evaluate stack trace).
|
||||
/// Falls back to <c>page.evaluate</c> when no CDP session is available.
|
||||
/// </summary>
|
||||
public static class HumanKeyboard
|
||||
{
|
||||
/// <summary>Characters that require holding Shift to produce.</summary>
|
||||
public static readonly IReadOnlySet<char> ShiftSymbols =
|
||||
new HashSet<char>("@#!$%^&*()_+{}|:\"<>?~");
|
||||
|
||||
/// <summary>QWERTY-adjacent keys used to simulate fat-finger mistypes.</summary>
|
||||
public static readonly IReadOnlyDictionary<char, string> NearbyKeys = new Dictionary<char, string>
|
||||
{
|
||||
['a'] = "sqwz", ['b'] = "vghn", ['c'] = "xdfv", ['d'] = "sfecx", ['e'] = "wrsdf",
|
||||
['f'] = "dgrtcv", ['g'] = "fhtyb", ['h'] = "gjybn", ['i'] = "ujko", ['j'] = "hkunm",
|
||||
['k'] = "jloi", ['l'] = "kop", ['m'] = "njk", ['n'] = "bhjm", ['o'] = "iklp",
|
||||
['p'] = "ol", ['q'] = "wa", ['r'] = "edft", ['s'] = "awedxz", ['t'] = "rfgy",
|
||||
['u'] = "yhji", ['v'] = "cfgb", ['w'] = "qase", ['x'] = "zsdc", ['y'] = "tghu",
|
||||
['z'] = "asx",
|
||||
['1'] = "2q", ['2'] = "13qw", ['3'] = "24we", ['4'] = "35er", ['5'] = "46rt",
|
||||
['6'] = "57ty", ['7'] = "68yu", ['8'] = "79ui", ['9'] = "80io", ['0'] = "9p",
|
||||
};
|
||||
|
||||
/// <summary>CDP key <c>code</c> for each shift symbol's physical key.</summary>
|
||||
private static readonly IReadOnlyDictionary<char, string> ShiftSymbolCodes = new Dictionary<char, string>
|
||||
{
|
||||
['!'] = "Digit1", ['@'] = "Digit2", ['#'] = "Digit3", ['$'] = "Digit4",
|
||||
['%'] = "Digit5", ['^'] = "Digit6", ['&'] = "Digit7", ['*'] = "Digit8",
|
||||
['('] = "Digit9", [')'] = "Digit0", ['_'] = "Minus", ['+'] = "Equal",
|
||||
['{'] = "BracketLeft", ['}'] = "BracketRight", ['|'] = "Backslash",
|
||||
[':'] = "Semicolon", ['"'] = "Quote", ['<'] = "Comma", ['>'] = "Period",
|
||||
['?'] = "Slash", ['~'] = "Backquote",
|
||||
};
|
||||
|
||||
/// <summary>Windows virtual key codes for <c>Input.dispatchKeyEvent</c>.</summary>
|
||||
private static readonly IReadOnlyDictionary<char, int> ShiftSymbolKeyCodes = new Dictionary<char, int>
|
||||
{
|
||||
['!'] = 49, ['@'] = 50, ['#'] = 51, ['$'] = 52, ['%'] = 53,
|
||||
['^'] = 54, ['&'] = 55, ['*'] = 56, ['('] = 57, [')'] = 48,
|
||||
['_'] = 189, ['+'] = 187, ['{'] = 219, ['}'] = 221, ['|'] = 220,
|
||||
[':'] = 186, ['"'] = 222, ['<'] = 188, ['>'] = 190, ['?'] = 191,
|
||||
['~'] = 192,
|
||||
};
|
||||
|
||||
private static bool IsAscii(char c) => c <= 0x7F;
|
||||
private static bool IsAlnum(char c) => char.IsLetterOrDigit(c) && IsAscii(c);
|
||||
|
||||
/// <summary>Return a random adjacent key for the given character.</summary>
|
||||
private static char GetNearbyKey(char ch)
|
||||
{
|
||||
char lower = char.ToLowerInvariant(ch);
|
||||
if (NearbyKeys.TryGetValue(lower, out var neighbors) && neighbors.Length > 0)
|
||||
{
|
||||
char wrong = HumanRandom.Choice(neighbors);
|
||||
return char.IsUpper(ch) ? char.ToUpperInvariant(wrong) : wrong;
|
||||
}
|
||||
return ch;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Type <paramref name="text"/> with human-like per-character timing.
|
||||
/// </summary>
|
||||
/// <param name="evaluator">Used by the fallback shift-symbol path (page.evaluate).</param>
|
||||
/// <param name="raw">The raw keyboard to drive.</param>
|
||||
/// <param name="text">The text to type.</param>
|
||||
/// <param name="cfg">Behavior configuration.</param>
|
||||
/// <param name="cdpSession">
|
||||
/// If provided, shift symbols use CDP <c>Input.dispatchKeyEvent</c> producing
|
||||
/// isTrusted=true events with no evaluate stack trace. If null, falls back to
|
||||
/// <paramref name="evaluator"/> (detectable).
|
||||
/// </param>
|
||||
public static async Task HumanTypeAsync(
|
||||
IRawEvaluator? evaluator,
|
||||
IRawKeyboard raw,
|
||||
string text,
|
||||
HumanConfig cfg,
|
||||
IRawCdpSession? cdpSession = null)
|
||||
{
|
||||
for (int i = 0; i < text.Length; i++)
|
||||
{
|
||||
char ch = text[i];
|
||||
|
||||
// Non-ASCII characters (Cyrillic, CJK, emoji) - use insertText.
|
||||
if (!IsAscii(ch))
|
||||
{
|
||||
await HumanRandom.SleepMsAsync(HumanRandom.RandRange(cfg.KeyHold)).ConfigureAwait(false);
|
||||
await raw.InsertTextAsync(ch.ToString()).ConfigureAwait(false);
|
||||
if (i < text.Length - 1)
|
||||
await InterCharDelayAsync(cfg).ConfigureAwait(false);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Mistype chance - only for ASCII alphanumeric.
|
||||
if (HumanRandom.NextDouble() < cfg.MistypeChance && IsAlnum(ch))
|
||||
{
|
||||
char wrong = GetNearbyKey(ch);
|
||||
await TypeNormalCharAsync(raw, wrong, cfg).ConfigureAwait(false);
|
||||
await HumanRandom.SleepMsAsync(HumanRandom.RandRange(cfg.MistypeDelayNotice)).ConfigureAwait(false);
|
||||
await raw.DownAsync("Backspace").ConfigureAwait(false);
|
||||
await HumanRandom.SleepMsAsync(HumanRandom.RandRange(cfg.KeyHold)).ConfigureAwait(false);
|
||||
await raw.UpAsync("Backspace").ConfigureAwait(false);
|
||||
await HumanRandom.SleepMsAsync(HumanRandom.RandRange(cfg.MistypeDelayCorrect)).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (char.IsUpper(ch) && char.IsLetter(ch))
|
||||
await TypeShiftedCharAsync(raw, ch, cfg).ConfigureAwait(false);
|
||||
else if (ShiftSymbols.Contains(ch))
|
||||
await TypeShiftSymbolAsync(evaluator, raw, ch, cfg, cdpSession).ConfigureAwait(false);
|
||||
else
|
||||
await TypeNormalCharAsync(raw, ch, cfg).ConfigureAwait(false);
|
||||
|
||||
if (i < text.Length - 1)
|
||||
await InterCharDelayAsync(cfg).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task TypeNormalCharAsync(IRawKeyboard raw, char ch, HumanConfig cfg)
|
||||
{
|
||||
await raw.DownAsync(ch.ToString()).ConfigureAwait(false);
|
||||
await HumanRandom.SleepMsAsync(HumanRandom.RandRange(cfg.KeyHold)).ConfigureAwait(false);
|
||||
await raw.UpAsync(ch.ToString()).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static async Task TypeShiftedCharAsync(IRawKeyboard raw, char ch, HumanConfig cfg)
|
||||
{
|
||||
await raw.DownAsync("Shift").ConfigureAwait(false);
|
||||
await HumanRandom.SleepMsAsync(HumanRandom.RandRange(cfg.ShiftDownDelay)).ConfigureAwait(false);
|
||||
await raw.DownAsync(ch.ToString()).ConfigureAwait(false);
|
||||
await HumanRandom.SleepMsAsync(HumanRandom.RandRange(cfg.KeyHold)).ConfigureAwait(false);
|
||||
await raw.UpAsync(ch.ToString()).ConfigureAwait(false);
|
||||
await HumanRandom.SleepMsAsync(HumanRandom.RandRange(cfg.ShiftUpDelay)).ConfigureAwait(false);
|
||||
await raw.UpAsync("Shift").ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static async Task TypeShiftSymbolAsync(
|
||||
IRawEvaluator? evaluator,
|
||||
IRawKeyboard raw,
|
||||
char ch,
|
||||
HumanConfig cfg,
|
||||
IRawCdpSession? cdpSession)
|
||||
{
|
||||
if (cdpSession != null)
|
||||
{
|
||||
// --- Stealth path: CDP Input.dispatchKeyEvent ---
|
||||
string code = ShiftSymbolCodes.TryGetValue(ch, out var c) ? c : "";
|
||||
int keyCode = ShiftSymbolKeyCodes.TryGetValue(ch, out var kc) ? kc : 0;
|
||||
string s = ch.ToString();
|
||||
|
||||
await raw.DownAsync("Shift").ConfigureAwait(false);
|
||||
await HumanRandom.SleepMsAsync(HumanRandom.RandRange(cfg.ShiftDownDelay)).ConfigureAwait(false);
|
||||
|
||||
await cdpSession.SendAsync("Input.dispatchKeyEvent", new JsonObject
|
||||
{
|
||||
["type"] = "keyDown",
|
||||
["modifiers"] = 8, // Shift modifier flag
|
||||
["key"] = s,
|
||||
["code"] = code,
|
||||
["windowsVirtualKeyCode"] = keyCode,
|
||||
["text"] = s,
|
||||
["unmodifiedText"] = s,
|
||||
}).ConfigureAwait(false);
|
||||
await HumanRandom.SleepMsAsync(HumanRandom.RandRange(cfg.KeyHold)).ConfigureAwait(false);
|
||||
|
||||
await cdpSession.SendAsync("Input.dispatchKeyEvent", new JsonObject
|
||||
{
|
||||
["type"] = "keyUp",
|
||||
["modifiers"] = 8,
|
||||
["key"] = s,
|
||||
["code"] = code,
|
||||
["windowsVirtualKeyCode"] = keyCode,
|
||||
}).ConfigureAwait(false);
|
||||
|
||||
await HumanRandom.SleepMsAsync(HumanRandom.RandRange(cfg.ShiftUpDelay)).ConfigureAwait(false);
|
||||
await raw.UpAsync("Shift").ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
// --- Fallback path: page.evaluate (detectable) ---
|
||||
await raw.DownAsync("Shift").ConfigureAwait(false);
|
||||
await HumanRandom.SleepMsAsync(HumanRandom.RandRange(cfg.ShiftDownDelay)).ConfigureAwait(false);
|
||||
await raw.InsertTextAsync(ch.ToString()).ConfigureAwait(false);
|
||||
if (evaluator != null)
|
||||
{
|
||||
await evaluator.EvaluateAsync(
|
||||
@"(key) => {
|
||||
const el = document.activeElement;
|
||||
if (el) {
|
||||
el.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true }));
|
||||
el.dispatchEvent(new KeyboardEvent('keyup', { key, bubbles: true }));
|
||||
}
|
||||
}",
|
||||
ch.ToString()).ConfigureAwait(false);
|
||||
}
|
||||
await HumanRandom.SleepMsAsync(HumanRandom.RandRange(cfg.ShiftUpDelay)).ConfigureAwait(false);
|
||||
await raw.UpAsync("Shift").ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task InterCharDelayAsync(HumanConfig cfg)
|
||||
{
|
||||
if (HumanRandom.NextDouble() < cfg.TypingPauseChance)
|
||||
{
|
||||
await HumanRandom.SleepMsAsync(HumanRandom.RandRange(cfg.TypingPauseRange)).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
double delay = cfg.TypingDelay + (HumanRandom.NextDouble() - 0.5) * 2 * cfg.TypingDelaySpread;
|
||||
await HumanRandom.SleepMsAsync(Math.Max(10, delay)).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
namespace CloakBrowser.Human;
|
||||
|
||||
/// <summary>A 2D point used by the mouse-movement curve math.</summary>
|
||||
public readonly record struct Point(double X, double Y);
|
||||
|
||||
/// <summary>
|
||||
/// Minimal mouse abstraction the humanize layer drives. Mirrors the Python
|
||||
/// <c>RawMouse</c> Protocol. Implemented over Playwright's <c>IMouse</c>.
|
||||
/// All methods are async to match .NET Playwright.
|
||||
/// </summary>
|
||||
public interface IRawMouse
|
||||
{
|
||||
Task MoveAsync(double x, double y);
|
||||
Task DownAsync();
|
||||
Task UpAsync();
|
||||
Task WheelAsync(double deltaX, double deltaY);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Human-like mouse movement and clicking.
|
||||
/// Direct port of Python <c>cloakbrowser/human/mouse.py</c>.
|
||||
/// Movement follows a cubic Bezier curve with perpendicular wobble, burst
|
||||
/// pauses, and an optional overshoot+correction at the end.
|
||||
/// </summary>
|
||||
public static class HumanMouse
|
||||
{
|
||||
/// <summary>Cubic ease-in-out, matching the Python implementation.</summary>
|
||||
private static double EaseInOut(double t)
|
||||
{
|
||||
if (t < 0.5)
|
||||
return 4 * t * t * t;
|
||||
return 1 - Math.Pow(-2 * t + 2, 3) / 2;
|
||||
}
|
||||
|
||||
/// <summary>Cubic Bezier interpolation between four control points.</summary>
|
||||
private static Point Bezier(Point p0, Point p1, Point p2, Point p3, double t)
|
||||
{
|
||||
double u = 1 - t;
|
||||
double uu = u * u;
|
||||
double uuu = uu * u;
|
||||
double tt = t * t;
|
||||
double ttt = tt * t;
|
||||
return new Point(
|
||||
uuu * p0.X + 3 * uu * t * p1.X + 3 * u * tt * p2.X + ttt * p3.X,
|
||||
uuu * p0.Y + 3 * uu * t * p1.Y + 3 * u * tt * p2.Y + ttt * p3.Y);
|
||||
}
|
||||
|
||||
/// <summary>Generate two random control points biased perpendicular to the path.</summary>
|
||||
private static (Point, Point) RandomControlPoints(Point start, Point end)
|
||||
{
|
||||
double dx = end.X - start.X;
|
||||
double dy = end.Y - start.Y;
|
||||
double dist = Math.Sqrt(dx * dx + dy * dy);
|
||||
if (dist == 0) dist = 1;
|
||||
double px = -dy / dist;
|
||||
double py = dx / dist;
|
||||
double bias1 = HumanRandom.Rand(-0.3, 0.3) * dist;
|
||||
double bias2 = HumanRandom.Rand(-0.3, 0.3) * dist;
|
||||
return (
|
||||
new Point(start.X + dx * 0.25 + px * bias1, start.Y + dy * 0.25 + py * bias1),
|
||||
new Point(start.X + dx * 0.75 + px * bias2, start.Y + dy * 0.75 + py * bias2));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Move the cursor from (startX, startY) to (endX, endY) along a human-like
|
||||
/// Bezier curve with wobble, burst pauses, and an optional overshoot.
|
||||
/// </summary>
|
||||
public static async Task HumanMoveAsync(
|
||||
IRawMouse raw,
|
||||
double startX, double startY,
|
||||
double endX, double endY,
|
||||
HumanConfig cfg)
|
||||
{
|
||||
double dist = Math.Sqrt((endX - startX) * (endX - startX) + (endY - startY) * (endY - startY));
|
||||
if (dist < 1)
|
||||
return;
|
||||
|
||||
int steps = (int)Math.Max(cfg.MouseMinSteps,
|
||||
Math.Min(cfg.MouseMaxSteps, Math.Round(dist / cfg.MouseStepsDivisor)));
|
||||
var start = new Point(startX, startY);
|
||||
var end = new Point(endX, endY);
|
||||
var (cp1, cp2) = RandomControlPoints(start, end);
|
||||
|
||||
int burstCounter = 0;
|
||||
int burstSize = HumanRandom.RandIntRange(cfg.MouseBurstSize);
|
||||
|
||||
for (int i = 0; i <= steps; i++)
|
||||
{
|
||||
double progress = (double)i / steps;
|
||||
double easedT = EaseInOut(progress);
|
||||
var pt = Bezier(start, cp1, cp2, end, easedT);
|
||||
|
||||
double wobbleAmp = Math.Sin(Math.PI * progress) * cfg.MouseWobbleMax;
|
||||
double wx = pt.X + (HumanRandom.NextDouble() - 0.5) * 2 * wobbleAmp;
|
||||
double wy = pt.Y + (HumanRandom.NextDouble() - 0.5) * 2 * wobbleAmp;
|
||||
|
||||
await raw.MoveAsync(Math.Round(wx), Math.Round(wy)).ConfigureAwait(false);
|
||||
|
||||
burstCounter++;
|
||||
if (burstCounter >= burstSize && i < steps)
|
||||
{
|
||||
await HumanRandom.SleepMsAsync(HumanRandom.RandRange(cfg.MouseBurstPause)).ConfigureAwait(false);
|
||||
burstCounter = 0;
|
||||
}
|
||||
}
|
||||
|
||||
if (HumanRandom.NextDouble() < cfg.MouseOvershootChance)
|
||||
{
|
||||
double overshootDist = HumanRandom.RandRange(cfg.MouseOvershootPx);
|
||||
double angle = Math.Atan2(endY - startY, endX - startX);
|
||||
await raw.MoveAsync(
|
||||
Math.Round(endX + Math.Cos(angle) * overshootDist),
|
||||
Math.Round(endY + Math.Sin(angle) * overshootDist)).ConfigureAwait(false);
|
||||
await HumanRandom.SleepMsAsync(HumanRandom.Rand(30, 70)).ConfigureAwait(false);
|
||||
await raw.MoveAsync(
|
||||
Math.Round(endX + (HumanRandom.NextDouble() - 0.5) * 4),
|
||||
Math.Round(endY + (HumanRandom.NextDouble() - 0.5) * 4)).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compute a randomized click point inside a bounding box. Inputs get a
|
||||
/// left-biased X and a wider Y band; buttons get a centered cluster.
|
||||
/// </summary>
|
||||
public static Point ClickTarget(BoundingBox box, bool isInput, HumanConfig cfg)
|
||||
{
|
||||
double xFrac, yFrac;
|
||||
if (isInput)
|
||||
{
|
||||
xFrac = HumanRandom.RandRange(cfg.ClickInputXRange);
|
||||
yFrac = HumanRandom.Rand(0.30, 0.70);
|
||||
}
|
||||
else
|
||||
{
|
||||
xFrac = HumanRandom.Rand(0.35, 0.65);
|
||||
yFrac = HumanRandom.Rand(0.35, 0.65);
|
||||
}
|
||||
return new Point(
|
||||
Math.Round(box.X + box.Width * xFrac),
|
||||
Math.Round(box.Y + box.Height * yFrac));
|
||||
}
|
||||
|
||||
/// <summary>Perform a human-like press: aim delay, mouse down, hold, mouse up.</summary>
|
||||
public static async Task HumanClickAsync(IRawMouse raw, bool isInput, HumanConfig cfg)
|
||||
{
|
||||
double aimDelay = isInput
|
||||
? HumanRandom.RandRange(cfg.ClickAimDelayInput)
|
||||
: HumanRandom.RandRange(cfg.ClickAimDelayButton);
|
||||
await HumanRandom.SleepMsAsync(aimDelay).ConfigureAwait(false);
|
||||
double holdTime = isInput
|
||||
? HumanRandom.RandRange(cfg.ClickHoldInput)
|
||||
: HumanRandom.RandRange(cfg.ClickHoldButton);
|
||||
await raw.DownAsync().ConfigureAwait(false);
|
||||
await HumanRandom.SleepMsAsync(holdTime).ConfigureAwait(false);
|
||||
await raw.UpAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>Drift the cursor with tiny random movements for ~<paramref name="seconds"/> seconds.</summary>
|
||||
public static async Task HumanIdleAsync(IRawMouse raw, double seconds, double cx, double cy, HumanConfig cfg)
|
||||
{
|
||||
var endTime = DateTime.UtcNow.AddSeconds(seconds);
|
||||
double x = cx, y = cy;
|
||||
while (DateTime.UtcNow < endTime)
|
||||
{
|
||||
double dx = (HumanRandom.NextDouble() - 0.5) * 2 * cfg.IdleDriftPx;
|
||||
double dy = (HumanRandom.NextDouble() - 0.5) * 2 * cfg.IdleDriftPx;
|
||||
x += dx;
|
||||
y += dy;
|
||||
await raw.MoveAsync(Math.Round(x), Math.Round(y)).ConfigureAwait(false);
|
||||
await HumanRandom.SleepMsAsync(HumanRandom.RandRange(cfg.IdlePauseRange)).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A bounding box (x, y, width, height) in CSS pixels - mirrors Playwright's
|
||||
/// <c>BoundingBox</c> but kept independent so the math helpers don't depend on
|
||||
/// the Playwright type directly.
|
||||
/// </summary>
|
||||
public readonly record struct BoundingBox(double X, double Y, double Width, double Height);
|
||||
@@ -0,0 +1,667 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using Microsoft.Playwright;
|
||||
|
||||
namespace CloakBrowser.Human;
|
||||
|
||||
/// <summary>Options accepted by the humanized action methods on <see cref="HumanPage"/>.</summary>
|
||||
public sealed class HumanActionOptions
|
||||
{
|
||||
/// <summary>Overall timeout in milliseconds (default 30000).</summary>
|
||||
public double Timeout { get; set; } = 30000;
|
||||
|
||||
/// <summary>Skip all actionability checks and motion guarantees when true.</summary>
|
||||
public bool Force { get; set; }
|
||||
|
||||
/// <summary>Per-call config overrides (snake_case or PascalCase keys), merged on top of the page config.</summary>
|
||||
public IReadOnlyDictionary<string, object>? HumanConfig { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A human-like wrapper around a Playwright <see cref="IPage"/>.
|
||||
///
|
||||
/// .NET's Playwright exposes sealed interfaces (<see cref="IPage"/>, <see cref="ILocator"/>,
|
||||
/// etc.) that cannot be monkey-patched the way the Python/JS implementations replace
|
||||
/// methods at runtime. Instead, this wrapper exposes the same humanized behaviors as
|
||||
/// explicit methods. The underlying real page is always available via <see cref="Page"/>
|
||||
/// for anything not covered here.
|
||||
///
|
||||
/// Direct behavioral port of <c>patch_page</c> in <c>cloakbrowser/human/__init__.py</c>:
|
||||
/// every action runs Playwright-style actionability checks, a Bezier-curve mouse
|
||||
/// approach with optional scroll-into-view, pointer-events verification, and human
|
||||
/// typing through the CDP isolated world / dispatchKeyEvent stealth path.
|
||||
/// </summary>
|
||||
public sealed class HumanPage
|
||||
{
|
||||
private static readonly bool IsMac = RuntimeInformation.IsOSPlatform(OSPlatform.OSX);
|
||||
private static readonly string SelectAll = IsMac ? "Meta+a" : "Control+a";
|
||||
|
||||
private readonly IPage _page;
|
||||
private readonly HumanConfig _cfg;
|
||||
private readonly CursorState _cursor = new();
|
||||
|
||||
private readonly IRawMouse _rawMouse;
|
||||
private readonly IRawKeyboard _rawKeyboard;
|
||||
private readonly IRawScrollPage _scrollPage;
|
||||
private readonly IRawEvaluator _evaluator;
|
||||
|
||||
private IsolatedWorld? _stealth;
|
||||
private IRawCdpSession? _cdpSession;
|
||||
private bool _stealthInitialized;
|
||||
|
||||
/// <summary>The underlying real Playwright page.</summary>
|
||||
public IPage Page => _page;
|
||||
|
||||
/// <summary>The resolved behavior configuration for this page.</summary>
|
||||
public HumanConfig Config => _cfg;
|
||||
|
||||
/// <summary>The current virtual cursor position (x, y).</summary>
|
||||
public (double X, double Y) Cursor => (_cursor.X, _cursor.Y);
|
||||
|
||||
/// <summary>Create a humanized wrapper. Use <see cref="CreateAsync(IPage, HumanConfig?)"/> for the
|
||||
/// stealth-enabled variant (recommended).</summary>
|
||||
public HumanPage(IPage page, HumanConfig? cfg = null)
|
||||
{
|
||||
_page = page;
|
||||
_cfg = cfg ?? new HumanConfig();
|
||||
_rawMouse = new PlaywrightRawMouse(page.Mouse);
|
||||
_rawKeyboard = new PlaywrightRawKeyboard(page.Keyboard);
|
||||
_scrollPage = new PlaywrightScrollPage(page);
|
||||
_evaluator = new PlaywrightEvaluator(page);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create a humanized page and initialize the CDP isolated world + dispatchKeyEvent
|
||||
/// stealth path. Falls back gracefully (stealth disabled) when CDP is unavailable.
|
||||
/// </summary>
|
||||
public static async Task<HumanPage> CreateAsync(IPage page, HumanConfig? cfg = null)
|
||||
{
|
||||
var hp = new HumanPage(page, cfg);
|
||||
await hp.InitStealthAsync().ConfigureAwait(false);
|
||||
await hp.InitCursorAsync().ConfigureAwait(false);
|
||||
return hp;
|
||||
}
|
||||
|
||||
private async Task InitStealthAsync()
|
||||
{
|
||||
if (_stealthInitialized) return;
|
||||
_stealthInitialized = true;
|
||||
try
|
||||
{
|
||||
_stealth = new IsolatedWorld(_page);
|
||||
var session = await _stealth.GetCdpSessionAsync().ConfigureAwait(false);
|
||||
_cdpSession = new PlaywrightCdpSession(session);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
_stealth = null;
|
||||
_cdpSession = null;
|
||||
CloakLog.Debug("Could not create CDP session - stealth features disabled");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task InitCursorAsync()
|
||||
{
|
||||
// Initialize cursor immediately so it doesn't visibly jump from (0,0).
|
||||
_cursor.X = HumanRandom.Rand(_cfg.InitialCursorX.Min, _cfg.InitialCursorX.Max);
|
||||
_cursor.Y = HumanRandom.Rand(_cfg.InitialCursorY.Min, _cfg.InitialCursorY.Max);
|
||||
try
|
||||
{
|
||||
await _rawMouse.MoveAsync(_cursor.X, _cursor.Y).ConfigureAwait(false);
|
||||
_cursor.Initialized = true;
|
||||
}
|
||||
catch (Exception) { /* viewport may not be ready yet */ }
|
||||
}
|
||||
|
||||
private async Task EnsureCursorInitAsync()
|
||||
{
|
||||
if (!_cursor.Initialized)
|
||||
{
|
||||
_cursor.X = HumanRandom.Rand(_cfg.InitialCursorX.Min, _cfg.InitialCursorX.Max);
|
||||
_cursor.Y = HumanRandom.Rand(_cfg.InitialCursorY.Min, _cfg.InitialCursorY.Max);
|
||||
await _rawMouse.MoveAsync(_cursor.X, _cursor.Y).ConfigureAwait(false);
|
||||
_cursor.Initialized = true;
|
||||
}
|
||||
}
|
||||
|
||||
private HumanConfig MergeCfg(HumanActionOptions? opts) =>
|
||||
opts?.HumanConfig == null ? _cfg : _cfg.With(opts.HumanConfig);
|
||||
|
||||
private static double RemainingMs(double deadline) => Actionability.RemainingMs(deadline);
|
||||
|
||||
private async Task<bool> IsInputElementAsync(string selector)
|
||||
{
|
||||
if (_stealth != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
string escaped = IsolatedWorld.JsonEncode(selector);
|
||||
return await _stealth.EvaluateBoolAsync(
|
||||
$"(() => {{" +
|
||||
$" const el = document.querySelector({escaped});" +
|
||||
$" if (!el) return false;" +
|
||||
$" const tag = el.tagName.toLowerCase();" +
|
||||
$" return tag === 'input' || tag === 'textarea'" +
|
||||
$" || el.getAttribute('contenteditable') === 'true';" +
|
||||
$"}})()").ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception) { /* fall through */ }
|
||||
}
|
||||
try
|
||||
{
|
||||
return await _page.EvaluateAsync<bool>(
|
||||
@"(sel) => {
|
||||
const el = document.querySelector(sel);
|
||||
if (!el) return false;
|
||||
const tag = el.tagName.toLowerCase();
|
||||
return tag === 'input' || tag === 'textarea'
|
||||
|| el.getAttribute('contenteditable') === 'true';
|
||||
}", selector).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception) { return false; }
|
||||
}
|
||||
|
||||
private async Task<bool> IsSelectorFocusedAsync(string selector)
|
||||
{
|
||||
if (_stealth != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
string escaped = IsolatedWorld.JsonEncode(selector);
|
||||
return await _stealth.EvaluateBoolAsync(
|
||||
$"(() => {{" +
|
||||
$" const el = document.querySelector({escaped});" +
|
||||
$" return el === document.activeElement;" +
|
||||
$"}})()").ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception) { /* fall through */ }
|
||||
}
|
||||
try
|
||||
{
|
||||
return await _page.EvaluateAsync<bool>(
|
||||
@"(sel) => {
|
||||
const el = document.querySelector(sel);
|
||||
return el === document.activeElement;
|
||||
}", selector).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception) { return false; }
|
||||
}
|
||||
|
||||
private async Task<BoundingBox?> GetBoxAsync(string selector, double timeoutMs)
|
||||
{
|
||||
try
|
||||
{
|
||||
var box = await _page.Locator(selector).First.BoundingBoxAsync(new LocatorBoundingBoxOptions
|
||||
{
|
||||
Timeout = (float)Math.Max(1, timeoutMs),
|
||||
}).ConfigureAwait(false);
|
||||
return box == null ? null : new BoundingBox(box.X, box.Y, box.Width, box.Height);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Navigation
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <summary>Navigate to a URL and invalidate the isolated world afterward.</summary>
|
||||
public async Task<IResponse?> GotoAsync(string url, PageGotoOptions? options = null)
|
||||
{
|
||||
var response = await _page.GotoAsync(url, options).ConfigureAwait(false);
|
||||
_stealth?.Invalidate();
|
||||
return response;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Click
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <summary>Human-like click on <paramref name="selector"/>.</summary>
|
||||
public Task ClickAsync(string selector, HumanActionOptions? options = null) =>
|
||||
ClickInternalAsync(selector, options, skipChecks: false);
|
||||
|
||||
private async Task ClickInternalAsync(string selector, HumanActionOptions? options, bool skipChecks)
|
||||
{
|
||||
await EnsureCursorInitAsync().ConfigureAwait(false);
|
||||
var callCfg = MergeCfg(options);
|
||||
double timeout = options?.Timeout ?? 30000;
|
||||
bool force = options?.Force ?? false;
|
||||
double deadline = Environment.TickCount64 + timeout;
|
||||
|
||||
if (!force && !skipChecks)
|
||||
await Actionability.EnsureActionableAsync(_page, selector, Actionability.ChecksClick, RemainingMs(deadline), force).ConfigureAwait(false);
|
||||
if (callCfg.IdleBetweenActions)
|
||||
await HumanMouse.HumanIdleAsync(_rawMouse, HumanRandom.Rand(callCfg.IdleBetweenDuration.Min, callCfg.IdleBetweenDuration.Max), _cursor.X, _cursor.Y, callCfg).ConfigureAwait(false);
|
||||
|
||||
var scroll = await HumanScroll.HumanScrollIntoViewAsync(
|
||||
_scrollPage, _rawMouse, () => GetBoxAsync(selector, RemainingMs(deadline)),
|
||||
_cursor.X, _cursor.Y, callCfg).ConfigureAwait(false);
|
||||
_cursor.X = scroll.CursorX;
|
||||
_cursor.Y = scroll.CursorY;
|
||||
var box = scroll.Box;
|
||||
|
||||
bool isInput = await IsInputElementAsync(selector).ConfigureAwait(false);
|
||||
if (!force && scroll.DidScroll)
|
||||
{
|
||||
await Actionability.EnsureStableAsync(_page, selector, RemainingMs(deadline)).ConfigureAwait(false);
|
||||
box = await GetBoxAsync(selector, RemainingMs(deadline)).ConfigureAwait(false) ?? box;
|
||||
}
|
||||
var target = HumanMouse.ClickTarget(box, isInput, callCfg);
|
||||
if (!force)
|
||||
await Actionability.CheckPointerEventsAsync(_page, selector, target.X, target.Y, RemainingMs(deadline)).ConfigureAwait(false);
|
||||
await HumanMouse.HumanMoveAsync(_rawMouse, _cursor.X, _cursor.Y, target.X, target.Y, callCfg).ConfigureAwait(false);
|
||||
_cursor.X = target.X;
|
||||
_cursor.Y = target.Y;
|
||||
await HumanMouse.HumanClickAsync(_rawMouse, isInput, callCfg).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Double-click
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <summary>Human-like double-click on <paramref name="selector"/>.</summary>
|
||||
public async Task DblClickAsync(string selector, HumanActionOptions? options = null)
|
||||
{
|
||||
await EnsureCursorInitAsync().ConfigureAwait(false);
|
||||
var callCfg = MergeCfg(options);
|
||||
double timeout = options?.Timeout ?? 30000;
|
||||
bool force = options?.Force ?? false;
|
||||
double deadline = Environment.TickCount64 + timeout;
|
||||
|
||||
if (!force)
|
||||
await Actionability.EnsureActionableAsync(_page, selector, Actionability.ChecksClick, RemainingMs(deadline), force).ConfigureAwait(false);
|
||||
if (callCfg.IdleBetweenActions)
|
||||
await HumanMouse.HumanIdleAsync(_rawMouse, HumanRandom.Rand(callCfg.IdleBetweenDuration.Min, callCfg.IdleBetweenDuration.Max), _cursor.X, _cursor.Y, callCfg).ConfigureAwait(false);
|
||||
|
||||
var scroll = await HumanScroll.HumanScrollIntoViewAsync(
|
||||
_scrollPage, _rawMouse, () => GetBoxAsync(selector, RemainingMs(deadline)),
|
||||
_cursor.X, _cursor.Y, callCfg).ConfigureAwait(false);
|
||||
_cursor.X = scroll.CursorX;
|
||||
_cursor.Y = scroll.CursorY;
|
||||
var box = scroll.Box;
|
||||
|
||||
bool isInput = await IsInputElementAsync(selector).ConfigureAwait(false);
|
||||
if (!force && scroll.DidScroll)
|
||||
{
|
||||
await Actionability.EnsureStableAsync(_page, selector, RemainingMs(deadline)).ConfigureAwait(false);
|
||||
box = await GetBoxAsync(selector, RemainingMs(deadline)).ConfigureAwait(false) ?? box;
|
||||
}
|
||||
var target = HumanMouse.ClickTarget(box, isInput, callCfg);
|
||||
if (!force)
|
||||
await Actionability.CheckPointerEventsAsync(_page, selector, target.X, target.Y, RemainingMs(deadline)).ConfigureAwait(false);
|
||||
await HumanMouse.HumanMoveAsync(_rawMouse, _cursor.X, _cursor.Y, target.X, target.Y, callCfg).ConfigureAwait(false);
|
||||
_cursor.X = target.X;
|
||||
_cursor.Y = target.Y;
|
||||
// Two presses for a double-click via Playwright IMouse click count.
|
||||
await _page.Mouse.DownAsync(new MouseDownOptions { ClickCount = 2 }).ConfigureAwait(false);
|
||||
await HumanRandom.SleepMsAsync(HumanRandom.Rand(30, 60)).ConfigureAwait(false);
|
||||
await _page.Mouse.UpAsync(new MouseUpOptions { ClickCount = 2 }).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Hover
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <summary>Human-like hover over <paramref name="selector"/>.</summary>
|
||||
public Task HoverAsync(string selector, HumanActionOptions? options = null) =>
|
||||
HoverInternalAsync(selector, options, skipChecks: false);
|
||||
|
||||
private async Task HoverInternalAsync(string selector, HumanActionOptions? options, bool skipChecks)
|
||||
{
|
||||
await EnsureCursorInitAsync().ConfigureAwait(false);
|
||||
var callCfg = MergeCfg(options);
|
||||
double timeout = options?.Timeout ?? 30000;
|
||||
bool force = options?.Force ?? false;
|
||||
double deadline = Environment.TickCount64 + timeout;
|
||||
|
||||
if (!force && !skipChecks)
|
||||
await Actionability.EnsureActionableAsync(_page, selector, Actionability.ChecksHover, RemainingMs(deadline), force).ConfigureAwait(false);
|
||||
if (callCfg.IdleBetweenActions)
|
||||
await HumanMouse.HumanIdleAsync(_rawMouse, HumanRandom.Rand(callCfg.IdleBetweenDuration.Min, callCfg.IdleBetweenDuration.Max), _cursor.X, _cursor.Y, callCfg).ConfigureAwait(false);
|
||||
|
||||
var scroll = await HumanScroll.HumanScrollIntoViewAsync(
|
||||
_scrollPage, _rawMouse, () => GetBoxAsync(selector, RemainingMs(deadline)),
|
||||
_cursor.X, _cursor.Y, callCfg).ConfigureAwait(false);
|
||||
_cursor.X = scroll.CursorX;
|
||||
_cursor.Y = scroll.CursorY;
|
||||
var box = scroll.Box;
|
||||
|
||||
if (!force && scroll.DidScroll)
|
||||
{
|
||||
await Actionability.EnsureStableAsync(_page, selector, RemainingMs(deadline)).ConfigureAwait(false);
|
||||
box = await GetBoxAsync(selector, RemainingMs(deadline)).ConfigureAwait(false) ?? box;
|
||||
}
|
||||
var target = HumanMouse.ClickTarget(box, false, callCfg);
|
||||
if (!force)
|
||||
await Actionability.CheckPointerEventsAsync(_page, selector, target.X, target.Y, RemainingMs(deadline)).ConfigureAwait(false);
|
||||
await HumanMouse.HumanMoveAsync(_rawMouse, _cursor.X, _cursor.Y, target.X, target.Y, callCfg).ConfigureAwait(false);
|
||||
_cursor.X = target.X;
|
||||
_cursor.Y = target.Y;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Type (append) and Fill (clear + type)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <summary>Human-like typing into <paramref name="selector"/> (appends to existing value).</summary>
|
||||
public async Task TypeAsync(string selector, string text, HumanActionOptions? options = null)
|
||||
{
|
||||
var callCfg = MergeCfg(options);
|
||||
double timeout = options?.Timeout ?? 30000;
|
||||
bool force = options?.Force ?? false;
|
||||
double deadline = Environment.TickCount64 + timeout;
|
||||
|
||||
if (!force)
|
||||
await Actionability.EnsureActionableAsync(_page, selector, Actionability.ChecksInput, RemainingMs(deadline), force).ConfigureAwait(false);
|
||||
await HumanRandom.SleepMsAsync(HumanRandom.RandRange(callCfg.FieldSwitchDelay)).ConfigureAwait(false);
|
||||
await ClickInternalAsync(selector, new HumanActionOptions { Timeout = RemainingMs(deadline), Force = force, HumanConfig = options?.HumanConfig }, skipChecks: true).ConfigureAwait(false);
|
||||
await HumanRandom.SleepMsAsync(HumanRandom.Rand(100, 250)).ConfigureAwait(false);
|
||||
await HumanKeyboard.HumanTypeAsync(_evaluator, _rawKeyboard, text, callCfg, _cdpSession).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>Human-like fill of <paramref name="selector"/> (selects all, deletes, then types).</summary>
|
||||
public async Task FillAsync(string selector, string value, HumanActionOptions? options = null)
|
||||
{
|
||||
var callCfg = MergeCfg(options);
|
||||
double timeout = options?.Timeout ?? 30000;
|
||||
bool force = options?.Force ?? false;
|
||||
double deadline = Environment.TickCount64 + timeout;
|
||||
|
||||
if (!force)
|
||||
await Actionability.EnsureActionableAsync(_page, selector, Actionability.ChecksInput, RemainingMs(deadline), force).ConfigureAwait(false);
|
||||
await HumanRandom.SleepMsAsync(HumanRandom.RandRange(callCfg.FieldSwitchDelay)).ConfigureAwait(false);
|
||||
await ClickInternalAsync(selector, new HumanActionOptions { Timeout = RemainingMs(deadline), Force = force, HumanConfig = options?.HumanConfig }, skipChecks: true).ConfigureAwait(false);
|
||||
await HumanRandom.SleepMsAsync(HumanRandom.Rand(100, 250)).ConfigureAwait(false);
|
||||
await _page.Keyboard.PressAsync(SelectAll).ConfigureAwait(false);
|
||||
await HumanRandom.SleepMsAsync(HumanRandom.Rand(30, 80)).ConfigureAwait(false);
|
||||
await _page.Keyboard.PressAsync("Backspace").ConfigureAwait(false);
|
||||
await HumanRandom.SleepMsAsync(HumanRandom.Rand(50, 150)).ConfigureAwait(false);
|
||||
await HumanKeyboard.HumanTypeAsync(_evaluator, _rawKeyboard, value, callCfg, _cdpSession).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Check / Uncheck
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <summary>Human-like check of a checkbox/radio (no-op if already checked).</summary>
|
||||
public async Task CheckAsync(string selector, HumanActionOptions? options = null)
|
||||
{
|
||||
bool force = options?.Force ?? false;
|
||||
double timeout = options?.Timeout ?? 30000;
|
||||
double deadline = Environment.TickCount64 + timeout;
|
||||
|
||||
if (!force)
|
||||
await Actionability.EnsureActionableAsync(_page, selector, Actionability.ChecksCheck, RemainingMs(deadline), force).ConfigureAwait(false);
|
||||
bool checked_;
|
||||
try { checked_ = await _page.IsCheckedAsync(selector).ConfigureAwait(false); }
|
||||
catch (Exception) { checked_ = false; }
|
||||
if (!checked_)
|
||||
await ClickInternalAsync(selector, new HumanActionOptions { Timeout = RemainingMs(deadline), Force = force, HumanConfig = options?.HumanConfig }, skipChecks: true).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>Human-like uncheck of a checkbox (no-op if already unchecked).</summary>
|
||||
public async Task UncheckAsync(string selector, HumanActionOptions? options = null)
|
||||
{
|
||||
bool force = options?.Force ?? false;
|
||||
double timeout = options?.Timeout ?? 30000;
|
||||
double deadline = Environment.TickCount64 + timeout;
|
||||
|
||||
if (!force)
|
||||
await Actionability.EnsureActionableAsync(_page, selector, Actionability.ChecksCheck, RemainingMs(deadline), force).ConfigureAwait(false);
|
||||
bool checked_;
|
||||
try { checked_ = await _page.IsCheckedAsync(selector).ConfigureAwait(false); }
|
||||
catch (Exception) { checked_ = true; }
|
||||
if (checked_)
|
||||
await ClickInternalAsync(selector, new HumanActionOptions { Timeout = RemainingMs(deadline), Force = force, HumanConfig = options?.HumanConfig }, skipChecks: true).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Select option
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <summary>Human-like select of a dropdown option (hovers, then uses Playwright's selectOption).</summary>
|
||||
public async Task<IReadOnlyList<string>> SelectOptionAsync(string selector, string[] values, HumanActionOptions? options = null)
|
||||
{
|
||||
bool force = options?.Force ?? false;
|
||||
double timeout = options?.Timeout ?? 30000;
|
||||
double deadline = Environment.TickCount64 + timeout;
|
||||
|
||||
if (!force)
|
||||
await Actionability.EnsureActionableAsync(_page, selector, Actionability.ChecksFocus, RemainingMs(deadline), force).ConfigureAwait(false);
|
||||
await HoverInternalAsync(selector, new HumanActionOptions { Timeout = RemainingMs(deadline), Force = force, HumanConfig = options?.HumanConfig }, skipChecks: true).ConfigureAwait(false);
|
||||
await HumanRandom.SleepMsAsync(HumanRandom.Rand(100, 300)).ConfigureAwait(false);
|
||||
return await _page.SelectOptionAsync(selector, values).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Press a key while a selector is focused
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <summary>Human-like press of <paramref name="key"/> after focusing <paramref name="selector"/>.</summary>
|
||||
public async Task PressAsync(string selector, string key, HumanActionOptions? options = null)
|
||||
{
|
||||
bool force = options?.Force ?? false;
|
||||
double timeout = options?.Timeout ?? 30000;
|
||||
double deadline = Environment.TickCount64 + timeout;
|
||||
|
||||
if (!force)
|
||||
await Actionability.EnsureActionableAsync(_page, selector, Actionability.ChecksFocus, RemainingMs(deadline), force).ConfigureAwait(false);
|
||||
if (!await IsSelectorFocusedAsync(selector).ConfigureAwait(false))
|
||||
await ClickInternalAsync(selector, new HumanActionOptions { Timeout = RemainingMs(deadline), Force = force, HumanConfig = options?.HumanConfig }, skipChecks: true).ConfigureAwait(false);
|
||||
await HumanRandom.SleepMsAsync(HumanRandom.Rand(50, 150)).ConfigureAwait(false);
|
||||
await _page.Keyboard.PressAsync(key).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Set-checked (drives state to the requested value)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <summary>Human-like set-checked: clicks only when the current state differs from
|
||||
/// the requested <paramref name="checked_"/> value. Port of <c>_humanized_set_checked</c>.</summary>
|
||||
public async Task SetCheckedAsync(string selector, bool checked_, HumanActionOptions? options = null)
|
||||
{
|
||||
bool force = options?.Force ?? false;
|
||||
double timeout = options?.Timeout ?? 30000;
|
||||
double deadline = Environment.TickCount64 + timeout;
|
||||
|
||||
if (!force)
|
||||
await Actionability.EnsureActionableAsync(_page, selector, Actionability.ChecksCheck, RemainingMs(deadline), force).ConfigureAwait(false);
|
||||
bool current;
|
||||
try { current = await _page.IsCheckedAsync(selector).ConfigureAwait(false); }
|
||||
catch (Exception) { current = !checked_; }
|
||||
if (current != checked_)
|
||||
await ClickInternalAsync(selector, new HumanActionOptions { Timeout = RemainingMs(deadline), Force = force, HumanConfig = options?.HumanConfig }, skipChecks: true).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Tap (humanized click; mobile gesture maps to the same motion)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <summary>Human-like tap. Port of <c>_humanized_tap</c> - same motion as a click.</summary>
|
||||
public Task TapAsync(string selector, HumanActionOptions? options = null) =>
|
||||
ClickAsync(selector, options);
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Press sequentially (focus then human-type, no clear)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <summary>Human-like press-sequentially: focuses (via click if needed) then types the
|
||||
/// text with human timing, without clearing. Port of <c>_humanized_press_sequentially</c>.</summary>
|
||||
public async Task PressSequentiallyAsync(string selector, string text, HumanActionOptions? options = null)
|
||||
{
|
||||
bool force = options?.Force ?? false;
|
||||
double timeout = options?.Timeout ?? 30000;
|
||||
double deadline = Environment.TickCount64 + timeout;
|
||||
|
||||
if (!force)
|
||||
await Actionability.EnsureActionableAsync(_page, selector, Actionability.ChecksInput, RemainingMs(deadline), force).ConfigureAwait(false);
|
||||
if (!await IsSelectorFocusedAsync(selector).ConfigureAwait(false))
|
||||
await ClickInternalAsync(selector, new HumanActionOptions { Timeout = RemainingMs(deadline), Force = force, HumanConfig = options?.HumanConfig }, skipChecks: true).ConfigureAwait(false);
|
||||
await HumanRandom.SleepMsAsync(HumanRandom.Rand(50, 150)).ConfigureAwait(false);
|
||||
await HumanKeyboard.HumanTypeAsync(_evaluator, _rawKeyboard, text, MergeCfg(options), _cdpSession).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Clear (focus, select-all, backspace)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <summary>Human-like clear: focuses (via click if needed), selects all, deletes.
|
||||
/// Port of <c>_humanized_clear</c>.</summary>
|
||||
public async Task ClearAsync(string selector, HumanActionOptions? options = null)
|
||||
{
|
||||
bool force = options?.Force ?? false;
|
||||
double timeout = options?.Timeout ?? 30000;
|
||||
double deadline = Environment.TickCount64 + timeout;
|
||||
|
||||
if (!force)
|
||||
await Actionability.EnsureActionableAsync(_page, selector, Actionability.ChecksInput, RemainingMs(deadline), force).ConfigureAwait(false);
|
||||
if (!await IsSelectorFocusedAsync(selector).ConfigureAwait(false))
|
||||
await ClickInternalAsync(selector, new HumanActionOptions { Timeout = RemainingMs(deadline), Force = force, HumanConfig = options?.HumanConfig }, skipChecks: true).ConfigureAwait(false);
|
||||
await HumanRandom.SleepMsAsync(HumanRandom.Rand(50, 100)).ConfigureAwait(false);
|
||||
await _page.Keyboard.PressAsync(SelectAll).ConfigureAwait(false);
|
||||
await HumanRandom.SleepMsAsync(HumanRandom.Rand(30, 80)).ConfigureAwait(false);
|
||||
await _page.Keyboard.PressAsync("Backspace").ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Focus (human cursor move, then programmatic focus - NO click side-effects)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <summary>Human-like focus: moves the cursor over the element with a Bezier curve,
|
||||
/// then focuses it programmatically (no click, so no onclick/submit/navigation).
|
||||
/// Port of <c>_human_el_focus</c> applied to a selector.</summary>
|
||||
public async Task FocusAsync(string selector, HumanActionOptions? options = null)
|
||||
{
|
||||
await EnsureCursorInitAsync().ConfigureAwait(false);
|
||||
var callCfg = MergeCfg(options);
|
||||
double timeout = options?.Timeout ?? 30000;
|
||||
bool force = options?.Force ?? false;
|
||||
double deadline = Environment.TickCount64 + timeout;
|
||||
|
||||
if (!force)
|
||||
await Actionability.EnsureActionableAsync(_page, selector, Actionability.ChecksFocus, RemainingMs(deadline), force).ConfigureAwait(false);
|
||||
|
||||
var scroll = await HumanScroll.HumanScrollIntoViewAsync(
|
||||
_scrollPage, _rawMouse, () => GetBoxAsync(selector, RemainingMs(deadline)),
|
||||
_cursor.X, _cursor.Y, callCfg).ConfigureAwait(false);
|
||||
_cursor.X = scroll.CursorX;
|
||||
_cursor.Y = scroll.CursorY;
|
||||
var box = scroll.Box;
|
||||
if (!force && scroll.DidScroll)
|
||||
{
|
||||
await Actionability.EnsureStableAsync(_page, selector, RemainingMs(deadline)).ConfigureAwait(false);
|
||||
box = await GetBoxAsync(selector, RemainingMs(deadline)).ConfigureAwait(false) ?? box;
|
||||
}
|
||||
var target = HumanMouse.ClickTarget(box, false, callCfg);
|
||||
await HumanMouse.HumanMoveAsync(_rawMouse, _cursor.X, _cursor.Y, target.X, target.Y, callCfg).ConfigureAwait(false);
|
||||
_cursor.X = target.X;
|
||||
_cursor.Y = target.Y;
|
||||
// Programmatic focus - never clicks (mirrors stock Playwright el.focus()).
|
||||
await _page.FocusAsync(selector).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Scroll into view (humanized accelerate->cruise->decelerate->overshoot)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <summary>Human-like scroll-into-view. Port of <c>_humanized_scroll_into_view_if_needed</c>.
|
||||
/// Returns true if a scroll was performed (false when already in viewport).</summary>
|
||||
public async Task<bool> ScrollIntoViewIfNeededAsync(string selector, HumanActionOptions? options = null)
|
||||
{
|
||||
await EnsureCursorInitAsync().ConfigureAwait(false);
|
||||
var callCfg = MergeCfg(options);
|
||||
double timeout = options?.Timeout ?? 30000;
|
||||
try
|
||||
{
|
||||
var scroll = await HumanScroll.HumanScrollIntoViewAsync(
|
||||
_scrollPage, _rawMouse, () => GetBoxAsync(selector, timeout),
|
||||
_cursor.X, _cursor.Y, callCfg).ConfigureAwait(false);
|
||||
_cursor.X = scroll.CursorX;
|
||||
_cursor.Y = scroll.CursorY;
|
||||
return scroll.DidScroll;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Fall back to native scroll, mirroring the Python except branch.
|
||||
await _page.Locator(selector).First.ScrollIntoViewIfNeededAsync(
|
||||
new LocatorScrollIntoViewIfNeededOptions { Timeout = (float)timeout }).ConfigureAwait(false);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Drag-and-drop (humanized: move to source center, down, move to target, up)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <summary>Human-like drag from <paramref name="sourceSelector"/> to
|
||||
/// <paramref name="targetSelector"/>. Port of <c>_frame_drag_and_drop</c> / <c>_humanized_drag_to</c>:
|
||||
/// moves to the source center, presses, moves to the target center, releases.</summary>
|
||||
public async Task DragAndDropAsync(string sourceSelector, string targetSelector, HumanActionOptions? options = null)
|
||||
{
|
||||
await EnsureCursorInitAsync().ConfigureAwait(false);
|
||||
double timeout = options?.Timeout ?? 30000;
|
||||
|
||||
var srcBox = await GetBoxAsync(sourceSelector, timeout).ConfigureAwait(false);
|
||||
var tgtBox = await GetBoxAsync(targetSelector, timeout).ConfigureAwait(false);
|
||||
if (srcBox == null || tgtBox == null)
|
||||
{
|
||||
// Fall back to native drag-and-drop.
|
||||
await _page.DragAndDropAsync(sourceSelector, targetSelector).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
BoundingBox src = srcBox.Value;
|
||||
BoundingBox tgt = tgtBox.Value;
|
||||
double sx = src.X + src.Width / 2;
|
||||
double sy = src.Y + src.Height / 2;
|
||||
double tx = tgt.X + tgt.Width / 2;
|
||||
double ty = tgt.Y + tgt.Height / 2;
|
||||
|
||||
await HumanMouse.HumanMoveAsync(_rawMouse, _cursor.X, _cursor.Y, sx, sy, _cfg).ConfigureAwait(false);
|
||||
_cursor.X = sx; _cursor.Y = sy;
|
||||
await HumanRandom.SleepMsAsync(HumanRandom.Rand(100, 200)).ConfigureAwait(false);
|
||||
await _rawMouse.DownAsync().ConfigureAwait(false);
|
||||
await HumanRandom.SleepMsAsync(HumanRandom.Rand(80, 150)).ConfigureAwait(false);
|
||||
await HumanMouse.HumanMoveAsync(_rawMouse, _cursor.X, _cursor.Y, tx, ty, _cfg).ConfigureAwait(false);
|
||||
_cursor.X = tx; _cursor.Y = ty;
|
||||
await HumanRandom.SleepMsAsync(HumanRandom.Rand(80, 150)).ConfigureAwait(false);
|
||||
await _rawMouse.UpAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Low-level mouse / keyboard
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <summary>Move the virtual cursor to absolute (x, y) with a human-like curve.</summary>
|
||||
public async Task MouseMoveAsync(double x, double y)
|
||||
{
|
||||
await EnsureCursorInitAsync().ConfigureAwait(false);
|
||||
await HumanMouse.HumanMoveAsync(_rawMouse, _cursor.X, _cursor.Y, x, y, _cfg).ConfigureAwait(false);
|
||||
_cursor.X = x;
|
||||
_cursor.Y = y;
|
||||
}
|
||||
|
||||
/// <summary>Move to absolute (x, y) and perform a human-like click there.</summary>
|
||||
public async Task MouseClickAsync(double x, double y)
|
||||
{
|
||||
await EnsureCursorInitAsync().ConfigureAwait(false);
|
||||
await HumanMouse.HumanMoveAsync(_rawMouse, _cursor.X, _cursor.Y, x, y, _cfg).ConfigureAwait(false);
|
||||
_cursor.X = x;
|
||||
_cursor.Y = y;
|
||||
await HumanMouse.HumanClickAsync(_rawMouse, false, _cfg).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>Type text into whatever element currently has focus, with human timing.</summary>
|
||||
public Task KeyboardTypeAsync(string text) =>
|
||||
HumanKeyboard.HumanTypeAsync(_evaluator, _rawKeyboard, text, _cfg, _cdpSession);
|
||||
|
||||
/// <summary>Mutable cursor position used across actions.</summary>
|
||||
private sealed class CursorState
|
||||
{
|
||||
public double X;
|
||||
public double Y;
|
||||
public bool Initialized;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
namespace CloakBrowser.Human;
|
||||
|
||||
/// <summary>
|
||||
/// Random and timing utilities for the humanize layer.
|
||||
/// Mirrors the helpers at the bottom of Python <c>cloakbrowser/human/config.py</c>
|
||||
/// (<c>rand</c>, <c>rand_int</c>, <c>rand_range</c>, <c>rand_int_range</c>,
|
||||
/// <c>sleep_ms</c>, <c>async_sleep_ms</c>), plus a <c>Choice</c> helper used by
|
||||
/// the keyboard mistype simulation.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Uses a thread-safe shared <see cref="System.Random"/>. .NET's
|
||||
/// <c>Random.Shared</c> (introduced in .NET 6) is already thread-safe, so we
|
||||
/// delegate to it directly rather than locking a private instance.
|
||||
/// </remarks>
|
||||
public static class HumanRandom
|
||||
{
|
||||
private static System.Random Rng => System.Random.Shared;
|
||||
|
||||
/// <summary>Random double in [0.0, 1.0).</summary>
|
||||
public static double NextDouble() => Rng.NextDouble();
|
||||
|
||||
/// <summary>Random float in [lo, hi] (inclusive), like Python's <c>random.uniform</c>.</summary>
|
||||
public static double Rand(double lo, double hi)
|
||||
{
|
||||
if (hi < lo)
|
||||
(lo, hi) = (hi, lo);
|
||||
return lo + Rng.NextDouble() * (hi - lo);
|
||||
}
|
||||
|
||||
/// <summary>Random integer in [lo, hi] inclusive, like Python's <c>random.randint</c>.</summary>
|
||||
public static int RandInt(int lo, int hi)
|
||||
{
|
||||
if (hi < lo)
|
||||
(lo, hi) = (hi, lo);
|
||||
// Random.Next upper bound is exclusive - add 1 for inclusive range.
|
||||
return Rng.Next(lo, hi + 1);
|
||||
}
|
||||
|
||||
/// <summary>Random float from a <see cref="Range"/> (min, max), inclusive.</summary>
|
||||
public static double RandRange(Range r) => Rand(r.Min, r.Max);
|
||||
|
||||
/// <summary>Random integer from a <see cref="Range"/> (min, max), inclusive.</summary>
|
||||
public static int RandIntRange(Range r) => RandInt((int)r.Min, (int)r.Max);
|
||||
|
||||
/// <summary>Return <c>true</c> with the given probability in [0, 1].</summary>
|
||||
public static bool Chance(double probability) => Rng.NextDouble() < probability;
|
||||
|
||||
/// <summary>Pick a random character from a non-empty string, like Python's <c>random.choice</c>.</summary>
|
||||
public static char Choice(string options)
|
||||
{
|
||||
if (string.IsNullOrEmpty(options))
|
||||
throw new ArgumentException("Cannot choose from an empty string.", nameof(options));
|
||||
return options[Rng.Next(options.Length)];
|
||||
}
|
||||
|
||||
/// <summary>Pick a random element from a non-empty list, like Python's <c>random.choice</c>.</summary>
|
||||
public static T Choice<T>(IReadOnlyList<T> options)
|
||||
{
|
||||
if (options == null || options.Count == 0)
|
||||
throw new ArgumentException("Cannot choose from an empty collection.", nameof(options));
|
||||
return options[Rng.Next(options.Count)];
|
||||
}
|
||||
|
||||
/// <summary>Block the current thread for <paramref name="ms"/> milliseconds (no-op if <= 0).</summary>
|
||||
public static void SleepMs(double ms)
|
||||
{
|
||||
if (ms > 0)
|
||||
Thread.Sleep((int)Math.Round(ms));
|
||||
}
|
||||
|
||||
/// <summary>Asynchronously wait for <paramref name="ms"/> milliseconds (no-op if <= 0).</summary>
|
||||
public static Task SleepMsAsync(double ms)
|
||||
{
|
||||
if (ms <= 0)
|
||||
return Task.CompletedTask;
|
||||
return Task.Delay((int)Math.Round(ms));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
namespace CloakBrowser.Human;
|
||||
|
||||
/// <summary>
|
||||
/// Page-level operations the scroller needs: the viewport size and a way to
|
||||
/// fetch a bounding box on demand. Implemented over Playwright's <c>IPage</c>.
|
||||
/// </summary>
|
||||
public interface IRawScrollPage
|
||||
{
|
||||
/// <summary>Current viewport size, or null if unavailable.</summary>
|
||||
(int Width, int Height)? ViewportSize { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Live window dimensions (<c>window.innerWidth</c>/<c>innerHeight</c>), used as a
|
||||
/// fallback when <see cref="ViewportSize"/> is null. Headed launches default to
|
||||
/// no-viewport so the page tracks the real OS window and <c>ViewportSize</c> is
|
||||
/// null there - this keeps humanized scroll working in headed (stealth) mode.
|
||||
/// Returns null if the dimensions can't be read.
|
||||
/// </summary>
|
||||
Task<(int Width, int Height)?> GetLiveWindowSizeAsync();
|
||||
}
|
||||
|
||||
/// <summary>Result of a humanized scroll-into-view operation.</summary>
|
||||
/// <param name="Box">The element's bounding box after scrolling.</param>
|
||||
/// <param name="CursorX">The cursor X position after scrolling.</param>
|
||||
/// <param name="CursorY">The cursor Y position after scrolling.</param>
|
||||
/// <param name="DidScroll">False when the element was already in the viewport.</param>
|
||||
public readonly record struct ScrollResult(BoundingBox Box, double CursorX, double CursorY, bool DidScroll);
|
||||
|
||||
/// <summary>
|
||||
/// Human-like scrolling via mouse wheel events.
|
||||
/// Direct port of Python <c>cloakbrowser/human/scroll.py</c>.
|
||||
/// </summary>
|
||||
public static class HumanScroll
|
||||
{
|
||||
private static bool IsInViewport(BoundingBox bounds, int viewportHeight, HumanConfig cfg)
|
||||
{
|
||||
double topEdge = bounds.Y;
|
||||
double bottomEdge = bounds.Y + bounds.Height;
|
||||
double zoneTop = viewportHeight * cfg.ScrollTargetZone.Min;
|
||||
double zoneBottom = viewportHeight * cfg.ScrollTargetZone.Max;
|
||||
return topEdge >= zoneTop && bottomEdge <= zoneBottom;
|
||||
}
|
||||
|
||||
/// <summary>Send one logical scroll as a burst of small wheel events (like real inertia).</summary>
|
||||
private static async Task SmoothWheelAsync(IRawMouse raw, int delta, HumanConfig cfg)
|
||||
{
|
||||
double absD = Math.Abs(delta);
|
||||
int sign = delta > 0 ? 1 : -1;
|
||||
double sent = 0;
|
||||
while (sent < absD)
|
||||
{
|
||||
double stepSize = HumanRandom.Rand(20, 40);
|
||||
double chunk = Math.Min(stepSize, absD - sent);
|
||||
await raw.WheelAsync(0, Math.Round(chunk) * sign).ConfigureAwait(false);
|
||||
sent += chunk;
|
||||
await HumanRandom.SleepMsAsync(HumanRandom.Rand(8, 20)).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Public smooth-wheel helper for the transparent <c>IMouse.WheelAsync</c> override:
|
||||
/// breaks a (deltaX, deltaY) wheel request into the same small-burst-with-inertia
|
||||
/// pattern used for scroll-into-view, so direct wheel calls look human too.
|
||||
/// </summary>
|
||||
public static async Task SmoothWheelAsync(IRawMouse raw, double deltaX, double deltaY, HumanConfig cfg)
|
||||
{
|
||||
// X axis is sent in one go (horizontal scroll is rarely incremental), Y is
|
||||
// chunked for inertia. When both are zero, send a single no-op wheel event so
|
||||
// semantics match Playwright's IMouse.WheelAsync(0, 0).
|
||||
if (deltaY != 0)
|
||||
await SmoothWheelAsync(raw, (int)Math.Round(deltaY), cfg).ConfigureAwait(false);
|
||||
if (deltaX != 0)
|
||||
await raw.WheelAsync(Math.Round(deltaX), 0).ConfigureAwait(false);
|
||||
if (deltaX == 0 && deltaY == 0)
|
||||
await raw.WheelAsync(0, 0).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Humanized scrolling that uses an arbitrary <paramref name="getBox"/> callable
|
||||
/// instead of a CSS selector. Runs the accelerate -> cruise -> decelerate ->
|
||||
/// overshoot behavior.
|
||||
/// </summary>
|
||||
/// <returns>(box, cursorX, cursorY, didScroll) - didScroll is false when the
|
||||
/// element was already in the viewport.</returns>
|
||||
public static async Task<ScrollResult> HumanScrollIntoViewAsync(
|
||||
IRawScrollPage page,
|
||||
IRawMouse raw,
|
||||
Func<Task<BoundingBox?>> getBox,
|
||||
double cursorX, double cursorY,
|
||||
HumanConfig cfg)
|
||||
{
|
||||
var viewport = page.ViewportSize;
|
||||
if (viewport == null)
|
||||
// Headed launches default to no_viewport so the page tracks the real OS
|
||||
// window; ViewportSize is then null. Fall back to the live window
|
||||
// dimensions so humanize works headed (the stealth-relevant mode).
|
||||
viewport = await page.GetLiveWindowSizeAsync().ConfigureAwait(false);
|
||||
if (viewport == null || viewport.Value.Height == 0)
|
||||
throw new InvalidOperationException("Viewport size not available");
|
||||
|
||||
int viewportHeight = viewport.Value.Height;
|
||||
int viewportWidth = viewport.Value.Width;
|
||||
|
||||
var box = await getBox().ConfigureAwait(false);
|
||||
if (box == null)
|
||||
throw new InvalidOperationException("Element not found while scrolling into view");
|
||||
|
||||
if (IsInViewport(box.Value, viewportHeight, cfg))
|
||||
return new ScrollResult(box.Value, cursorX, cursorY, false);
|
||||
|
||||
// Move cursor into scroll area.
|
||||
double scrollAreaX = Math.Round(viewportWidth * HumanRandom.Rand(0.3, 0.7));
|
||||
double scrollAreaY = Math.Round(viewportHeight * HumanRandom.Rand(0.3, 0.7));
|
||||
await HumanMouse.HumanMoveAsync(raw, cursorX, cursorY, scrollAreaX, scrollAreaY, cfg).ConfigureAwait(false);
|
||||
cursorX = scrollAreaX;
|
||||
cursorY = scrollAreaY;
|
||||
await HumanRandom.SleepMsAsync(HumanRandom.RandRange(cfg.ScrollPreMoveDelay)).ConfigureAwait(false);
|
||||
|
||||
// Calculate scroll distance.
|
||||
double targetY = viewportHeight * HumanRandom.Rand(cfg.ScrollTargetZone.Min, cfg.ScrollTargetZone.Max);
|
||||
double elementCenter = box.Value.Y + box.Value.Height / 2;
|
||||
double distanceToScroll = elementCenter - targetY;
|
||||
|
||||
int direction = distanceToScroll > 0 ? 1 : -1;
|
||||
double absDistance = Math.Abs(distanceToScroll);
|
||||
double avgDelta = (cfg.ScrollDeltaBase.Min + cfg.ScrollDeltaBase.Max) / 2;
|
||||
int totalClicks = Math.Max(3, (int)Math.Ceiling(absDistance / avgDelta));
|
||||
int accelSteps = HumanRandom.RandIntRange(cfg.ScrollAccelSteps);
|
||||
int decelSteps = HumanRandom.RandIntRange(cfg.ScrollDecelSteps);
|
||||
|
||||
// Scroll loop: accelerate -> cruise -> decelerate.
|
||||
double scrolled = 0;
|
||||
for (int i = 0; i < totalClicks; i++)
|
||||
{
|
||||
double delta, pause;
|
||||
if (i < accelSteps)
|
||||
{
|
||||
delta = HumanRandom.Rand(80, 100);
|
||||
pause = HumanRandom.RandRange(cfg.ScrollPauseSlow);
|
||||
}
|
||||
else if (i >= totalClicks - decelSteps)
|
||||
{
|
||||
delta = HumanRandom.Rand(60, 90);
|
||||
pause = HumanRandom.RandRange(cfg.ScrollPauseSlow);
|
||||
}
|
||||
else
|
||||
{
|
||||
delta = HumanRandom.RandRange(cfg.ScrollDeltaBase);
|
||||
pause = HumanRandom.RandRange(cfg.ScrollPauseFast);
|
||||
}
|
||||
|
||||
delta *= 1 + (HumanRandom.NextDouble() - 0.5) * 2 * cfg.ScrollDeltaVariance;
|
||||
int deltaInt = (int)(Math.Round(delta) * direction);
|
||||
|
||||
await SmoothWheelAsync(raw, deltaInt, cfg).ConfigureAwait(false);
|
||||
scrolled += Math.Abs(deltaInt);
|
||||
await HumanRandom.SleepMsAsync(pause).ConfigureAwait(false);
|
||||
|
||||
// Check visibility every 3 steps.
|
||||
if (i % 3 == 2 || i == totalClicks - 1)
|
||||
{
|
||||
box = await getBox().ConfigureAwait(false);
|
||||
if (box != null && IsInViewport(box.Value, viewportHeight, cfg))
|
||||
break;
|
||||
}
|
||||
if (scrolled >= absDistance * 1.1)
|
||||
break;
|
||||
}
|
||||
|
||||
// Optional overshoot + correction.
|
||||
if (HumanRandom.NextDouble() < cfg.ScrollOvershootChance)
|
||||
{
|
||||
int overshootPx = (int)(Math.Round(HumanRandom.RandRange(cfg.ScrollOvershootPx)) * direction);
|
||||
await SmoothWheelAsync(raw, overshootPx, cfg).ConfigureAwait(false);
|
||||
await HumanRandom.SleepMsAsync(HumanRandom.RandRange(cfg.ScrollSettleDelay)).ConfigureAwait(false);
|
||||
int corrections = HumanRandom.RandIntRange((1, 2));
|
||||
for (int c = 0; c < corrections; c++)
|
||||
{
|
||||
int corrDelta = (int)(Math.Round(HumanRandom.Rand(40, 80)) * -direction);
|
||||
await SmoothWheelAsync(raw, corrDelta, cfg).ConfigureAwait(false);
|
||||
await HumanRandom.SleepMsAsync(HumanRandom.Rand(100, 250)).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Settle.
|
||||
await HumanRandom.SleepMsAsync(HumanRandom.RandRange(cfg.ScrollSettleDelay)).ConfigureAwait(false);
|
||||
|
||||
box = await getBox().ConfigureAwait(false);
|
||||
if (box == null)
|
||||
throw new InvalidOperationException("Element lost after scrolling into view");
|
||||
|
||||
return new ScrollResult(box.Value, cursorX, cursorY, true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using Microsoft.Playwright;
|
||||
|
||||
namespace CloakBrowser.Human;
|
||||
|
||||
/// <summary>
|
||||
/// Manages a CDP isolated execution context for DOM reads.
|
||||
/// Direct port of Python <c>_AsyncIsolatedWorld</c> (cloakbrowser/human/__init__.py).
|
||||
///
|
||||
/// Produces clean <c>Error.stack</c> traces (no <c>eval at evaluate</c> frames)
|
||||
/// and is invisible to <c>querySelector</c> monkey-patches in the main world.
|
||||
/// The context ID is invalidated on navigation and auto-recreated on next call.
|
||||
/// </summary>
|
||||
public sealed class IsolatedWorld
|
||||
{
|
||||
private readonly IPage _page;
|
||||
private ICDPSession? _cdp;
|
||||
private int? _contextId;
|
||||
|
||||
public IsolatedWorld(IPage page)
|
||||
{
|
||||
_page = page;
|
||||
}
|
||||
|
||||
private async Task<ICDPSession> EnsureCdpAsync()
|
||||
{
|
||||
_cdp ??= await _page.Context.NewCDPSessionAsync(_page).ConfigureAwait(false);
|
||||
return _cdp;
|
||||
}
|
||||
|
||||
private async Task<int> CreateWorldAsync()
|
||||
{
|
||||
var cdp = await EnsureCdpAsync().ConfigureAwait(false);
|
||||
var tree = await cdp.SendAsync("Page.getFrameTree").ConfigureAwait(false);
|
||||
string frameId = tree.Value
|
||||
.GetProperty("frameTree")
|
||||
.GetProperty("frame")
|
||||
.GetProperty("id")
|
||||
.GetString()!;
|
||||
var result = await cdp.SendAsync("Page.createIsolatedWorld", new Dictionary<string, object>
|
||||
{
|
||||
["frameId"] = frameId,
|
||||
["worldName"] = "",
|
||||
["grantUniveralAccess"] = true, // (intentional typo preserved from CDP/source)
|
||||
}).ConfigureAwait(false);
|
||||
_contextId = result.Value.GetProperty("executionContextId").GetInt32();
|
||||
return _contextId.Value;
|
||||
}
|
||||
|
||||
/// <summary>Evaluate JS in the isolated world. Auto-recreates on a stale context. Returns null on failure.</summary>
|
||||
public async Task<JsonElement?> EvaluateAsync(string expression)
|
||||
{
|
||||
if (_contextId == null)
|
||||
await CreateWorldAsync().ConfigureAwait(false);
|
||||
|
||||
for (int attempt = 0; attempt < 2; attempt++)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await _cdp!.SendAsync("Runtime.evaluate", new Dictionary<string, object>
|
||||
{
|
||||
["expression"] = expression,
|
||||
["contextId"] = _contextId!.Value,
|
||||
["returnByValue"] = true,
|
||||
}).ConfigureAwait(false);
|
||||
|
||||
if (result.Value.TryGetProperty("exceptionDetails", out _))
|
||||
{
|
||||
if (attempt == 0)
|
||||
{
|
||||
await CreateWorldAsync().ConfigureAwait(false);
|
||||
continue;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (result.Value.TryGetProperty("result", out var r) &&
|
||||
r.TryGetProperty("value", out var v))
|
||||
{
|
||||
return v;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
if (attempt == 0)
|
||||
{
|
||||
_contextId = null;
|
||||
try { await CreateWorldAsync().ConfigureAwait(false); }
|
||||
catch (Exception) { return null; }
|
||||
continue;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>Evaluate and coerce the result to a bool (false on null/failure).</summary>
|
||||
public async Task<bool> EvaluateBoolAsync(string expression)
|
||||
{
|
||||
var v = await EvaluateAsync(expression).ConfigureAwait(false);
|
||||
if (v == null) return false;
|
||||
return v.Value.ValueKind switch
|
||||
{
|
||||
JsonValueKind.True => true,
|
||||
JsonValueKind.False => false,
|
||||
JsonValueKind.Number => v.Value.GetDouble() != 0,
|
||||
JsonValueKind.String => !string.IsNullOrEmpty(v.Value.GetString()),
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Mark the context as stale - call after navigation.</summary>
|
||||
public void Invalidate() => _contextId = null;
|
||||
|
||||
/// <summary>Get the underlying CDP session (reused for <c>Input.dispatchKeyEvent</c>).</summary>
|
||||
public Task<ICDPSession> GetCdpSessionAsync() => EnsureCdpAsync();
|
||||
|
||||
/// <summary>JSON-encode a string for safe embedding in a JS expression (like Python's json.dumps).</summary>
|
||||
public static string JsonEncode(string s) => JsonSerializer.Serialize(s);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
using System.Text.Json.Nodes;
|
||||
using Microsoft.Playwright;
|
||||
|
||||
namespace CloakBrowser.Human;
|
||||
|
||||
/// <summary>Adapts Playwright's <see cref="IMouse"/> to <see cref="IRawMouse"/>.</summary>
|
||||
internal sealed class PlaywrightRawMouse : IRawMouse
|
||||
{
|
||||
private readonly IMouse _mouse;
|
||||
public PlaywrightRawMouse(IMouse mouse) => _mouse = mouse;
|
||||
|
||||
public Task MoveAsync(double x, double y) => _mouse.MoveAsync((float)x, (float)y);
|
||||
public Task DownAsync() => _mouse.DownAsync();
|
||||
public Task UpAsync() => _mouse.UpAsync();
|
||||
public Task WheelAsync(double deltaX, double deltaY) => _mouse.WheelAsync((float)deltaX, (float)deltaY);
|
||||
}
|
||||
|
||||
/// <summary>Adapts Playwright's <see cref="IKeyboard"/> to <see cref="IRawKeyboard"/>.</summary>
|
||||
internal sealed class PlaywrightRawKeyboard : IRawKeyboard
|
||||
{
|
||||
private readonly IKeyboard _keyboard;
|
||||
public PlaywrightRawKeyboard(IKeyboard keyboard) => _keyboard = keyboard;
|
||||
|
||||
public Task DownAsync(string key) => _keyboard.DownAsync(key);
|
||||
public Task UpAsync(string key) => _keyboard.UpAsync(key);
|
||||
public Task TypeAsync(string text) => _keyboard.TypeAsync(text);
|
||||
public Task InsertTextAsync(string text) => _keyboard.InsertTextAsync(text);
|
||||
}
|
||||
|
||||
/// <summary>Adapts a Playwright <see cref="ICDPSession"/> to <see cref="IRawCdpSession"/>.</summary>
|
||||
internal sealed class PlaywrightCdpSession : IRawCdpSession
|
||||
{
|
||||
private readonly ICDPSession _session;
|
||||
public PlaywrightCdpSession(ICDPSession session) => _session = session;
|
||||
|
||||
public async Task SendAsync(string method, JsonObject? args = null)
|
||||
{
|
||||
if (args == null)
|
||||
{
|
||||
await _session.SendAsync(method).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
var dict = new Dictionary<string, object>();
|
||||
foreach (var kv in args)
|
||||
{
|
||||
if (kv.Value is JsonValue jv)
|
||||
{
|
||||
if (jv.TryGetValue<int>(out var i)) dict[kv.Key] = i;
|
||||
else if (jv.TryGetValue<double>(out var d)) dict[kv.Key] = d;
|
||||
else if (jv.TryGetValue<bool>(out var b)) dict[kv.Key] = b;
|
||||
else dict[kv.Key] = jv.ToString();
|
||||
}
|
||||
else if (kv.Value != null)
|
||||
{
|
||||
dict[kv.Key] = kv.Value;
|
||||
}
|
||||
}
|
||||
await _session.SendAsync(method, dict).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Adapts a Playwright <see cref="IPage"/> to <see cref="IRawEvaluator"/> (fallback shift-symbol path).</summary>
|
||||
internal sealed class PlaywrightEvaluator : IRawEvaluator
|
||||
{
|
||||
private readonly IPage _page;
|
||||
public PlaywrightEvaluator(IPage page) => _page = page;
|
||||
|
||||
public async Task EvaluateAsync(string expression, object? arg)
|
||||
{
|
||||
await _page.EvaluateAsync(expression, arg).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Adapts a Playwright <see cref="IPage"/> to <see cref="IRawScrollPage"/>.</summary>
|
||||
internal sealed class PlaywrightScrollPage : IRawScrollPage
|
||||
{
|
||||
private readonly IPage _page;
|
||||
public PlaywrightScrollPage(IPage page) => _page = page;
|
||||
|
||||
public (int Width, int Height)? ViewportSize
|
||||
{
|
||||
get
|
||||
{
|
||||
var vs = _page.ViewportSize;
|
||||
return vs == null ? null : (vs.Width, vs.Height);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<(int Width, int Height)?> GetLiveWindowSizeAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var dims = await _page.EvaluateAsync<WindowDims>(
|
||||
"() => ({ width: window.innerWidth, height: window.innerHeight })")
|
||||
.ConfigureAwait(false);
|
||||
if (dims.Width <= 0 || dims.Height <= 0)
|
||||
return null;
|
||||
return (dims.Width, dims.Height);
|
||||
}
|
||||
catch (System.Exception)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private struct WindowDims
|
||||
{
|
||||
public int Width { get; set; }
|
||||
public int Height { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
using CloakBrowser.Human;
|
||||
|
||||
namespace CloakBrowser;
|
||||
|
||||
/// <summary>
|
||||
/// Options for <see cref="CloakLauncher.LaunchAsync(LaunchOptions)"/> and friends.
|
||||
/// Mirrors the keyword arguments of the Python <c>launch()</c> family.
|
||||
/// </summary>
|
||||
public class LaunchOptions
|
||||
{
|
||||
/// <summary>Run in headless mode (default true).</summary>
|
||||
public bool Headless { get; set; } = true;
|
||||
|
||||
/// <summary>Proxy: a URL string (<c>http://user:pass@host:port</c>) or a <see cref="ProxySettings"/>.</summary>
|
||||
public object? Proxy { get; set; }
|
||||
|
||||
/// <summary>Additional Chromium CLI arguments.</summary>
|
||||
public List<string>? Args { get; set; }
|
||||
|
||||
/// <summary>Include the default stealth fingerprint args (default true).</summary>
|
||||
public bool StealthArgs { get; set; } = true;
|
||||
|
||||
/// <summary>IANA timezone, e.g. <c>America/New_York</c> - sets <c>--fingerprint-timezone</c>.</summary>
|
||||
public string? Timezone { get; set; }
|
||||
|
||||
/// <summary>BCP 47 locale, e.g. <c>en-US</c> - sets <c>--lang</c> and <c>--fingerprint-locale</c>.</summary>
|
||||
public string? Locale { get; set; }
|
||||
|
||||
/// <summary>Auto-detect timezone/locale (and WebRTC exit IP) from the proxy IP (default false).</summary>
|
||||
public bool GeoIp { get; set; }
|
||||
|
||||
/// <summary>Enable the human-like behavior layer when creating pages via <see cref="CloakBrowserHandle"/>.</summary>
|
||||
public bool Humanize { get; set; }
|
||||
|
||||
/// <summary>Humanize preset (default <see cref="HumanPreset.Default"/>).</summary>
|
||||
public HumanPreset HumanPreset { get; set; } = HumanPreset.Default;
|
||||
|
||||
/// <summary>Custom humanize config overrides (snake_case or PascalCase keys).</summary>
|
||||
public IReadOnlyDictionary<string, object>? HumanConfig { get; set; }
|
||||
|
||||
/// <summary>Chrome extension paths to load.</summary>
|
||||
public List<string>? ExtensionPaths { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// CloakBrowser Pro license key. Also read from the <c>CLOAKBROWSER_LICENSE_KEY</c>
|
||||
/// env var or <c>~/.cloakbrowser/license.key</c>. With a valid key the latest Pro
|
||||
/// binary is downloaded from cloakbrowser.dev; without one, the free binary is used.
|
||||
/// </summary>
|
||||
public string? LicenseKey { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Exact Chromium version pin. Also read from the <c>CLOAKBROWSER_VERSION</c>
|
||||
/// env var. When set, downloads (and caches) this specific version instead of
|
||||
/// the platform default. Pinning does NOT overwrite the 'latest' version marker
|
||||
/// — a subsequent unpinned launch will use the latest available version, not the
|
||||
/// pinned one. Port of Python/JS <c>browser_version</c> / <c>browserVersion</c>.
|
||||
/// </summary>
|
||||
public string? BrowserVersion { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Internal: suppress the auto <c>--start-maximized</c> flag. Set by the context
|
||||
/// launchers when the caller chose a viewport geometry, so the window is not also
|
||||
/// maximized. Mirrors Python <c>_suppress_maximize</c> / JS <c>explicitViewport</c>.
|
||||
/// </summary>
|
||||
internal bool SuppressMaximize { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Options for context-producing launchers (adds context-level emulation settings).</summary>
|
||||
public class LaunchContextOptions : LaunchOptions
|
||||
{
|
||||
/// <summary>Custom user agent string.</summary>
|
||||
public string? UserAgent { get; set; }
|
||||
|
||||
/// <summary>Viewport size. Null means "use default 1920x947". Set <see cref="NoViewport"/> to disable.</summary>
|
||||
public (int Width, int Height)? Viewport { get; set; }
|
||||
|
||||
/// <summary>Disable viewport emulation (use the OS window size).</summary>
|
||||
public bool NoViewport { get; set; }
|
||||
|
||||
/// <summary>Color scheme preference: <c>light</c>, <c>dark</c>, or <c>no-preference</c>.</summary>
|
||||
public string? ColorScheme { get; set; }
|
||||
|
||||
/// <summary>Path to a Playwright storage-state JSON to restore cookies/localStorage.</summary>
|
||||
public string? StorageStatePath { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace CloakBrowser;
|
||||
|
||||
/// <summary>
|
||||
/// Result of a CloakBrowser Pro license validation.
|
||||
/// Mirrors the Python <c>LicenseInfo</c> dataclass / JS <c>LicenseInfo</c> interface.
|
||||
/// </summary>
|
||||
public sealed record LicenseInfo(bool Valid, string Plan, string? Expires);
|
||||
|
||||
/// <summary>
|
||||
/// Source of a resolved license key. Determines whether env injection
|
||||
/// into the child browser process is needed.
|
||||
/// </summary>
|
||||
internal enum LicenseKeySource
|
||||
{
|
||||
/// <summary>Explicit <c>licenseKey</c> param.</summary>
|
||||
Param,
|
||||
/// <summary><c>CLOAKBROWSER_LICENSE_KEY</c> env var.</summary>
|
||||
Env,
|
||||
/// <summary>Default <c>~/.cloakbrowser/license.key</c> (binary reads it directly).</summary>
|
||||
DefaultFile,
|
||||
/// <summary>Custom cache dir <c>license.key</c> (binary can't see it).</summary>
|
||||
CustomFile,
|
||||
/// <summary>No key resolved.</summary>
|
||||
None,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// License validation and caching for CloakBrowser Pro.
|
||||
///
|
||||
/// Handles license-key resolution (param -> env -> file), server validation with a
|
||||
/// local 24h cache, and Pro version lookups. Direct port of Python
|
||||
/// <c>cloakbrowser/license.py</c> and JS <c>js/src/license.ts</c>.
|
||||
/// </summary>
|
||||
public static class License
|
||||
{
|
||||
public const string ValidateUrl = "https://cloakbrowser.dev/api/license/validate";
|
||||
public const string ProVersionUrl = "https://cloakbrowser.dev/api/download/version";
|
||||
|
||||
// 24 hours / 1 hour, in seconds (matches Python's LICENSE_CACHE_TTL / PRO_VERSION_CHECK_INTERVAL).
|
||||
private const double LicenseCacheTtl = 86400;
|
||||
private const double ProVersionCheckInterval = 3600;
|
||||
|
||||
// Not readonly so tests can swap in an HttpClient backed by a recording
|
||||
// handler to exercise the real request path (header, etc.) without network.
|
||||
internal static HttpClient Http = CreateHttpClient();
|
||||
|
||||
private static HttpClient CreateHttpClient()
|
||||
{
|
||||
var client = new HttpClient { Timeout = TimeSpan.FromSeconds(10) };
|
||||
client.DefaultRequestHeaders.UserAgent.ParseAdd($"cloakbrowser-dotnet/{CloakVersion.Version}");
|
||||
return client;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Testing seams - mirror the monkey-patching the Python/JS tests rely on.
|
||||
// Null means "use real behavior" (HTTP). Tests inject deterministic results
|
||||
// without touching the network.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <summary>Overrides the server license-validation call for tests. Null -> real HTTP.</summary>
|
||||
internal static Func<string, LicenseInfo?>? ValidateLicenseOverride;
|
||||
|
||||
/// <summary>Overrides the Pro latest-version lookup for tests. Null -> real HTTP.</summary>
|
||||
internal static Func<string?>? ProLatestVersionOverride;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the user home directory used to detect the default
|
||||
/// <c>~/.cloakbrowser</c> cache path. A test seam mirroring the Python
|
||||
/// <c>Path.home</c> / JS <c>os.homedir</c> mocks. Null -> real UserProfile.
|
||||
/// </summary>
|
||||
internal static Func<string>? HomeDirOverride;
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Key source tracking — determines whether env injection is needed.
|
||||
// (The binary reads the default file path directly, so env injection
|
||||
// is only required for explicit params or custom cache-dir files.)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <summary>Resolve license key with source tracking for env-injection decisions.</summary>
|
||||
internal static (string? Key, LicenseKeySource Source) ResolveLicenseKeyWithSource(
|
||||
string? licenseKey = null)
|
||||
{
|
||||
// 1. Explicit param
|
||||
var trimmed = licenseKey?.Trim();
|
||||
if (!string.IsNullOrEmpty(trimmed))
|
||||
return (trimmed, LicenseKeySource.Param);
|
||||
|
||||
// 2. Environment variable
|
||||
var envKey = (Environment.GetEnvironmentVariable("CLOAKBROWSER_LICENSE_KEY") ?? "").Trim();
|
||||
if (!string.IsNullOrEmpty(envKey))
|
||||
return (envKey, LicenseKeySource.Env);
|
||||
|
||||
// 3. File in the wrapper cache dir
|
||||
try
|
||||
{
|
||||
var cacheDir = Config.GetCacheDir();
|
||||
var keyFile = Path.Combine(cacheDir, "license.key");
|
||||
var content = File.ReadAllText(keyFile).Trim();
|
||||
if (!string.IsNullOrEmpty(content))
|
||||
{
|
||||
var homeDir = HomeDirOverride?.Invoke()
|
||||
?? Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
|
||||
var defaultCache = Path.Combine(homeDir, ".cloakbrowser");
|
||||
var source = string.Equals(
|
||||
Path.GetFullPath(cacheDir),
|
||||
Path.GetFullPath(defaultCache),
|
||||
StringComparison.OrdinalIgnoreCase)
|
||||
? LicenseKeySource.DefaultFile
|
||||
: LicenseKeySource.CustomFile;
|
||||
return (content, source);
|
||||
}
|
||||
}
|
||||
catch (IOException) { /* file missing/unreadable */ }
|
||||
catch (UnauthorizedAccessException) { }
|
||||
|
||||
return (null, LicenseKeySource.None);
|
||||
}
|
||||
|
||||
/// <summary>Resolve the license key: explicit param > env var > file > null.</summary>
|
||||
public static string? ResolveLicenseKey(string? licenseKey = null)
|
||||
{
|
||||
return ResolveLicenseKeyWithSource(licenseKey).Key;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build a child-process env dict with any needed license key injection.
|
||||
///
|
||||
/// The Pro binary reads <c>CLOAKBROWSER_LICENSE_KEY</c> from its own process
|
||||
/// environment at startup. This helper merges the resolved key into the
|
||||
/// child process env dict <b>only</b> when injection is necessary:
|
||||
///
|
||||
/// <list type="bullet">
|
||||
/// <item><description><c>Param</c> / <c>CustomFile</c> — inject into child env.</description></item>
|
||||
/// <item><description><c>Env</c> — child inherits from parent (no injection).</description></item>
|
||||
/// <item><description><c>DefaultFile</c> — binary reads the file directly (no injection), unless a custom userEnv is passed (Playwright replaces the child env and can drop HOME) — then inject.</description></item>
|
||||
/// </list>
|
||||
///
|
||||
/// When <paramref name="userEnv"/> is provided it is used as the base
|
||||
/// (Playwright replaces the child env entirely when <c>env</c> is set),
|
||||
/// with the key injected only when needed.
|
||||
///
|
||||
/// Returns <c>null</c> when no injection is needed and no custom userEnv
|
||||
/// was given — Playwright treats <c>env=null</c> as "inherit parent env".
|
||||
/// </summary>
|
||||
public static Dictionary<string, string>? BuildLaunchEnv(
|
||||
string? licenseKey = null,
|
||||
Dictionary<string, string>? userEnv = null)
|
||||
{
|
||||
var (key, source) = ResolveLicenseKeyWithSource(licenseKey);
|
||||
|
||||
// Default file: binary reads it directly — no env injection needed,
|
||||
// UNLESS the caller passes a custom env. Playwright replaces (not
|
||||
// merges) the child env, which can drop HOME and hide the file from
|
||||
// the binary, so inject the key too then (fall through to the merge).
|
||||
if (source == LicenseKeySource.DefaultFile && userEnv == null)
|
||||
return null;
|
||||
|
||||
// No key at all: pass through user env or null.
|
||||
if (source == LicenseKeySource.None || key == null)
|
||||
return userEnv;
|
||||
|
||||
// Env source, no custom user env: child inherits parent env, which
|
||||
// already has CLOAKBROWSER_LICENSE_KEY.
|
||||
if (source == LicenseKeySource.Env && userEnv == null)
|
||||
return null;
|
||||
|
||||
// Build the merged env dict.
|
||||
var merged = userEnv != null
|
||||
? new Dictionary<string, string>(userEnv)
|
||||
: Environment.GetEnvironmentVariables()
|
||||
.Cast<System.Collections.DictionaryEntry>()
|
||||
.ToDictionary(e => (string)e.Key, e => (string)e.Value!);
|
||||
|
||||
// For Param/CustomFile this is THE injection into the child env.
|
||||
// For Env source with a custom userEnv this ensures the key persists
|
||||
// through the user's env override (Playwright replaces, not merges).
|
||||
merged["CLOAKBROWSER_LICENSE_KEY"] = key;
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validate a license key with the CloakBrowser server.
|
||||
///
|
||||
/// Checks a local file cache first (24h TTL). Falls back to a stale cache if the
|
||||
/// server is unreachable. Returns the <see cref="LicenseInfo"/> on success, or
|
||||
/// null on total failure (server unreachable and no cache).
|
||||
/// </summary>
|
||||
public static LicenseInfo? ValidateLicense(string licenseKey)
|
||||
{
|
||||
if (ValidateLicenseOverride != null)
|
||||
return ValidateLicenseOverride(licenseKey);
|
||||
|
||||
var cachePath = Path.Combine(Config.GetCacheDir(), ".license_cache");
|
||||
var keySha = Sha256Hex(licenseKey);
|
||||
|
||||
var cached = ReadCache(cachePath, keySha);
|
||||
if (cached != null)
|
||||
return cached;
|
||||
|
||||
try
|
||||
{
|
||||
var body = new StringContent(
|
||||
JsonSerializer.Serialize(new Dictionary<string, string> { ["license_key"] = licenseKey }),
|
||||
Encoding.UTF8, "application/json");
|
||||
using var resp = Http.PostAsync(ValidateUrl, body).GetAwaiter().GetResult();
|
||||
resp.EnsureSuccessStatusCode();
|
||||
var json = resp.Content.ReadAsStringAsync().GetAwaiter().GetResult();
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
var root = doc.RootElement;
|
||||
|
||||
var info = new LicenseInfo(
|
||||
Valid: root.TryGetProperty("valid", out var v) && v.ValueKind == JsonValueKind.True,
|
||||
Plan: root.TryGetProperty("plan", out var p) && p.ValueKind == JsonValueKind.String
|
||||
? p.GetString() ?? "solo" : "solo",
|
||||
Expires: root.TryGetProperty("expires", out var e) && e.ValueKind == JsonValueKind.String
|
||||
? e.GetString() : null);
|
||||
|
||||
if (info.Valid)
|
||||
WriteCache(cachePath, keySha, info);
|
||||
return info;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
CloakLog.Warning("License validation request failed: {0}", ex.Message);
|
||||
|
||||
var stale = ReadCache(cachePath, keySha, ignoreTtl: true);
|
||||
if (stale != null)
|
||||
{
|
||||
CloakLog.Warning("Using cached license validation (server unreachable)");
|
||||
return stale;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get the latest Pro binary version from the server.
|
||||
/// Rate-limited to 1 call per hour via a marker file.
|
||||
/// </summary>
|
||||
public static string? GetProLatestVersion()
|
||||
{
|
||||
if (ProLatestVersionOverride != null)
|
||||
return ProLatestVersionOverride();
|
||||
|
||||
var marker = Path.Combine(Config.GetCacheDir(), $".last_pro_version_check_{Config.GetPlatformTag()}");
|
||||
|
||||
if (File.Exists(marker))
|
||||
{
|
||||
try
|
||||
{
|
||||
var age = (DateTime.UtcNow - File.GetLastWriteTimeUtc(marker)).TotalSeconds;
|
||||
if (age < ProVersionCheckInterval)
|
||||
{
|
||||
var content = File.ReadAllText(marker).Trim();
|
||||
return string.IsNullOrEmpty(content) ? null : content;
|
||||
}
|
||||
}
|
||||
catch (IOException) { /* unreadable - proceed with fetch */ }
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var req = new HttpRequestMessage(HttpMethod.Get, ProVersionUrl);
|
||||
req.Headers.Add("X-Platform", Config.GetPlatformTag());
|
||||
using var resp = Http.SendAsync(req).GetAwaiter().GetResult();
|
||||
resp.EnsureSuccessStatusCode();
|
||||
var json = resp.Content.ReadAsStringAsync().GetAwaiter().GetResult();
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
var version = doc.RootElement.TryGetProperty("version", out var ve) && ve.ValueKind == JsonValueKind.String
|
||||
? ve.GetString() : null;
|
||||
if (string.IsNullOrEmpty(version))
|
||||
return null;
|
||||
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(marker)!);
|
||||
var tmp = marker + ".tmp";
|
||||
File.WriteAllText(tmp, version);
|
||||
if (File.Exists(marker)) File.Delete(marker);
|
||||
File.Move(tmp, marker);
|
||||
}
|
||||
catch (IOException) { /* non-fatal */ }
|
||||
|
||||
return version;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
CloakLog.Debug("Pro version check failed: {0}", ex.Message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Cache helpers (atomic write via tmp+rename, like Python/JS).
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private sealed record CacheData(
|
||||
string? key_sha256, bool valid, string? plan, string? expires, double validated_at);
|
||||
|
||||
private static LicenseInfo? ReadCache(string cachePath, string keySha, bool ignoreTtl = false)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!File.Exists(cachePath))
|
||||
return null;
|
||||
|
||||
using var doc = JsonDocument.Parse(File.ReadAllText(cachePath));
|
||||
var root = doc.RootElement;
|
||||
|
||||
var cachedSha = root.TryGetProperty("key_sha256", out var ks) && ks.ValueKind == JsonValueKind.String
|
||||
? ks.GetString() : null;
|
||||
if (cachedSha != keySha)
|
||||
return null;
|
||||
|
||||
if (!ignoreTtl)
|
||||
{
|
||||
// A non-numeric validated_at (corrupted cache) is treated as absent
|
||||
// rather than silently trusting the entry.
|
||||
if (!root.TryGetProperty("validated_at", out var va) || va.ValueKind != JsonValueKind.Number)
|
||||
return null;
|
||||
var validatedAt = va.GetDouble();
|
||||
var now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() / 1000.0;
|
||||
if (now - validatedAt > LicenseCacheTtl)
|
||||
return null;
|
||||
}
|
||||
|
||||
var plan = root.TryGetProperty("plan", out var pe) && pe.ValueKind == JsonValueKind.String
|
||||
? pe.GetString() ?? "solo" : "solo";
|
||||
var expires = root.TryGetProperty("expires", out var ee) && ee.ValueKind == JsonValueKind.String
|
||||
? ee.GetString() : null;
|
||||
var valid = root.TryGetProperty("valid", out var ve) && ve.ValueKind == JsonValueKind.True;
|
||||
|
||||
// An expired license is reported invalid even if it was cached as valid.
|
||||
if (!string.IsNullOrEmpty(expires))
|
||||
{
|
||||
if (DateTimeOffset.TryParse(expires, System.Globalization.CultureInfo.InvariantCulture,
|
||||
System.Globalization.DateTimeStyles.AssumeUniversal | System.Globalization.DateTimeStyles.AdjustToUniversal,
|
||||
out var expDt))
|
||||
{
|
||||
if (expDt < DateTimeOffset.UtcNow)
|
||||
return new LicenseInfo(false, plan, expires);
|
||||
}
|
||||
}
|
||||
|
||||
return new LicenseInfo(valid, plan, expires);
|
||||
}
|
||||
catch (Exception ex) when (ex is JsonException or IOException or UnauthorizedAccessException)
|
||||
{
|
||||
// Any unreadable/corrupt cache is treated as absent rather than crashing.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static void WriteCache(string cachePath, string keySha, LicenseInfo info)
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(cachePath)!);
|
||||
var tmpPath = cachePath + ".tmp";
|
||||
var now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() / 1000.0;
|
||||
var payload = JsonSerializer.Serialize(new CacheData(
|
||||
key_sha256: keySha, valid: info.Valid, plan: info.Plan,
|
||||
expires: info.Expires, validated_at: now));
|
||||
File.WriteAllText(tmpPath, payload);
|
||||
if (File.Exists(cachePath)) File.Delete(cachePath);
|
||||
File.Move(tmpPath, cachePath);
|
||||
}
|
||||
catch (IOException ex)
|
||||
{
|
||||
CloakLog.Debug("Failed to write license cache: {0}", ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private static string Sha256Hex(string s)
|
||||
{
|
||||
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(s));
|
||||
return Convert.ToHexString(bytes).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,346 @@
|
||||
namespace CloakBrowser;
|
||||
|
||||
/// <summary>
|
||||
/// Proxy resolution: maps a proxy (string URL or <see cref="ProxySettings"/>) into
|
||||
/// Playwright proxy options and/or Chrome <c>--proxy-server</c> args.
|
||||
/// Direct port of the proxy helpers in Python <c>cloakbrowser/browser.py</c>.
|
||||
/// </summary>
|
||||
internal static class ProxyResolver
|
||||
{
|
||||
/// <summary>Result of resolving a proxy: Playwright proxy (or null) plus extra Chrome args.</summary>
|
||||
public sealed record ProxyResolution(Microsoft.Playwright.Proxy? PlaywrightProxy, List<string> ExtraArgs);
|
||||
|
||||
// -- small URL parsing model -------------------------------------------------
|
||||
|
||||
/// <summary>A minimal parsed proxy URL (mirrors the pieces Python's urlparse exposes).</summary>
|
||||
private sealed class ParsedUrl
|
||||
{
|
||||
public string Scheme = "";
|
||||
public string? Username; // null = absent, "" = present-but-empty
|
||||
public string? Password;
|
||||
public string Host = "";
|
||||
public int? Port;
|
||||
public string Path = "";
|
||||
public string Query = "";
|
||||
public string Fragment = "";
|
||||
}
|
||||
|
||||
/// <summary>Prepend <c>http://</c> to schemeless proxy URLs so parsers can extract the hostname.</summary>
|
||||
public static string EnsureProxyScheme(string proxyUrl) =>
|
||||
proxyUrl.Contains("://") ? proxyUrl : $"http://{proxyUrl}";
|
||||
|
||||
private static ParsedUrl ParseUrl(string url)
|
||||
{
|
||||
var p = new ParsedUrl();
|
||||
string rest = url;
|
||||
|
||||
int schemeIdx = rest.IndexOf("://", StringComparison.Ordinal);
|
||||
if (schemeIdx >= 0)
|
||||
{
|
||||
p.Scheme = rest[..schemeIdx].ToLowerInvariant();
|
||||
rest = rest[(schemeIdx + 3)..];
|
||||
}
|
||||
|
||||
// Split off fragment.
|
||||
int hashIdx = rest.IndexOf('#');
|
||||
if (hashIdx >= 0) { p.Fragment = rest[(hashIdx + 1)..]; rest = rest[..hashIdx]; }
|
||||
|
||||
// Split off query.
|
||||
int qIdx = rest.IndexOf('?');
|
||||
if (qIdx >= 0) { p.Query = rest[(qIdx + 1)..]; rest = rest[..qIdx]; }
|
||||
|
||||
// Split off path.
|
||||
int slashIdx = rest.IndexOf('/');
|
||||
string netloc;
|
||||
if (slashIdx >= 0) { p.Path = rest[slashIdx..]; netloc = rest[..slashIdx]; }
|
||||
else netloc = rest;
|
||||
|
||||
// Userinfo.
|
||||
int atIdx = netloc.LastIndexOf('@');
|
||||
string hostport;
|
||||
if (atIdx >= 0)
|
||||
{
|
||||
string userinfo = netloc[..atIdx];
|
||||
hostport = netloc[(atIdx + 1)..];
|
||||
int colon = userinfo.IndexOf(':');
|
||||
if (colon >= 0)
|
||||
{
|
||||
p.Username = userinfo[..colon];
|
||||
p.Password = userinfo[(colon + 1)..];
|
||||
}
|
||||
else
|
||||
{
|
||||
p.Username = userinfo;
|
||||
p.Password = null;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
hostport = netloc;
|
||||
}
|
||||
|
||||
// Host / port (handle IPv6 literal in brackets).
|
||||
if (hostport.StartsWith('['))
|
||||
{
|
||||
int close = hostport.IndexOf(']');
|
||||
p.Host = hostport[1..close];
|
||||
string after = hostport[(close + 1)..];
|
||||
if (after.StartsWith(':'))
|
||||
p.Port = ParsePort(after[1..]);
|
||||
}
|
||||
else
|
||||
{
|
||||
int colon = hostport.LastIndexOf(':');
|
||||
if (colon >= 0)
|
||||
{
|
||||
p.Host = hostport[..colon];
|
||||
p.Port = ParsePort(hostport[(colon + 1)..]);
|
||||
}
|
||||
else
|
||||
{
|
||||
p.Host = hostport;
|
||||
}
|
||||
}
|
||||
// Python's urlparse().hostname cosmetically lowercases the host; match it so
|
||||
// the assembled proxy URL/server string is byte-for-byte identical.
|
||||
p.Host = p.Host.ToLowerInvariant();
|
||||
return p;
|
||||
}
|
||||
|
||||
private static int? ParsePort(string s)
|
||||
{
|
||||
if (string.IsNullOrEmpty(s)) return null;
|
||||
if (!int.TryParse(s, out var port) || port < 0 || port > 65535)
|
||||
throw new FormatException($"Invalid port: {s}");
|
||||
return port;
|
||||
}
|
||||
|
||||
/// <summary>Percent-encode like Python's <c>quote(safe="")</c>.</summary>
|
||||
private static string Quote(string s) => Uri.EscapeDataString(s);
|
||||
|
||||
/// <summary>Percent-decode like Python's <c>unquote</c>.</summary>
|
||||
private static string Unquote(string s) => Uri.UnescapeDataString(s);
|
||||
|
||||
private static string AssembleProxyUrl(
|
||||
string scheme, string host, int? port,
|
||||
string encUser, string? encPass,
|
||||
string path = "", string query = "", string fragment = "")
|
||||
{
|
||||
if (host.Contains(':')) // IPv6 literal - re-add brackets
|
||||
host = $"[{host}]";
|
||||
string userinfo;
|
||||
if (encPass != null)
|
||||
userinfo = $"{encUser}:{encPass}@";
|
||||
else if (!string.IsNullOrEmpty(encUser))
|
||||
userinfo = $"{encUser}@";
|
||||
else
|
||||
userinfo = "";
|
||||
string netloc = $"{userinfo}{host}";
|
||||
if (port != null)
|
||||
netloc += $":{port}";
|
||||
var sb = new System.Text.StringBuilder();
|
||||
if (!string.IsNullOrEmpty(scheme)) sb.Append(scheme).Append("://");
|
||||
sb.Append(netloc).Append(path);
|
||||
if (!string.IsNullOrEmpty(query)) sb.Append('?').Append(query);
|
||||
if (!string.IsNullOrEmpty(fragment)) sb.Append('#').Append(fragment);
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
// -- SOCKS handling ----------------------------------------------------------
|
||||
|
||||
public static bool IsSocksProxy(string? url) =>
|
||||
url != null && (url.StartsWith("socks5://", StringComparison.OrdinalIgnoreCase)
|
||||
|| url.StartsWith("socks5h://", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
public static bool IsSocksProxy(ProxySettings proxy) => IsSocksProxy(proxy.Server);
|
||||
|
||||
private static string ReconstructSocksUrl(ProxySettings proxy)
|
||||
{
|
||||
string server = proxy.Server;
|
||||
string username = proxy.Username ?? "";
|
||||
string password = proxy.Password ?? "";
|
||||
if (string.IsNullOrEmpty(username))
|
||||
return server;
|
||||
var parsed = ParseUrl(server);
|
||||
string encUser = Quote(username);
|
||||
string? encPass = string.IsNullOrEmpty(password) ? null : Quote(password);
|
||||
return AssembleProxyUrl(parsed.Scheme, parsed.Host, parsed.Port, encUser, encPass, parsed.Path);
|
||||
}
|
||||
|
||||
private static string NormalizeSocksStringUrl(string url)
|
||||
{
|
||||
ParsedUrl parsed;
|
||||
try { parsed = ParseUrl(url); }
|
||||
catch (FormatException e)
|
||||
{
|
||||
CloakLog.Warning($"Malformed SOCKS5 proxy URL, passing through unchanged: {e.Message}");
|
||||
return url;
|
||||
}
|
||||
if (parsed.Username == null && parsed.Password == null)
|
||||
return url;
|
||||
string rawUser = parsed.Username ?? "";
|
||||
string encUser = string.IsNullOrEmpty(rawUser) ? "" : Quote(Unquote(rawUser));
|
||||
string? rawPass = parsed.Password;
|
||||
string? encPass = parsed.Password != null
|
||||
? (string.IsNullOrEmpty(parsed.Password) ? "" : Quote(Unquote(parsed.Password)))
|
||||
: null;
|
||||
string normalized = AssembleProxyUrl(parsed.Scheme, parsed.Host, parsed.Port, encUser, encPass,
|
||||
parsed.Path, parsed.Query, parsed.Fragment);
|
||||
if (encUser != rawUser || encPass != rawPass)
|
||||
CloakLog.Info("Auto URL-encoded SOCKS5 proxy credentials (special characters detected). " +
|
||||
"Pre-encode the URL to suppress this notice.");
|
||||
return normalized;
|
||||
}
|
||||
|
||||
// -- HTTP handling -----------------------------------------------------------
|
||||
|
||||
private static bool HasCredentials(ProxySettings proxy) => !string.IsNullOrEmpty(proxy.Username);
|
||||
private static bool HasCredentials(string proxy) => proxy.Contains('@');
|
||||
|
||||
private static string ReconstructHttpUrl(ProxySettings proxy)
|
||||
{
|
||||
string server = proxy.Server;
|
||||
string username = proxy.Username ?? "";
|
||||
string password = proxy.Password ?? "";
|
||||
if (string.IsNullOrEmpty(username))
|
||||
return server;
|
||||
var parsed = ParseUrl(EnsureProxyScheme(server));
|
||||
string encUser = Quote(username);
|
||||
string? encPass = string.IsNullOrEmpty(password) ? null : Quote(password);
|
||||
return AssembleProxyUrl(parsed.Scheme, parsed.Host, parsed.Port, encUser, encPass, parsed.Path);
|
||||
}
|
||||
|
||||
private static string NormalizeHttpStringUrl(string url)
|
||||
{
|
||||
string normalized = url.Contains("://") ? url : $"http://{url}";
|
||||
ParsedUrl parsed;
|
||||
try { parsed = ParseUrl(normalized); }
|
||||
catch (FormatException e)
|
||||
{
|
||||
CloakLog.Warning($"Malformed HTTP proxy URL, passing through unchanged: {e.Message}");
|
||||
return normalized;
|
||||
}
|
||||
if (parsed.Username == null && parsed.Password == null)
|
||||
return normalized;
|
||||
string rawUser = parsed.Username ?? "";
|
||||
string encUser = string.IsNullOrEmpty(rawUser) ? "" : Quote(Unquote(rawUser));
|
||||
string? rawPass = parsed.Password;
|
||||
string? encPass = parsed.Password != null
|
||||
? (string.IsNullOrEmpty(parsed.Password) ? "" : Quote(Unquote(parsed.Password)))
|
||||
: null;
|
||||
string result = AssembleProxyUrl(parsed.Scheme, parsed.Host, parsed.Port, encUser, encPass,
|
||||
parsed.Path, parsed.Query, parsed.Fragment);
|
||||
if (encUser != rawUser || encPass != rawPass)
|
||||
CloakLog.Info("Auto URL-encoded HTTP proxy credentials (special characters detected). " +
|
||||
"Pre-encode the URL to suppress this notice.");
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>Parse an HTTP(S) proxy URL into a Playwright <see cref="Microsoft.Playwright.Proxy"/>.</summary>
|
||||
private static Microsoft.Playwright.Proxy ParseProxyUrl(string proxy)
|
||||
{
|
||||
string normalized = proxy;
|
||||
if (proxy.Contains('@') && !proxy.Contains("://"))
|
||||
normalized = $"http://{proxy}";
|
||||
|
||||
var parsed = ParseUrl(normalized);
|
||||
if (string.IsNullOrEmpty(parsed.Username))
|
||||
return new Microsoft.Playwright.Proxy { Server = proxy };
|
||||
|
||||
string netloc = parsed.Host;
|
||||
if (parsed.Port != null) netloc += $":{parsed.Port}";
|
||||
var sb = new System.Text.StringBuilder();
|
||||
if (!string.IsNullOrEmpty(parsed.Scheme)) sb.Append(parsed.Scheme).Append("://");
|
||||
sb.Append(netloc).Append(parsed.Path);
|
||||
|
||||
var result = new Microsoft.Playwright.Proxy
|
||||
{
|
||||
Server = sb.ToString(),
|
||||
Username = Unquote(parsed.Username!),
|
||||
};
|
||||
if (!string.IsNullOrEmpty(parsed.Password))
|
||||
result.Password = Unquote(parsed.Password!);
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>Extract a normalized proxy URL string from a string or dict proxy (for geoip / webrtc).</summary>
|
||||
public static string? ExtractProxyUrl(object? proxy)
|
||||
{
|
||||
switch (proxy)
|
||||
{
|
||||
case null:
|
||||
return null;
|
||||
case ProxySettings ps:
|
||||
if (string.IsNullOrEmpty(ps.Server)) return null;
|
||||
return IsSocksProxy(ps) ? ReconstructSocksUrl(ps) : EnsureProxyScheme(ps.Server);
|
||||
case string s:
|
||||
return EnsureProxyScheme(s);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Resolve a proxy into Playwright options + extra Chrome args (one or both empty).</summary>
|
||||
public static ProxyResolution Resolve(object? proxy, string? browserVersion = null, string? licenseKey = null)
|
||||
{
|
||||
if (proxy == null)
|
||||
return new ProxyResolution(null, new List<string>());
|
||||
|
||||
// SOCKS5: bypass Playwright, pass directly to Chrome via --proxy-server.
|
||||
bool socks = proxy switch
|
||||
{
|
||||
ProxySettings ps => IsSocksProxy(ps),
|
||||
string s => IsSocksProxy(s),
|
||||
_ => false,
|
||||
};
|
||||
if (socks)
|
||||
{
|
||||
if (proxy is ProxySettings psd)
|
||||
{
|
||||
string url = ReconstructSocksUrl(psd);
|
||||
var extra = new List<string> { $"--proxy-server={url}" };
|
||||
if (!string.IsNullOrEmpty(psd.Bypass))
|
||||
extra.Add($"--proxy-bypass-list={psd.Bypass}");
|
||||
return new ProxyResolution(null, extra);
|
||||
}
|
||||
string sUrl = (string)proxy;
|
||||
return new ProxyResolution(null, new List<string> { $"--proxy-server={NormalizeSocksStringUrl(sUrl)}" });
|
||||
}
|
||||
|
||||
// HTTP/HTTPS with credentials, only on binaries that ship inline proxy auth:
|
||||
// inline creds via --proxy-server. Older binaries (free macOS/linux-arm64)
|
||||
// can't parse inline credentials, so they fall through to Playwright's proxy.
|
||||
bool hasCreds = proxy switch
|
||||
{
|
||||
ProxySettings ps => HasCredentials(ps),
|
||||
string s => HasCredentials(s),
|
||||
_ => false,
|
||||
};
|
||||
if (hasCreds && Config.BinarySupportsHttpProxyInlineAuth(licenseKey, browserVersion))
|
||||
{
|
||||
if (proxy is ProxySettings psd)
|
||||
{
|
||||
string url = ReconstructHttpUrl(psd);
|
||||
var extra = new List<string> { $"--proxy-server={url}" };
|
||||
if (!string.IsNullOrEmpty(psd.Bypass))
|
||||
extra.Add($"--proxy-bypass-list={psd.Bypass}");
|
||||
return new ProxyResolution(null, extra);
|
||||
}
|
||||
string sUrl = (string)proxy;
|
||||
return new ProxyResolution(null, new List<string> { $"--proxy-server={NormalizeHttpStringUrl(sUrl)}" });
|
||||
}
|
||||
|
||||
// HTTP/HTTPS without credentials: use Playwright's proxy.
|
||||
if (proxy is ProxySettings dict)
|
||||
{
|
||||
return new ProxyResolution(new Microsoft.Playwright.Proxy
|
||||
{
|
||||
Server = dict.Server,
|
||||
Bypass = dict.Bypass,
|
||||
Username = dict.Username,
|
||||
Password = dict.Password,
|
||||
}, new List<string>());
|
||||
}
|
||||
return new ProxyResolution(ParseProxyUrl((string)proxy), new List<string>());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
namespace CloakBrowser;
|
||||
|
||||
/// <summary>
|
||||
/// Playwright-compatible proxy configuration. Mirrors the Python
|
||||
/// <c>ProxySettings</c> TypedDict. <see cref="Server"/> is required; the rest are optional.
|
||||
/// </summary>
|
||||
public sealed class ProxySettings
|
||||
{
|
||||
/// <summary>Proxy server URL, e.g. <c>http://proxy:8080</c> or <c>socks5://proxy:1080</c>.</summary>
|
||||
public string Server { get; set; } = "";
|
||||
|
||||
/// <summary>Comma-separated bypass list, e.g. <c>.google.com</c>.</summary>
|
||||
public string? Bypass { get; set; }
|
||||
|
||||
/// <summary>Proxy username (for authenticated proxies).</summary>
|
||||
public string? Username { get; set; }
|
||||
|
||||
/// <summary>Proxy password (for authenticated proxies).</summary>
|
||||
public string? Password { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using Microsoft.Playwright;
|
||||
|
||||
namespace CloakBrowser;
|
||||
|
||||
/// <summary>
|
||||
/// Shared headed-launch viewport defaulting - the single source of truth behind both
|
||||
/// <see cref="CloakBrowserHandle"/> and the humanize <c>HumanizedBrowser</c> wrapper, so
|
||||
/// the default applies no matter which path creates the page/context (mirrors Python's
|
||||
/// single <c>_default_no_viewport</c> that monkey-patches the raw browser).
|
||||
///
|
||||
/// On a headed launch, a page/context the caller gave no explicit viewport defaults to
|
||||
/// <see cref="ViewportSize.NoViewport"/> so it tracks the real OS window - a bare emulated
|
||||
/// viewport on a headed window yields <c>outerWidth < innerWidth</c> (a physically
|
||||
/// impossible window / bot tell). Headless keeps Playwright's default (coherent there).
|
||||
/// An explicit viewport (including <see cref="ViewportSize.NoViewport"/>) is always honored.
|
||||
/// </summary>
|
||||
internal static class ViewportDefaults
|
||||
{
|
||||
public static BrowserNewPageOptions ApplyHeadedNoViewport(
|
||||
BrowserNewPageOptions? options, bool headless, bool headlessNoViewport = false)
|
||||
{
|
||||
var o = options ?? new BrowserNewPageOptions();
|
||||
if (headless && !headlessNoViewport) return o;
|
||||
o.ViewportSize ??= ViewportSize.NoViewport;
|
||||
return o;
|
||||
}
|
||||
|
||||
public static BrowserNewContextOptions ApplyHeadedNoViewport(
|
||||
BrowserNewContextOptions? options, bool headless, bool headlessNoViewport = false)
|
||||
{
|
||||
var o = options ?? new BrowserNewContextOptions();
|
||||
if (headless && !headlessNoViewport) return o;
|
||||
o.ViewportSize ??= ViewportSize.NoViewport;
|
||||
return o;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace CloakBrowser;
|
||||
|
||||
/// <summary>
|
||||
/// Widevine CDM hint-file seeding for persistent contexts.
|
||||
/// CloakBrowser's binary is built with Widevine support but ships no CDM (the CDM
|
||||
/// is a proprietary Google binary we can't redistribute). Users sideload it by
|
||||
/// copying a <c>WidevineCdm/</c> directory from a real Chrome install next to the binary.
|
||||
///
|
||||
/// This module pre-seeds the hint file before launch so a sideloaded CDM works on
|
||||
/// the very first launch. It never bundles, downloads, or copies the CDM itself -
|
||||
/// it only writes the hint when a CDM the user provided is already present.
|
||||
///
|
||||
/// Linux only: Chromium's hint-file mechanism is Linux/ChromeOS-specific.
|
||||
/// Direct port of Python <c>cloakbrowser/widevine.py</c>.
|
||||
/// </summary>
|
||||
public static class Widevine
|
||||
{
|
||||
// Chromium reads this file from <user-data-dir>/WidevineCdm/ at early startup.
|
||||
private const string HintFilename = "latest-component-updated-widevine-cdm";
|
||||
|
||||
private static bool SeedingDisabled()
|
||||
{
|
||||
var val = (Environment.GetEnvironmentVariable("CLOAKBROWSER_WIDEVINE") ?? "").Trim().ToLowerInvariant();
|
||||
return val is "0" or "false" or "off" or "no";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Locate a sideloaded Widevine CDM directory, or null if absent.
|
||||
/// If <c>CLOAKBROWSER_WIDEVINE_CDM</c> is set, it is used exclusively (overrides
|
||||
/// auto-detection). Otherwise <c><dir of the chrome binary>/WidevineCdm</c>.
|
||||
/// A directory counts only if it contains <c>manifest.json</c>.
|
||||
/// </summary>
|
||||
public static string? ResolveWidevineCdmDir(string binaryPath)
|
||||
{
|
||||
var custom = Environment.GetEnvironmentVariable("CLOAKBROWSER_WIDEVINE_CDM");
|
||||
// "is not null" (not truthiness): a present-but-empty env var is "set" and
|
||||
// used exclusively - it resolves to an invalid path and skips seeding.
|
||||
string cdmDir;
|
||||
if (custom != null)
|
||||
{
|
||||
// A present-but-empty/whitespace value is "set" and used exclusively,
|
||||
// but is not a usable path: treat it as invalid so we skip seeding
|
||||
// (Path.Combine("", "manifest.json") would otherwise probe the CWD,
|
||||
// diverging from the Python/JS wrappers' bogus-path => null behaviour).
|
||||
if (string.IsNullOrWhiteSpace(custom))
|
||||
return null;
|
||||
cdmDir = custom;
|
||||
}
|
||||
else
|
||||
{
|
||||
cdmDir = Path.Combine(Path.GetDirectoryName(Path.GetFullPath(binaryPath)) ?? ".", "WidevineCdm");
|
||||
}
|
||||
|
||||
if (File.Exists(Path.Combine(cdmDir, "manifest.json")))
|
||||
return Path.GetFullPath(cdmDir);
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Write the Widevine CDM hint file into a persistent profile before launch.
|
||||
/// No-op on non-Linux platforms, when seeding is disabled via CLOAKBROWSER_WIDEVINE,
|
||||
/// or when no sideloaded CDM is present. Never throws.
|
||||
/// </summary>
|
||||
public static void SeedWidevineHint(string? userDataDir, string binaryPath)
|
||||
{
|
||||
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
|
||||
return;
|
||||
if (SeedingDisabled())
|
||||
{
|
||||
CloakLog.Debug("Widevine hint seeding disabled via CLOAKBROWSER_WIDEVINE");
|
||||
return;
|
||||
}
|
||||
if (string.IsNullOrEmpty(userDataDir))
|
||||
{
|
||||
// Empty user_data_dir = Playwright's ephemeral profile (its own temp dir).
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var cdmDir = ResolveWidevineCdmDir(binaryPath);
|
||||
if (cdmDir == null)
|
||||
{
|
||||
if (Environment.GetEnvironmentVariable("CLOAKBROWSER_WIDEVINE_CDM") != null)
|
||||
CloakLog.Warning(
|
||||
"CLOAKBROWSER_WIDEVINE_CDM is set but has no manifest.json; " +
|
||||
"skipping Widevine hint seeding");
|
||||
else
|
||||
CloakLog.Debug("No sideloaded Widevine CDM found; skipping hint seeding");
|
||||
return;
|
||||
}
|
||||
|
||||
var hintDir = Path.Combine(userDataDir, "WidevineCdm");
|
||||
Directory.CreateDirectory(hintDir);
|
||||
var hintFile = Path.Combine(hintDir, HintFilename);
|
||||
|
||||
// Compact separators byte-match the JS wrapper's JSON.stringify (UTF-8) output.
|
||||
var content = JsonSerializer.Serialize(
|
||||
new Dictionary<string, string> { ["Path"] = cdmDir },
|
||||
new JsonSerializerOptions { Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping });
|
||||
|
||||
try
|
||||
{
|
||||
if (File.Exists(hintFile) && File.ReadAllText(hintFile, Encoding.UTF8) == content)
|
||||
return; // already seeded correctly
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
CloakLog.Warning("Existing Widevine hint unreadable; rewriting");
|
||||
}
|
||||
|
||||
File.WriteAllText(hintFile, content, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false));
|
||||
CloakLog.Info("Seeded Widevine CDM hint -> {0}", cdmDir);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
CloakLog.Warning("Failed to seed Widevine CDM hint file: {0}", e.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using CloakBrowser.Human;
|
||||
using Microsoft.Playwright;
|
||||
|
||||
namespace CloakBrowser.Wrappers;
|
||||
|
||||
/// <summary>
|
||||
/// Per-page shared humanize state: the virtual cursor position, the raw mouse /
|
||||
/// keyboard adapters, and the CDP typing stealth path. A single instance is shared
|
||||
/// by a <see cref="HumanizedPage"/>, its <see cref="HumanizedMouse"/>,
|
||||
/// <see cref="HumanizedKeyboard"/>, and every <see cref="HumanizedLocator"/> it
|
||||
/// produces, so cursor motion is continuous across them (no jumps).
|
||||
///
|
||||
/// This mirrors the cursor/stealth bookkeeping inside <see cref="HumanPage"/>, exposed
|
||||
/// in a form the wrappers can share.
|
||||
/// </summary>
|
||||
internal sealed class HumanCursor
|
||||
{
|
||||
private static readonly bool IsMac = RuntimeInformation.IsOSPlatform(OSPlatform.OSX);
|
||||
private static readonly string SelectAll = IsMac ? "Meta+a" : "Control+a";
|
||||
|
||||
private readonly IPage _page;
|
||||
private readonly IRawMouse _rawMouse;
|
||||
private readonly IRawKeyboard _rawKeyboard;
|
||||
private readonly IRawEvaluator _evaluator;
|
||||
|
||||
private IsolatedWorld? _stealth;
|
||||
private IRawCdpSession? _cdpSession;
|
||||
private bool _stealthInitialized;
|
||||
private bool _initialized;
|
||||
|
||||
public double X { get; private set; }
|
||||
public double Y { get; private set; }
|
||||
public IRawMouse RawMouse => _rawMouse;
|
||||
|
||||
public HumanCursor(IPage page)
|
||||
{
|
||||
_page = page;
|
||||
_rawMouse = new PlaywrightRawMouse(page.Mouse);
|
||||
_rawKeyboard = new PlaywrightRawKeyboard(page.Keyboard);
|
||||
_evaluator = new PlaywrightEvaluator(page);
|
||||
}
|
||||
|
||||
public void Set(double x, double y) { X = x; Y = y; }
|
||||
|
||||
public async Task InitStealthAsync()
|
||||
{
|
||||
if (_stealthInitialized) return;
|
||||
_stealthInitialized = true;
|
||||
try
|
||||
{
|
||||
_stealth = new IsolatedWorld(_page);
|
||||
var session = await _stealth.GetCdpSessionAsync().ConfigureAwait(false);
|
||||
_cdpSession = new PlaywrightCdpSession(session);
|
||||
}
|
||||
catch (System.Exception)
|
||||
{
|
||||
_stealth = null;
|
||||
_cdpSession = null;
|
||||
CloakLog.Debug("Could not create CDP session - stealth features disabled");
|
||||
}
|
||||
}
|
||||
|
||||
public void InvalidateStealth() => _stealth?.Invalidate();
|
||||
|
||||
public async Task EnsureInitializedAsync(HumanConfig cfg)
|
||||
{
|
||||
if (_initialized) return;
|
||||
X = HumanRandom.Rand(cfg.InitialCursorX.Min, cfg.InitialCursorX.Max);
|
||||
Y = HumanRandom.Rand(cfg.InitialCursorY.Min, cfg.InitialCursorY.Max);
|
||||
try
|
||||
{
|
||||
await _rawMouse.MoveAsync(X, Y).ConfigureAwait(false);
|
||||
_initialized = true;
|
||||
}
|
||||
catch (System.Exception) { /* viewport may not be ready yet */ }
|
||||
}
|
||||
|
||||
public Task RawMouseDownAsync(int clickCount = 1) =>
|
||||
_page.Mouse.DownAsync(new MouseDownOptions { ClickCount = clickCount });
|
||||
|
||||
public Task RawMouseUpAsync(int clickCount = 1) =>
|
||||
_page.Mouse.UpAsync(new MouseUpOptions { ClickCount = clickCount });
|
||||
|
||||
public Task SelectAllAsync() => _page.Keyboard.PressAsync(SelectAll);
|
||||
|
||||
public Task PressAsync(string key) => _page.Keyboard.PressAsync(key);
|
||||
|
||||
public Task HumanTypeAsync(string text, HumanConfig cfg) =>
|
||||
HumanKeyboard.HumanTypeAsync(_evaluator, _rawKeyboard, text, cfg, _cdpSession);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using CloakBrowser.Human;
|
||||
using Microsoft.Playwright;
|
||||
|
||||
namespace CloakBrowser.Wrappers;
|
||||
|
||||
/// <summary>
|
||||
/// Entry points and helpers for the transparent humanize layer.
|
||||
///
|
||||
/// The whole point of these wrappers is that user code keeps using the standard
|
||||
/// Playwright API - <c>page.ClickAsync(...)</c>, <c>page.Locator(...).FillAsync(...)</c>,
|
||||
/// <c>page.Mouse.MoveAsync(...)</c> - and the interaction methods are automatically
|
||||
/// routed through the human-like engine. Non-interaction members are forwarded
|
||||
/// verbatim by the Roslyn source generator, and every object that can perform an
|
||||
/// interaction (mouse, keyboard, locators, frames, child pages) is returned already
|
||||
/// wrapped so there are no "raw" leaks.
|
||||
/// </summary>
|
||||
public static class Humanize
|
||||
{
|
||||
/// <summary>
|
||||
/// Wrap a raw Playwright <see cref="IPage"/> so that all interaction methods are
|
||||
/// humanized. Returns a fully-wrapping <see cref="IPage"/> - assign it to an
|
||||
/// <c>IPage</c> variable and use the standard API.
|
||||
/// </summary>
|
||||
public static async Task<IPage> PageAsync(IPage page, HumanConfig? config = null)
|
||||
{
|
||||
if (page is HumanizedPage) return page; // already wrapped
|
||||
var cfg = config ?? new HumanConfig();
|
||||
var cursor = new HumanCursor(page);
|
||||
await cursor.InitStealthAsync().ConfigureAwait(false);
|
||||
await cursor.EnsureInitializedAsync(cfg).ConfigureAwait(false);
|
||||
return new HumanizedPage(page, cursor, cfg);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wrap a raw Playwright <see cref="IBrowserContext"/> so every page it produces
|
||||
/// (and already contains) is humanized.
|
||||
/// </summary>
|
||||
public static IBrowserContext Context(IBrowserContext context, HumanConfig? config = null)
|
||||
{
|
||||
if (context is HumanizedBrowserContext) return context;
|
||||
return new HumanizedBrowserContext(context, config ?? new HumanConfig());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wrap a raw Playwright <see cref="IBrowser"/> so every context/page it produces
|
||||
/// is humanized.
|
||||
/// </summary>
|
||||
public static IBrowser Browser(
|
||||
IBrowser browser, HumanConfig? config = null, bool headless = true, bool headlessNoViewport = false)
|
||||
{
|
||||
if (browser is HumanizedBrowser) return browser;
|
||||
return new HumanizedBrowser(browser, config ?? new HumanConfig(), headless, headlessNoViewport);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Internal re-wrap helpers (shared by the wrappers).
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
internal static ILocator WrapLocator(ILocator locator, HumanCursor cursor, HumanConfig cfg) =>
|
||||
locator is HumanizedLocator ? locator : new HumanizedLocator(locator, cursor, cfg);
|
||||
|
||||
internal static IFrame WrapFrame(IFrame frame, HumanCursor cursor, HumanConfig cfg) =>
|
||||
frame is HumanizedFrame ? frame : new HumanizedFrame(frame, cursor, cfg);
|
||||
|
||||
internal static IElementHandle WrapElementHandle(IElementHandle handle, HumanCursor cursor, HumanConfig cfg) =>
|
||||
handle is HumanizedElementHandle ? handle : new HumanizedElementHandle(handle, cursor, cfg);
|
||||
|
||||
internal static IReadOnlyList<IFrame> WrapFrames(IReadOnlyList<IFrame> frames, HumanCursor cursor, HumanConfig cfg) =>
|
||||
frames.Select(f => WrapFrame(f, cursor, cfg)).ToList();
|
||||
|
||||
internal static IReadOnlyList<IElementHandle> WrapHandles(IReadOnlyList<IElementHandle> handles, HumanCursor cursor, HumanConfig cfg) =>
|
||||
handles.Select(h => WrapElementHandle(h, cursor, cfg)).ToList();
|
||||
|
||||
/// <summary>Per-page cursor cache so pages from a context/browser share state across re-wraps.</summary>
|
||||
private static readonly System.Runtime.CompilerServices.ConditionalWeakTable<IPage, HumanCursor> CursorCache = new();
|
||||
|
||||
internal static async Task<IPage> WrapPageAsync(IPage page, HumanConfig cfg)
|
||||
{
|
||||
if (page is HumanizedPage) return page;
|
||||
if (CursorCache.TryGetValue(page, out var existing))
|
||||
return new HumanizedPage(page, existing, cfg);
|
||||
|
||||
var cursor = new HumanCursor(page);
|
||||
await cursor.InitStealthAsync().ConfigureAwait(false);
|
||||
await cursor.EnsureInitializedAsync(cfg).ConfigureAwait(false);
|
||||
CursorCache.Add(page, cursor);
|
||||
return new HumanizedPage(page, cursor, cfg);
|
||||
}
|
||||
|
||||
internal static IReadOnlyList<IPage> WrapPages(IReadOnlyList<IPage> pages, HumanConfig cfg) =>
|
||||
pages.Select(p =>
|
||||
{
|
||||
if (p is HumanizedPage) return p;
|
||||
var cursor = CursorCache.GetValue(p, key => new HumanCursor(key));
|
||||
return (IPage)new HumanizedPage(p, cursor, cfg);
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shared helpers for reading Force/Timeout out of the per-action Playwright option
|
||||
/// objects, which all expose <c>Force</c> and <c>Timeout</c> but have no common base.
|
||||
/// </summary>
|
||||
internal static class OptionReader
|
||||
{
|
||||
public static bool Force(object? options) =>
|
||||
options?.GetType().GetProperty("Force")?.GetValue(options) is bool b && b;
|
||||
|
||||
public static double Timeout(object? options)
|
||||
{
|
||||
var v = options?.GetType().GetProperty("Timeout")?.GetValue(options);
|
||||
return v is float f ? f : v is double d ? d : 30000;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using CloakBrowser.Human;
|
||||
using Microsoft.Playwright;
|
||||
|
||||
namespace CloakBrowser.Wrappers;
|
||||
|
||||
/// <summary>
|
||||
/// Transparent humanizing decorator over Playwright's <see cref="IBrowser"/>.
|
||||
///
|
||||
/// Intercepted: <c>NewPageAsync</c> / <c>NewContextAsync</c> / <c>Contexts</c> return
|
||||
/// humanized pages and contexts so the entire object graph reachable from the browser
|
||||
/// is humanized. Everything else is delegated to the inner browser by the generator.
|
||||
/// </summary>
|
||||
[GenerateInterfaceDelegation(typeof(IBrowser))]
|
||||
public sealed partial class HumanizedBrowser : IBrowser
|
||||
{
|
||||
private readonly IBrowser _inner;
|
||||
private readonly HumanConfig _cfg;
|
||||
private readonly bool _headless;
|
||||
private readonly bool _headlessNoViewport;
|
||||
|
||||
internal HumanizedBrowser(IBrowser inner, HumanConfig cfg, bool headless = true, bool headlessNoViewport = false)
|
||||
{
|
||||
_inner = inner;
|
||||
_cfg = cfg;
|
||||
_headless = headless;
|
||||
_headlessNoViewport = headlessNoViewport;
|
||||
}
|
||||
|
||||
/// <summary>The original, un-humanized Playwright browser (escape hatch).</summary>
|
||||
public IBrowser Original => _inner;
|
||||
|
||||
/// <summary>Alias of <see cref="Original"/>.</summary>
|
||||
public IBrowser Inner => _inner;
|
||||
|
||||
public async Task<IPage> NewPageAsync(BrowserNewPageOptions? options = null) =>
|
||||
await Humanize.WrapPageAsync(
|
||||
await _inner.NewPageAsync(ViewportDefaults.ApplyHeadedNoViewport(options, _headless, _headlessNoViewport)).ConfigureAwait(false),
|
||||
_cfg).ConfigureAwait(false);
|
||||
|
||||
public async Task<IBrowserContext> NewContextAsync(BrowserNewContextOptions? options = null) =>
|
||||
Humanize.Context(
|
||||
await _inner.NewContextAsync(ViewportDefaults.ApplyHeadedNoViewport(options, _headless, _headlessNoViewport)).ConfigureAwait(false),
|
||||
_cfg);
|
||||
|
||||
public IReadOnlyList<IBrowserContext> Contexts =>
|
||||
_inner.Contexts.Select(c => Humanize.Context(c, _cfg)).ToList();
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using CloakBrowser.Human;
|
||||
using Microsoft.Playwright;
|
||||
|
||||
namespace CloakBrowser.Wrappers;
|
||||
|
||||
/// <summary>
|
||||
/// Transparent humanizing decorator over Playwright's <see cref="IBrowserContext"/>.
|
||||
///
|
||||
/// Intercepted: page-producing members (<c>NewPageAsync</c>, <c>Pages</c>,
|
||||
/// <c>WaitForPageAsync</c>, <c>RunAndWaitForPageAsync</c>) return humanized pages.
|
||||
/// Everything else is delegated to the inner context by the source generator.
|
||||
/// </summary>
|
||||
[GenerateInterfaceDelegation(typeof(IBrowserContext))]
|
||||
public sealed partial class HumanizedBrowserContext : IBrowserContext
|
||||
{
|
||||
private readonly IBrowserContext _inner;
|
||||
private readonly HumanConfig _cfg;
|
||||
|
||||
internal HumanizedBrowserContext(IBrowserContext inner, HumanConfig cfg)
|
||||
{
|
||||
_inner = inner;
|
||||
_cfg = cfg;
|
||||
}
|
||||
|
||||
/// <summary>The original, un-humanized Playwright context (escape hatch).</summary>
|
||||
public IBrowserContext Original => _inner;
|
||||
|
||||
/// <summary>Alias of <see cref="Original"/>.</summary>
|
||||
public IBrowserContext Inner => _inner;
|
||||
|
||||
public async Task<IPage> NewPageAsync() =>
|
||||
await Humanize.WrapPageAsync(await _inner.NewPageAsync().ConfigureAwait(false), _cfg).ConfigureAwait(false);
|
||||
|
||||
public IReadOnlyList<IPage> Pages => Humanize.WrapPages(_inner.Pages, _cfg);
|
||||
|
||||
public async Task<IPage> WaitForPageAsync(BrowserContextWaitForPageOptions? options = null) =>
|
||||
await Humanize.WrapPageAsync(await _inner.WaitForPageAsync(options).ConfigureAwait(false), _cfg).ConfigureAwait(false);
|
||||
|
||||
public async Task<IPage> RunAndWaitForPageAsync(System.Func<Task> action, BrowserContextRunAndWaitForPageOptions? options = null) =>
|
||||
await Humanize.WrapPageAsync(await _inner.RunAndWaitForPageAsync(action, options).ConfigureAwait(false), _cfg).ConfigureAwait(false);
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
using CloakBrowser.Human;
|
||||
using Microsoft.Playwright;
|
||||
|
||||
namespace CloakBrowser.Wrappers;
|
||||
|
||||
/// <summary>
|
||||
/// Transparent humanizing decorator over Playwright's <see cref="IElementHandle"/>.
|
||||
///
|
||||
/// ElementHandle is a legacy, lower-level surface (Playwright itself recommends
|
||||
/// <see cref="ILocator"/>). For completeness this wrapper humanizes the common
|
||||
/// interaction methods (click/dblclick/hover/tap/fill/type/press/check/uncheck) by
|
||||
/// driving the shared cursor to the handle's bounding box, so the ElementHandle path
|
||||
/// does NOT silently bypass humanization. Handle-returning queries are re-wrapped;
|
||||
/// everything else is delegated by the generator.
|
||||
///
|
||||
/// Recommendation: prefer Locator / selector-based methods - they get the full
|
||||
/// actionability + isolated-world stealth path.
|
||||
/// </summary>
|
||||
[GenerateInterfaceDelegation(typeof(IElementHandle))]
|
||||
public sealed partial class HumanizedElementHandle : IElementHandle
|
||||
{
|
||||
private readonly IElementHandle _inner;
|
||||
private readonly HumanCursor _cursor;
|
||||
private readonly HumanConfig _cfg;
|
||||
|
||||
internal HumanizedElementHandle(IElementHandle inner, HumanCursor cursor, HumanConfig cfg)
|
||||
{
|
||||
_inner = inner;
|
||||
_cursor = cursor;
|
||||
_cfg = cfg;
|
||||
}
|
||||
|
||||
/// <summary>The original, un-humanized Playwright element handle (escape hatch).</summary>
|
||||
public IElementHandle Original => _inner;
|
||||
|
||||
/// <summary>Alias of <see cref="Original"/>.</summary>
|
||||
public IElementHandle Inner => _inner;
|
||||
|
||||
private async Task<(double X, double Y, bool IsInput)> MoveToAsync(double timeout, bool force)
|
||||
{
|
||||
await _cursor.EnsureInitializedAsync(_cfg).ConfigureAwait(false);
|
||||
if (!force)
|
||||
{
|
||||
try { await _inner.ScrollIntoViewIfNeededAsync(new ElementHandleScrollIntoViewIfNeededOptions { Timeout = (float)timeout }).ConfigureAwait(false); }
|
||||
catch (System.Exception) { /* best effort */ }
|
||||
}
|
||||
var box = await _inner.BoundingBoxAsync().ConfigureAwait(false);
|
||||
bool isInput;
|
||||
try
|
||||
{
|
||||
isInput = await _inner.EvaluateAsync<bool>(
|
||||
@"el => { const t = el.tagName.toLowerCase();
|
||||
return t==='input'||t==='textarea'||el.getAttribute('contenteditable')==='true'; }")
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (System.Exception) { isInput = false; }
|
||||
|
||||
var bb = box == null ? new BoundingBox(_cursor.X, _cursor.Y, 1, 1)
|
||||
: new BoundingBox(box.X, box.Y, box.Width, box.Height);
|
||||
var target = HumanMouse.ClickTarget(bb, isInput, _cfg);
|
||||
await HumanMouse.HumanMoveAsync(_cursor.RawMouse, _cursor.X, _cursor.Y, target.X, target.Y, _cfg).ConfigureAwait(false);
|
||||
_cursor.Set(target.X, target.Y);
|
||||
return (target.X, target.Y, isInput);
|
||||
}
|
||||
|
||||
public async Task ClickAsync(ElementHandleClickOptions? options = null)
|
||||
{
|
||||
var t = await MoveToAsync(OptionReader.Timeout(options), OptionReader.Force(options)).ConfigureAwait(false);
|
||||
await HumanMouse.HumanClickAsync(_cursor.RawMouse, t.IsInput, _cfg).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task DblClickAsync(ElementHandleDblClickOptions? options = null)
|
||||
{
|
||||
await MoveToAsync(OptionReader.Timeout(options), OptionReader.Force(options)).ConfigureAwait(false);
|
||||
await _cursor.RawMouseDownAsync(2).ConfigureAwait(false);
|
||||
await HumanRandom.SleepMsAsync(HumanRandom.Rand(30, 60)).ConfigureAwait(false);
|
||||
await _cursor.RawMouseUpAsync(2).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public Task HoverAsync(ElementHandleHoverOptions? options = null) =>
|
||||
MoveToAsync(OptionReader.Timeout(options), OptionReader.Force(options));
|
||||
|
||||
public Task TapAsync(ElementHandleTapOptions? options = null) =>
|
||||
ClickAsync(new ElementHandleClickOptions
|
||||
{
|
||||
Force = OptionReader.Force(options),
|
||||
Timeout = (float)OptionReader.Timeout(options),
|
||||
});
|
||||
|
||||
public async Task FillAsync(string value, ElementHandleFillOptions? options = null)
|
||||
{
|
||||
await ClickAsync(new ElementHandleClickOptions { Force = OptionReader.Force(options), Timeout = (float)OptionReader.Timeout(options) }).ConfigureAwait(false);
|
||||
await HumanRandom.SleepMsAsync(HumanRandom.Rand(100, 250)).ConfigureAwait(false);
|
||||
await _cursor.SelectAllAsync().ConfigureAwait(false);
|
||||
await HumanRandom.SleepMsAsync(HumanRandom.Rand(30, 80)).ConfigureAwait(false);
|
||||
await _cursor.PressAsync("Backspace").ConfigureAwait(false);
|
||||
await HumanRandom.SleepMsAsync(HumanRandom.Rand(50, 150)).ConfigureAwait(false);
|
||||
await _cursor.HumanTypeAsync(value, _cfg).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task TypeAsync(string text, ElementHandleTypeOptions? options = null)
|
||||
{
|
||||
await _inner.FocusAsync().ConfigureAwait(false);
|
||||
await HumanRandom.SleepMsAsync(HumanRandom.Rand(50, 150)).ConfigureAwait(false);
|
||||
await _cursor.HumanTypeAsync(text, _cfg).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task PressAsync(string key, ElementHandlePressOptions? options = null)
|
||||
{
|
||||
await _inner.FocusAsync().ConfigureAwait(false);
|
||||
await HumanRandom.SleepMsAsync(HumanRandom.Rand(50, 150)).ConfigureAwait(false);
|
||||
await _cursor.PressAsync(key).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task CheckAsync(ElementHandleCheckOptions? options = null)
|
||||
{
|
||||
if (!await _inner.IsCheckedAsync().ConfigureAwait(false))
|
||||
await ClickAsync(new ElementHandleClickOptions { Force = OptionReader.Force(options), Timeout = (float)OptionReader.Timeout(options) }).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task UncheckAsync(ElementHandleUncheckOptions? options = null)
|
||||
{
|
||||
if (await _inner.IsCheckedAsync().ConfigureAwait(false))
|
||||
await ClickAsync(new ElementHandleClickOptions { Force = OptionReader.Force(options), Timeout = (float)OptionReader.Timeout(options) }).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task SetCheckedAsync(bool checkedState, ElementHandleSetCheckedOptions? options = null)
|
||||
{
|
||||
bool current;
|
||||
try { current = await _inner.IsCheckedAsync().ConfigureAwait(false); }
|
||||
catch (System.Exception) { current = !checkedState; }
|
||||
if (current != checkedState)
|
||||
await ClickAsync(new ElementHandleClickOptions { Force = OptionReader.Force(options), Timeout = (float)OptionReader.Timeout(options) }).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// SelectOptionAsync (all 6 IElementHandle overloads) - humanized pre-roll.
|
||||
// Mirrors Python _human_el_select_option: move the cursor to the <select>
|
||||
// (curved), click, pause, then delegate the real select (native popups can't
|
||||
// be mouse-driven). Unwrap any HumanizedElementHandle args.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private static IElementHandle Unwrap(IElementHandle h) => h is HumanizedElementHandle w ? w.Original : h;
|
||||
|
||||
private async Task SelectPrologueAsync(ElementHandleSelectOptionOptions? options)
|
||||
{
|
||||
var t = await MoveToAsync(OptionReader.Timeout(options), OptionReader.Force(options)).ConfigureAwait(false);
|
||||
await HumanMouse.HumanClickAsync(_cursor.RawMouse, t.IsInput, _cfg).ConfigureAwait(false);
|
||||
await HumanRandom.SleepMsAsync(HumanRandom.Rand(100, 300)).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<string>> SelectOptionAsync(string values, ElementHandleSelectOptionOptions? options = null)
|
||||
{
|
||||
await SelectPrologueAsync(options).ConfigureAwait(false);
|
||||
return await _inner.SelectOptionAsync(values, options).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<string>> SelectOptionAsync(IElementHandle values, ElementHandleSelectOptionOptions? options = null)
|
||||
{
|
||||
await SelectPrologueAsync(options).ConfigureAwait(false);
|
||||
return await _inner.SelectOptionAsync(Unwrap(values), options).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<string>> SelectOptionAsync(IEnumerable<string> values, ElementHandleSelectOptionOptions? options = null)
|
||||
{
|
||||
await SelectPrologueAsync(options).ConfigureAwait(false);
|
||||
return await _inner.SelectOptionAsync(values, options).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<string>> SelectOptionAsync(SelectOptionValue values, ElementHandleSelectOptionOptions? options = null)
|
||||
{
|
||||
await SelectPrologueAsync(options).ConfigureAwait(false);
|
||||
return await _inner.SelectOptionAsync(values, options).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<string>> SelectOptionAsync(IEnumerable<IElementHandle> values, ElementHandleSelectOptionOptions? options = null)
|
||||
{
|
||||
await SelectPrologueAsync(options).ConfigureAwait(false);
|
||||
return await _inner.SelectOptionAsync(values.Select(Unwrap), options).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<string>> SelectOptionAsync(IEnumerable<SelectOptionValue> values, ElementHandleSelectOptionOptions? options = null)
|
||||
{
|
||||
await SelectPrologueAsync(options).ConfigureAwait(false);
|
||||
return await _inner.SelectOptionAsync(values, options).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Handle-returning members - re-wrap.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
public async Task<IElementHandle?> QuerySelectorAsync(string selector)
|
||||
{
|
||||
var h = await _inner.QuerySelectorAsync(selector).ConfigureAwait(false);
|
||||
return h == null ? null : Humanize.WrapElementHandle(h, _cursor, _cfg);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<IElementHandle>> QuerySelectorAllAsync(string selector)
|
||||
{
|
||||
var hs = await _inner.QuerySelectorAllAsync(selector).ConfigureAwait(false);
|
||||
return Humanize.WrapHandles(hs, _cursor, _cfg);
|
||||
}
|
||||
|
||||
public async Task<IElementHandle?> WaitForSelectorAsync(string selector, ElementHandleWaitForSelectorOptions? options = null)
|
||||
{
|
||||
var h = await _inner.WaitForSelectorAsync(selector, options).ConfigureAwait(false);
|
||||
return h == null ? null : Humanize.WrapElementHandle(h, _cursor, _cfg);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using CloakBrowser.Human;
|
||||
using Microsoft.Playwright;
|
||||
|
||||
namespace CloakBrowser.Wrappers;
|
||||
|
||||
/// <summary>
|
||||
/// Transparent humanizing decorator over Playwright's <see cref="IFrame"/>.
|
||||
///
|
||||
/// Frames have no Mouse/Keyboard of their own (those belong to the page), so the
|
||||
/// selector actions are humanized by resolving <c>frame.Locator(selector)</c> and
|
||||
/// running the shared locator humanizer with the page's cursor. Locator/frame
|
||||
/// returning members are re-wrapped; everything else is delegated by the generator.
|
||||
/// </summary>
|
||||
[GenerateInterfaceDelegation(typeof(IFrame))]
|
||||
public sealed partial class HumanizedFrame : IFrame
|
||||
{
|
||||
private readonly IFrame _inner;
|
||||
private readonly HumanCursor _cursor;
|
||||
private readonly HumanConfig _cfg;
|
||||
|
||||
internal HumanizedFrame(IFrame inner, HumanCursor cursor, HumanConfig cfg)
|
||||
{
|
||||
_inner = inner;
|
||||
_cursor = cursor;
|
||||
_cfg = cfg;
|
||||
}
|
||||
|
||||
/// <summary>The original, un-humanized Playwright frame (escape hatch for raw speed).</summary>
|
||||
public IFrame Original => _inner;
|
||||
|
||||
/// <summary>Alias of <see cref="Original"/>.</summary>
|
||||
public IFrame Inner => _inner;
|
||||
|
||||
private ILocator Wrap(ILocator l) => Humanize.WrapLocator(l, _cursor, _cfg);
|
||||
private IFrame Wrap(IFrame f) => Humanize.WrapFrame(f, _cursor, _cfg);
|
||||
private ILocator Loc(string selector) => _inner.Locator(selector);
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Humanized selector actions (routed through the locator humanizer).
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
public Task ClickAsync(string selector, FrameClickOptions? options = null) =>
|
||||
LocatorHumanizer.ClickAsync(Loc(selector), _cursor, _cfg, OptionReader.Timeout(options), OptionReader.Force(options));
|
||||
|
||||
public Task DblClickAsync(string selector, FrameDblClickOptions? options = null) =>
|
||||
LocatorHumanizer.DblClickAsync(Loc(selector), _cursor, _cfg, OptionReader.Timeout(options), OptionReader.Force(options));
|
||||
|
||||
public Task HoverAsync(string selector, FrameHoverOptions? options = null) =>
|
||||
LocatorHumanizer.HoverAsync(Loc(selector), _cursor, _cfg, OptionReader.Timeout(options), OptionReader.Force(options));
|
||||
|
||||
public Task TapAsync(string selector, FrameTapOptions? options = null) =>
|
||||
LocatorHumanizer.TapAsync(Loc(selector), _cursor, _cfg, OptionReader.Timeout(options), OptionReader.Force(options));
|
||||
|
||||
public Task FillAsync(string selector, string value, FrameFillOptions? options = null) =>
|
||||
LocatorHumanizer.FillAsync(Loc(selector), _cursor, _cfg, OptionReader.Timeout(options), OptionReader.Force(options), value);
|
||||
|
||||
public Task TypeAsync(string selector, string text, FrameTypeOptions? options = null) =>
|
||||
LocatorHumanizer.TypeAsync(Loc(selector), _cursor, _cfg, OptionReader.Timeout(options), OptionReader.Force(options), text);
|
||||
|
||||
public Task PressAsync(string selector, string key, FramePressOptions? options = null) =>
|
||||
LocatorHumanizer.PressAsync(Loc(selector), _cursor, _cfg, OptionReader.Timeout(options), OptionReader.Force(options), key);
|
||||
|
||||
public async Task CheckAsync(string selector, FrameCheckOptions? options = null)
|
||||
{
|
||||
if (!await _inner.IsCheckedAsync(selector).ConfigureAwait(false))
|
||||
await LocatorHumanizer.ClickAsync(Loc(selector), _cursor, _cfg, OptionReader.Timeout(options), OptionReader.Force(options)).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task UncheckAsync(string selector, FrameUncheckOptions? options = null)
|
||||
{
|
||||
if (await _inner.IsCheckedAsync(selector).ConfigureAwait(false))
|
||||
await LocatorHumanizer.ClickAsync(Loc(selector), _cursor, _cfg, OptionReader.Timeout(options), OptionReader.Force(options)).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task SetCheckedAsync(string selector, bool checkedState, FrameSetCheckedOptions? options = null)
|
||||
{
|
||||
bool current;
|
||||
try { current = await _inner.IsCheckedAsync(selector).ConfigureAwait(false); }
|
||||
catch (System.Exception) { current = !checkedState; }
|
||||
if (current != checkedState)
|
||||
await LocatorHumanizer.ClickAsync(Loc(selector), _cursor, _cfg, OptionReader.Timeout(options), OptionReader.Force(options)).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// SelectOptionAsync (all 6 IFrame overloads) - humanized pre-roll.
|
||||
// Mirrors Python _frame_select_option: hover the <select> (curved move) +
|
||||
// pause, then delegate the real select (native popups can't be mouse-driven).
|
||||
// Unwrap any HumanizedElementHandle args so Playwright sees raw handles.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private static IElementHandle Unwrap(IElementHandle h) => h is HumanizedElementHandle w ? w.Original : h;
|
||||
|
||||
private Task SelectPrologueAsync(string selector, FrameSelectOptionOptions? options) =>
|
||||
LocatorHumanizer.SelectOptionPrologueAsync(Loc(selector), _cursor, _cfg, OptionReader.Timeout(options), OptionReader.Force(options));
|
||||
|
||||
public async Task<IReadOnlyList<string>> SelectOptionAsync(string selector, string values, FrameSelectOptionOptions? options = null)
|
||||
{
|
||||
await SelectPrologueAsync(selector, options).ConfigureAwait(false);
|
||||
return await _inner.SelectOptionAsync(selector, values, options).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<string>> SelectOptionAsync(string selector, IElementHandle values, FrameSelectOptionOptions? options = null)
|
||||
{
|
||||
await SelectPrologueAsync(selector, options).ConfigureAwait(false);
|
||||
return await _inner.SelectOptionAsync(selector, Unwrap(values), options).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<string>> SelectOptionAsync(string selector, IEnumerable<string> values, FrameSelectOptionOptions? options = null)
|
||||
{
|
||||
await SelectPrologueAsync(selector, options).ConfigureAwait(false);
|
||||
return await _inner.SelectOptionAsync(selector, values, options).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<string>> SelectOptionAsync(string selector, SelectOptionValue values, FrameSelectOptionOptions? options = null)
|
||||
{
|
||||
await SelectPrologueAsync(selector, options).ConfigureAwait(false);
|
||||
return await _inner.SelectOptionAsync(selector, values, options).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<string>> SelectOptionAsync(string selector, IEnumerable<IElementHandle> values, FrameSelectOptionOptions? options = null)
|
||||
{
|
||||
await SelectPrologueAsync(selector, options).ConfigureAwait(false);
|
||||
return await _inner.SelectOptionAsync(selector, values.Select(Unwrap), options).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<string>> SelectOptionAsync(string selector, IEnumerable<SelectOptionValue> values, FrameSelectOptionOptions? options = null)
|
||||
{
|
||||
await SelectPrologueAsync(selector, options).ConfigureAwait(false);
|
||||
return await _inner.SelectOptionAsync(selector, values, options).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// DragAndDropAsync - humanized drag. Mirrors Python _frame_drag_and_drop:
|
||||
// resolve source/target boxes via frame locators; if both present, drive a
|
||||
// curved press-move-release with the page cursor; else delegate raw.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
public async Task DragAndDropAsync(string source, string target, FrameDragAndDropOptions? options = null)
|
||||
{
|
||||
LocatorBoundingBoxResult? srcBox, tgtBox;
|
||||
try
|
||||
{
|
||||
srcBox = await _inner.Locator(source).BoundingBoxAsync().ConfigureAwait(false);
|
||||
tgtBox = await _inner.Locator(target).BoundingBoxAsync().ConfigureAwait(false);
|
||||
}
|
||||
catch (System.Exception)
|
||||
{
|
||||
srcBox = tgtBox = null;
|
||||
}
|
||||
|
||||
if (srcBox == null || tgtBox == null)
|
||||
{
|
||||
await _inner.DragAndDropAsync(source, target, options).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
await _cursor.EnsureInitializedAsync(_cfg).ConfigureAwait(false);
|
||||
double sx = srcBox.X + srcBox.Width / 2, sy = srcBox.Y + srcBox.Height / 2;
|
||||
double tx = tgtBox.X + tgtBox.Width / 2, ty = tgtBox.Y + tgtBox.Height / 2;
|
||||
await HumanMouse.HumanMoveAsync(_cursor.RawMouse, _cursor.X, _cursor.Y, sx, sy, _cfg).ConfigureAwait(false);
|
||||
_cursor.Set(sx, sy);
|
||||
await HumanRandom.SleepMsAsync(HumanRandom.Rand(100, 200)).ConfigureAwait(false);
|
||||
await _cursor.RawMouseDownAsync().ConfigureAwait(false);
|
||||
await HumanRandom.SleepMsAsync(HumanRandom.Rand(80, 150)).ConfigureAwait(false);
|
||||
await HumanMouse.HumanMoveAsync(_cursor.RawMouse, _cursor.X, _cursor.Y, tx, ty, _cfg).ConfigureAwait(false);
|
||||
_cursor.Set(tx, ty);
|
||||
await HumanRandom.SleepMsAsync(HumanRandom.Rand(80, 150)).ConfigureAwait(false);
|
||||
await _cursor.RawMouseUpAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Locator-returning members - re-wrap.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
public ILocator Locator(string selector, FrameLocatorOptions? options = null) => Wrap(_inner.Locator(selector, options));
|
||||
public ILocator GetByAltText(string text, FrameGetByAltTextOptions? options = null) => Wrap(_inner.GetByAltText(text, options));
|
||||
public ILocator GetByAltText(Regex text, FrameGetByAltTextOptions? options = null) => Wrap(_inner.GetByAltText(text, options));
|
||||
public ILocator GetByLabel(string text, FrameGetByLabelOptions? options = null) => Wrap(_inner.GetByLabel(text, options));
|
||||
public ILocator GetByLabel(Regex text, FrameGetByLabelOptions? options = null) => Wrap(_inner.GetByLabel(text, options));
|
||||
public ILocator GetByPlaceholder(string text, FrameGetByPlaceholderOptions? options = null) => Wrap(_inner.GetByPlaceholder(text, options));
|
||||
public ILocator GetByPlaceholder(Regex text, FrameGetByPlaceholderOptions? options = null) => Wrap(_inner.GetByPlaceholder(text, options));
|
||||
public ILocator GetByRole(AriaRole role, FrameGetByRoleOptions? options = null) => Wrap(_inner.GetByRole(role, options));
|
||||
public ILocator GetByTestId(string testId) => Wrap(_inner.GetByTestId(testId));
|
||||
public ILocator GetByTestId(Regex testId) => Wrap(_inner.GetByTestId(testId));
|
||||
public ILocator GetByText(string text, FrameGetByTextOptions? options = null) => Wrap(_inner.GetByText(text, options));
|
||||
public ILocator GetByText(Regex text, FrameGetByTextOptions? options = null) => Wrap(_inner.GetByText(text, options));
|
||||
public ILocator GetByTitle(string text, FrameGetByTitleOptions? options = null) => Wrap(_inner.GetByTitle(text, options));
|
||||
public ILocator GetByTitle(Regex text, FrameGetByTitleOptions? options = null) => Wrap(_inner.GetByTitle(text, options));
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Frame-returning members - re-wrap.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
public IReadOnlyList<IFrame> ChildFrames => Humanize.WrapFrames(_inner.ChildFrames, _cursor, _cfg);
|
||||
public IFrame? ParentFrame { get { var f = _inner.ParentFrame; return f == null ? null : Wrap(f); } }
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using CloakBrowser.Human;
|
||||
using Microsoft.Playwright;
|
||||
|
||||
namespace CloakBrowser.Wrappers;
|
||||
|
||||
/// <summary>
|
||||
/// Transparent humanizing decorator over Playwright's <see cref="IKeyboard"/>.
|
||||
///
|
||||
/// Intercepted (humanized): <c>TypeAsync</c>, <c>PressAsync</c>, <c>InsertTextAsync</c>.
|
||||
/// Low-level <c>DownAsync</c>/<c>UpAsync</c> are delegated to the inner keyboard by the
|
||||
/// source generator (they are deliberate single key transitions, not "typing").
|
||||
/// </summary>
|
||||
[GenerateInterfaceDelegation(typeof(IKeyboard))]
|
||||
public sealed partial class HumanizedKeyboard : IKeyboard
|
||||
{
|
||||
private readonly IKeyboard _inner;
|
||||
private readonly HumanCursor _cursor;
|
||||
private readonly HumanConfig _cfg;
|
||||
|
||||
internal HumanizedKeyboard(IKeyboard inner, HumanCursor cursor, HumanConfig cfg)
|
||||
{
|
||||
_inner = inner;
|
||||
_cursor = cursor;
|
||||
_cfg = cfg;
|
||||
}
|
||||
|
||||
/// <summary>The original, un-humanized Playwright keyboard (escape hatch for raw speed).</summary>
|
||||
public IKeyboard Original => _inner;
|
||||
|
||||
/// <summary>Alias of <see cref="Original"/>.</summary>
|
||||
public IKeyboard Inner => _inner;
|
||||
|
||||
public Task TypeAsync(string text, KeyboardTypeOptions? options = null) =>
|
||||
_cursor.HumanTypeAsync(text, _cfg);
|
||||
|
||||
public async Task PressAsync(string key, KeyboardPressOptions? options = null)
|
||||
{
|
||||
// A single human key press: brief aim delay, then the inner press (which
|
||||
// already presses down + up). Mirrors the press timing used elsewhere.
|
||||
await HumanRandom.SleepMsAsync(HumanRandom.Rand(50, 150)).ConfigureAwait(false);
|
||||
await _inner.PressAsync(key, options).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public Task InsertTextAsync(string text) =>
|
||||
// InsertText is an atomic IME-style insertion; humanize it as paced typing.
|
||||
_cursor.HumanTypeAsync(text, _cfg);
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using CloakBrowser.Human;
|
||||
using Microsoft.Playwright;
|
||||
|
||||
namespace CloakBrowser.Wrappers;
|
||||
|
||||
/// <summary>
|
||||
/// Transparent humanizing decorator over Playwright's <see cref="ILocator"/>.
|
||||
///
|
||||
/// Intercepted (humanized): Click/DblClick/Hover/Tap/Fill/Type/PressSequentially/Press
|
||||
/// and Check/Uncheck/SetChecked (which route through a humanized click). All other
|
||||
/// members - assertions, queries, waits, getters - are delegated to the inner locator
|
||||
/// by the source generator. Locator-returning members are re-wrapped so chaining stays
|
||||
/// humanized.
|
||||
/// </summary>
|
||||
[GenerateInterfaceDelegation(typeof(ILocator))]
|
||||
public sealed partial class HumanizedLocator : ILocator
|
||||
{
|
||||
private readonly ILocator _inner;
|
||||
private readonly HumanCursor _cursor;
|
||||
private readonly HumanConfig _cfg;
|
||||
|
||||
internal HumanizedLocator(ILocator inner, HumanCursor cursor, HumanConfig cfg)
|
||||
{
|
||||
_inner = inner;
|
||||
_cursor = cursor;
|
||||
_cfg = cfg;
|
||||
}
|
||||
|
||||
/// <summary>The original, un-humanized Playwright locator (escape hatch for raw speed).</summary>
|
||||
public ILocator Original => _inner;
|
||||
|
||||
/// <summary>Alias of <see cref="Original"/>.</summary>
|
||||
public ILocator Inner => _inner;
|
||||
|
||||
private ILocator Wrap(ILocator l) => Humanize.WrapLocator(l, _cursor, _cfg);
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Humanized actions
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
public Task ClickAsync(LocatorClickOptions? options = null) =>
|
||||
LocatorHumanizer.ClickAsync(_inner, _cursor, _cfg, OptionReader.Timeout(options), OptionReader.Force(options));
|
||||
|
||||
public Task DblClickAsync(LocatorDblClickOptions? options = null) =>
|
||||
LocatorHumanizer.DblClickAsync(_inner, _cursor, _cfg, OptionReader.Timeout(options), OptionReader.Force(options));
|
||||
|
||||
public Task HoverAsync(LocatorHoverOptions? options = null) =>
|
||||
LocatorHumanizer.HoverAsync(_inner, _cursor, _cfg, OptionReader.Timeout(options), OptionReader.Force(options));
|
||||
|
||||
public Task TapAsync(LocatorTapOptions? options = null) =>
|
||||
LocatorHumanizer.TapAsync(_inner, _cursor, _cfg, OptionReader.Timeout(options), OptionReader.Force(options));
|
||||
|
||||
public Task FillAsync(string value, LocatorFillOptions? options = null) =>
|
||||
LocatorHumanizer.FillAsync(_inner, _cursor, _cfg, OptionReader.Timeout(options), OptionReader.Force(options), value);
|
||||
|
||||
public Task TypeAsync(string text, LocatorTypeOptions? options = null) =>
|
||||
LocatorHumanizer.TypeAsync(_inner, _cursor, _cfg, OptionReader.Timeout(options), OptionReader.Force(options), text);
|
||||
|
||||
public Task PressSequentiallyAsync(string text, LocatorPressSequentiallyOptions? options = null) =>
|
||||
LocatorHumanizer.PressSequentiallyAsync(_inner, _cursor, _cfg, OptionReader.Timeout(options), OptionReader.Force(options), text);
|
||||
|
||||
public Task PressAsync(string key, LocatorPressOptions? options = null) =>
|
||||
LocatorHumanizer.PressAsync(_inner, _cursor, _cfg, OptionReader.Timeout(options), OptionReader.Force(options), key);
|
||||
|
||||
public async Task CheckAsync(LocatorCheckOptions? options = null)
|
||||
{
|
||||
if (!await _inner.IsCheckedAsync().ConfigureAwait(false))
|
||||
await LocatorHumanizer.ClickAsync(_inner, _cursor, _cfg, OptionReader.Timeout(options), OptionReader.Force(options)).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task UncheckAsync(LocatorUncheckOptions? options = null)
|
||||
{
|
||||
if (await _inner.IsCheckedAsync().ConfigureAwait(false))
|
||||
await LocatorHumanizer.ClickAsync(_inner, _cursor, _cfg, OptionReader.Timeout(options), OptionReader.Force(options)).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task SetCheckedAsync(bool checkedState, LocatorSetCheckedOptions? options = null)
|
||||
{
|
||||
bool current;
|
||||
try { current = await _inner.IsCheckedAsync().ConfigureAwait(false); }
|
||||
catch (System.Exception) { current = !checkedState; }
|
||||
if (current != checkedState)
|
||||
await LocatorHumanizer.ClickAsync(_inner, _cursor, _cfg, OptionReader.Timeout(options), OptionReader.Force(options)).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task DragToAsync(ILocator target, LocatorDragToOptions? options = null)
|
||||
{
|
||||
var realTarget = target is HumanizedLocator h ? h.Original : target;
|
||||
var srcBox = await _inner.BoundingBoxAsync().ConfigureAwait(false);
|
||||
var tgtBox = await realTarget.BoundingBoxAsync().ConfigureAwait(false);
|
||||
if (srcBox == null || tgtBox == null)
|
||||
{
|
||||
await _inner.DragToAsync(realTarget, options).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
await _cursor.EnsureInitializedAsync(_cfg).ConfigureAwait(false);
|
||||
double sx = srcBox.X + srcBox.Width / 2, sy = srcBox.Y + srcBox.Height / 2;
|
||||
double tx = tgtBox.X + tgtBox.Width / 2, ty = tgtBox.Y + tgtBox.Height / 2;
|
||||
await HumanMouse.HumanMoveAsync(_cursor.RawMouse, _cursor.X, _cursor.Y, sx, sy, _cfg).ConfigureAwait(false);
|
||||
_cursor.Set(sx, sy);
|
||||
await HumanRandom.SleepMsAsync(HumanRandom.Rand(100, 200)).ConfigureAwait(false);
|
||||
await _cursor.RawMouseDownAsync().ConfigureAwait(false);
|
||||
await HumanRandom.SleepMsAsync(HumanRandom.Rand(80, 150)).ConfigureAwait(false);
|
||||
await HumanMouse.HumanMoveAsync(_cursor.RawMouse, _cursor.X, _cursor.Y, tx, ty, _cfg).ConfigureAwait(false);
|
||||
_cursor.Set(tx, ty);
|
||||
await HumanRandom.SleepMsAsync(HumanRandom.Rand(80, 150)).ConfigureAwait(false);
|
||||
await _cursor.RawMouseUpAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// --- SelectOptionAsync (all ILocator overloads) -------------------------
|
||||
// Human pre-roll = curved hover + pause, then delegate the real select (native
|
||||
// <select> popups can't be driven by synthetic mouse events). Unwrap any
|
||||
// HumanizedElementHandle args so Playwright sees the raw handles.
|
||||
|
||||
public async Task<IReadOnlyList<string>> SelectOptionAsync(string values, LocatorSelectOptionOptions? options = null)
|
||||
{
|
||||
await LocatorHumanizer.SelectOptionPrologueAsync(_inner, _cursor, _cfg, OptionReader.Timeout(options), OptionReader.Force(options)).ConfigureAwait(false);
|
||||
return await _inner.SelectOptionAsync(values, options).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<string>> SelectOptionAsync(IElementHandle values, LocatorSelectOptionOptions? options = null)
|
||||
{
|
||||
await LocatorHumanizer.SelectOptionPrologueAsync(_inner, _cursor, _cfg, OptionReader.Timeout(options), OptionReader.Force(options)).ConfigureAwait(false);
|
||||
return await _inner.SelectOptionAsync(Unwrap(values), options).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<string>> SelectOptionAsync(IEnumerable<string> values, LocatorSelectOptionOptions? options = null)
|
||||
{
|
||||
await LocatorHumanizer.SelectOptionPrologueAsync(_inner, _cursor, _cfg, OptionReader.Timeout(options), OptionReader.Force(options)).ConfigureAwait(false);
|
||||
return await _inner.SelectOptionAsync(values, options).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<string>> SelectOptionAsync(SelectOptionValue values, LocatorSelectOptionOptions? options = null)
|
||||
{
|
||||
await LocatorHumanizer.SelectOptionPrologueAsync(_inner, _cursor, _cfg, OptionReader.Timeout(options), OptionReader.Force(options)).ConfigureAwait(false);
|
||||
return await _inner.SelectOptionAsync(values, options).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<string>> SelectOptionAsync(IEnumerable<IElementHandle> values, LocatorSelectOptionOptions? options = null)
|
||||
{
|
||||
await LocatorHumanizer.SelectOptionPrologueAsync(_inner, _cursor, _cfg, OptionReader.Timeout(options), OptionReader.Force(options)).ConfigureAwait(false);
|
||||
return await _inner.SelectOptionAsync(values.Select(Unwrap), options).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<string>> SelectOptionAsync(IEnumerable<SelectOptionValue> values, LocatorSelectOptionOptions? options = null)
|
||||
{
|
||||
await LocatorHumanizer.SelectOptionPrologueAsync(_inner, _cursor, _cfg, OptionReader.Timeout(options), OptionReader.Force(options)).ConfigureAwait(false);
|
||||
return await _inner.SelectOptionAsync(values, options).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// --- ClearAsync ---------------------------------------------------------
|
||||
// Human path: focus (humanized click if needed) + select-all + Backspace,
|
||||
// instead of an instant value reset.
|
||||
|
||||
public Task ClearAsync(LocatorClearOptions? options = null) =>
|
||||
LocatorHumanizer.ClearAsync(_inner, _cursor, _cfg, OptionReader.Timeout(options), OptionReader.Force(options));
|
||||
|
||||
private static IElementHandle Unwrap(IElementHandle handle) =>
|
||||
handle is HumanizedElementHandle h ? h.Original : handle;
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Locator-returning members - re-wrap so chains stay humanized.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
public ILocator First => Wrap(_inner.First);
|
||||
public ILocator Last => Wrap(_inner.Last);
|
||||
public ILocator Nth(int index) => Wrap(_inner.Nth(index));
|
||||
public ILocator Or(ILocator locator) =>
|
||||
Wrap(_inner.Or(locator is HumanizedLocator h ? h.Original : locator));
|
||||
public ILocator And(ILocator locator) =>
|
||||
Wrap(_inner.And(locator is HumanizedLocator h ? h.Original : locator));
|
||||
|
||||
public ILocator Locator(string selectorOrLocator, LocatorLocatorOptions? options = null) =>
|
||||
Wrap(_inner.Locator(selectorOrLocator, options));
|
||||
public ILocator Locator(ILocator selectorOrLocator, LocatorLocatorOptions? options = null) =>
|
||||
Wrap(_inner.Locator(selectorOrLocator is HumanizedLocator h ? h.Original : selectorOrLocator, options));
|
||||
|
||||
public ILocator GetByAltText(string text, LocatorGetByAltTextOptions? options = null) => Wrap(_inner.GetByAltText(text, options));
|
||||
public ILocator GetByAltText(Regex text, LocatorGetByAltTextOptions? options = null) => Wrap(_inner.GetByAltText(text, options));
|
||||
public ILocator GetByLabel(string text, LocatorGetByLabelOptions? options = null) => Wrap(_inner.GetByLabel(text, options));
|
||||
public ILocator GetByLabel(Regex text, LocatorGetByLabelOptions? options = null) => Wrap(_inner.GetByLabel(text, options));
|
||||
public ILocator GetByPlaceholder(string text, LocatorGetByPlaceholderOptions? options = null) => Wrap(_inner.GetByPlaceholder(text, options));
|
||||
public ILocator GetByPlaceholder(Regex text, LocatorGetByPlaceholderOptions? options = null) => Wrap(_inner.GetByPlaceholder(text, options));
|
||||
public ILocator GetByRole(AriaRole role, LocatorGetByRoleOptions? options = null) => Wrap(_inner.GetByRole(role, options));
|
||||
public ILocator GetByTestId(string testId) => Wrap(_inner.GetByTestId(testId));
|
||||
public ILocator GetByTestId(Regex testId) => Wrap(_inner.GetByTestId(testId));
|
||||
public ILocator GetByText(string text, LocatorGetByTextOptions? options = null) => Wrap(_inner.GetByText(text, options));
|
||||
public ILocator GetByText(Regex text, LocatorGetByTextOptions? options = null) => Wrap(_inner.GetByText(text, options));
|
||||
public ILocator GetByTitle(string text, LocatorGetByTitleOptions? options = null) => Wrap(_inner.GetByTitle(text, options));
|
||||
public ILocator GetByTitle(Regex text, LocatorGetByTitleOptions? options = null) => Wrap(_inner.GetByTitle(text, options));
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
using CloakBrowser.Human;
|
||||
using Microsoft.Playwright;
|
||||
|
||||
namespace CloakBrowser.Wrappers;
|
||||
|
||||
/// <summary>
|
||||
/// Transparent humanizing decorator over Playwright's <see cref="IMouse"/>.
|
||||
///
|
||||
/// Intercepted (humanized): <c>MoveAsync</c>, <c>ClickAsync</c>, <c>DblClickAsync</c>,
|
||||
/// <c>DownAsync</c>, <c>UpAsync</c>, <c>WheelAsync</c>. Everything else is delegated
|
||||
/// to the inner mouse by the source generator.
|
||||
/// </summary>
|
||||
[GenerateInterfaceDelegation(typeof(IMouse))]
|
||||
public sealed partial class HumanizedMouse : IMouse
|
||||
{
|
||||
private readonly IMouse _inner;
|
||||
private readonly HumanCursor _cursor;
|
||||
private readonly HumanConfig _cfg;
|
||||
|
||||
internal HumanizedMouse(IMouse inner, HumanCursor cursor, HumanConfig cfg)
|
||||
{
|
||||
_inner = inner;
|
||||
_cursor = cursor;
|
||||
_cfg = cfg;
|
||||
}
|
||||
|
||||
/// <summary>The original, un-humanized Playwright mouse (escape hatch for raw speed).</summary>
|
||||
public IMouse Original => _inner;
|
||||
|
||||
/// <summary>Alias of <see cref="Original"/>.</summary>
|
||||
public IMouse Inner => _inner;
|
||||
|
||||
public async Task MoveAsync(float x, float y, MouseMoveOptions? options = null)
|
||||
{
|
||||
await _cursor.EnsureInitializedAsync(_cfg).ConfigureAwait(false);
|
||||
await HumanMouse.HumanMoveAsync(_cursor.RawMouse, _cursor.X, _cursor.Y, x, y, _cfg).ConfigureAwait(false);
|
||||
_cursor.Set(x, y);
|
||||
}
|
||||
|
||||
public async Task ClickAsync(float x, float y, MouseClickOptions? options = null)
|
||||
{
|
||||
await _cursor.EnsureInitializedAsync(_cfg).ConfigureAwait(false);
|
||||
await HumanMouse.HumanMoveAsync(_cursor.RawMouse, _cursor.X, _cursor.Y, x, y, _cfg).ConfigureAwait(false);
|
||||
_cursor.Set(x, y);
|
||||
await HumanMouse.HumanClickAsync(_cursor.RawMouse, isInput: false, _cfg).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task DblClickAsync(float x, float y, MouseDblClickOptions? options = null)
|
||||
{
|
||||
await _cursor.EnsureInitializedAsync(_cfg).ConfigureAwait(false);
|
||||
await HumanMouse.HumanMoveAsync(_cursor.RawMouse, _cursor.X, _cursor.Y, x, y, _cfg).ConfigureAwait(false);
|
||||
_cursor.Set(x, y);
|
||||
await _cursor.RawMouseDownAsync(2).ConfigureAwait(false);
|
||||
await HumanRandom.SleepMsAsync(HumanRandom.Rand(30, 60)).ConfigureAwait(false);
|
||||
await _cursor.RawMouseUpAsync(2).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task DownAsync(MouseDownOptions? options = null)
|
||||
{
|
||||
await HumanRandom.SleepMsAsync(HumanRandom.RandRange(_cfg.ClickHoldButton)).ConfigureAwait(false);
|
||||
await _inner.DownAsync(options).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task UpAsync(MouseUpOptions? options = null)
|
||||
{
|
||||
await HumanRandom.SleepMsAsync(HumanRandom.RandRange(_cfg.ClickHoldButton)).ConfigureAwait(false);
|
||||
await _inner.UpAsync(options).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task WheelAsync(float deltaX, float deltaY)
|
||||
{
|
||||
await HumanScroll.SmoothWheelAsync(_cursor.RawMouse, deltaX, deltaY, _cfg).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using CloakBrowser.Human;
|
||||
using Microsoft.Playwright;
|
||||
|
||||
namespace CloakBrowser.Wrappers;
|
||||
|
||||
/// <summary>
|
||||
/// Transparent humanizing decorator over Playwright's <see cref="IPage"/>.
|
||||
///
|
||||
/// Selector-based interaction methods (Click/Fill/Type/Hover/Press/Tap/Check/...) are
|
||||
/// routed through the selector-driven <see cref="HumanPage"/> engine. <c>Mouse</c> and
|
||||
/// <c>Keyboard</c> return humanized wrappers; <c>Locator</c>/<c>GetBy*</c>/frames return
|
||||
/// re-wrapped objects so the whole chain stays humanized. Everything else is delegated
|
||||
/// to the inner page by the source generator.
|
||||
/// </summary>
|
||||
[GenerateInterfaceDelegation(typeof(IPage))]
|
||||
public sealed partial class HumanizedPage : IPage
|
||||
{
|
||||
private readonly IPage _inner;
|
||||
private readonly HumanCursor _cursor;
|
||||
private readonly HumanConfig _cfg;
|
||||
private readonly HumanPage _human;
|
||||
private readonly HumanizedMouse _mouse;
|
||||
private readonly HumanizedKeyboard _keyboard;
|
||||
|
||||
internal HumanizedPage(IPage inner, HumanCursor cursor, HumanConfig cfg)
|
||||
{
|
||||
_inner = inner;
|
||||
_cursor = cursor;
|
||||
_cfg = cfg;
|
||||
_human = new HumanPage(inner, cfg);
|
||||
_mouse = new HumanizedMouse(inner.Mouse, cursor, cfg);
|
||||
_keyboard = new HumanizedKeyboard(inner.Keyboard, cursor, cfg);
|
||||
}
|
||||
|
||||
/// <summary>The original, un-humanized Playwright page (escape hatch for raw speed).</summary>
|
||||
public IPage Original => _inner;
|
||||
|
||||
/// <summary>Alias of <see cref="Original"/>.</summary>
|
||||
public IPage Inner => _inner;
|
||||
|
||||
private HumanActionOptions Opt(object? options) => new()
|
||||
{
|
||||
Timeout = OptionReader.Timeout(options),
|
||||
Force = OptionReader.Force(options),
|
||||
};
|
||||
|
||||
private ILocator Wrap(ILocator l) => Humanize.WrapLocator(l, _cursor, _cfg);
|
||||
private IFrame Wrap(IFrame f) => Humanize.WrapFrame(f, _cursor, _cfg);
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Humanized wrappers for nested objects
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
public IMouse Mouse => _mouse;
|
||||
public IKeyboard Keyboard => _keyboard;
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Humanized selector actions
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
public Task ClickAsync(string selector, PageClickOptions? options = null) =>
|
||||
_human.ClickAsync(selector, Opt(options));
|
||||
|
||||
public Task DblClickAsync(string selector, PageDblClickOptions? options = null) =>
|
||||
_human.DblClickAsync(selector, Opt(options));
|
||||
|
||||
public Task HoverAsync(string selector, PageHoverOptions? options = null) =>
|
||||
_human.HoverAsync(selector, Opt(options));
|
||||
|
||||
public Task TapAsync(string selector, PageTapOptions? options = null) =>
|
||||
_human.TapAsync(selector, Opt(options));
|
||||
|
||||
public Task FillAsync(string selector, string value, PageFillOptions? options = null) =>
|
||||
_human.FillAsync(selector, value, Opt(options));
|
||||
|
||||
public Task TypeAsync(string selector, string text, PageTypeOptions? options = null) =>
|
||||
_human.TypeAsync(selector, text, Opt(options));
|
||||
|
||||
public Task PressAsync(string selector, string key, PagePressOptions? options = null) =>
|
||||
_human.PressAsync(selector, key, Opt(options));
|
||||
|
||||
public Task CheckAsync(string selector, PageCheckOptions? options = null) =>
|
||||
_human.CheckAsync(selector, Opt(options));
|
||||
|
||||
public Task UncheckAsync(string selector, PageUncheckOptions? options = null) =>
|
||||
_human.UncheckAsync(selector, Opt(options));
|
||||
|
||||
public Task SetCheckedAsync(string selector, bool checkedState, PageSetCheckedOptions? options = null) =>
|
||||
_human.SetCheckedAsync(selector, checkedState, Opt(options));
|
||||
|
||||
public Task FocusAsync(string selector, PageFocusOptions? options = null) =>
|
||||
_human.FocusAsync(selector, Opt(options));
|
||||
|
||||
public Task DragAndDropAsync(string source, string target, PageDragAndDropOptions? options = null) =>
|
||||
_human.DragAndDropAsync(source, target, Opt(options));
|
||||
|
||||
public Task<IReadOnlyList<string>> SelectOptionAsync(string selector, string values, PageSelectOptionOptions? options = null) =>
|
||||
_human.SelectOptionAsync(selector, new[] { values }, Opt(options));
|
||||
|
||||
public Task<IReadOnlyList<string>> SelectOptionAsync(string selector, IEnumerable<string> values, PageSelectOptionOptions? options = null) =>
|
||||
_human.SelectOptionAsync(selector, values.ToArray(), Opt(options));
|
||||
|
||||
// SelectOption overloads taking handles / SelectOptionValue have no humanized
|
||||
// analogue; hover then delegate so the dropdown still gets a human approach.
|
||||
public async Task<IReadOnlyList<string>> SelectOptionAsync(string selector, IElementHandle values, PageSelectOptionOptions? options = null)
|
||||
{
|
||||
await _human.HoverAsync(selector, Opt(options)).ConfigureAwait(false);
|
||||
return await _inner.SelectOptionAsync(selector, Unwrap(values), options).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<string>> SelectOptionAsync(string selector, IEnumerable<IElementHandle> values, PageSelectOptionOptions? options = null)
|
||||
{
|
||||
await _human.HoverAsync(selector, Opt(options)).ConfigureAwait(false);
|
||||
return await _inner.SelectOptionAsync(selector, values.Select(Unwrap), options).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<string>> SelectOptionAsync(string selector, SelectOptionValue values, PageSelectOptionOptions? options = null)
|
||||
{
|
||||
await _human.HoverAsync(selector, Opt(options)).ConfigureAwait(false);
|
||||
return await _inner.SelectOptionAsync(selector, values, options).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<string>> SelectOptionAsync(string selector, IEnumerable<SelectOptionValue> values, PageSelectOptionOptions? options = null)
|
||||
{
|
||||
await _human.HoverAsync(selector, Opt(options)).ConfigureAwait(false);
|
||||
return await _inner.SelectOptionAsync(selector, values, options).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static IElementHandle Unwrap(IElementHandle h) => h is HumanizedElementHandle hh ? hh.Original : h;
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Locator-returning members - re-wrap.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
public ILocator Locator(string selector, PageLocatorOptions? options = null) => Wrap(_inner.Locator(selector, options));
|
||||
public ILocator GetByAltText(string text, PageGetByAltTextOptions? options = null) => Wrap(_inner.GetByAltText(text, options));
|
||||
public ILocator GetByAltText(Regex text, PageGetByAltTextOptions? options = null) => Wrap(_inner.GetByAltText(text, options));
|
||||
public ILocator GetByLabel(string text, PageGetByLabelOptions? options = null) => Wrap(_inner.GetByLabel(text, options));
|
||||
public ILocator GetByLabel(Regex text, PageGetByLabelOptions? options = null) => Wrap(_inner.GetByLabel(text, options));
|
||||
public ILocator GetByPlaceholder(string text, PageGetByPlaceholderOptions? options = null) => Wrap(_inner.GetByPlaceholder(text, options));
|
||||
public ILocator GetByPlaceholder(Regex text, PageGetByPlaceholderOptions? options = null) => Wrap(_inner.GetByPlaceholder(text, options));
|
||||
public ILocator GetByRole(AriaRole role, PageGetByRoleOptions? options = null) => Wrap(_inner.GetByRole(role, options));
|
||||
public ILocator GetByTestId(string testId) => Wrap(_inner.GetByTestId(testId));
|
||||
public ILocator GetByTestId(Regex testId) => Wrap(_inner.GetByTestId(testId));
|
||||
public ILocator GetByText(string text, PageGetByTextOptions? options = null) => Wrap(_inner.GetByText(text, options));
|
||||
public ILocator GetByText(Regex text, PageGetByTextOptions? options = null) => Wrap(_inner.GetByText(text, options));
|
||||
public ILocator GetByTitle(string text, PageGetByTitleOptions? options = null) => Wrap(_inner.GetByTitle(text, options));
|
||||
public ILocator GetByTitle(Regex text, PageGetByTitleOptions? options = null) => Wrap(_inner.GetByTitle(text, options));
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Frame-returning members - re-wrap.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
public IReadOnlyList<IFrame> Frames => Humanize.WrapFrames(_inner.Frames, _cursor, _cfg);
|
||||
public IFrame MainFrame => Wrap(_inner.MainFrame);
|
||||
public IFrame? Frame(string name) { var f = _inner.Frame(name); return f == null ? null : Wrap(f); }
|
||||
public IFrame? FrameByUrl(string url) { var f = _inner.FrameByUrl(url); return f == null ? null : Wrap(f); }
|
||||
public IFrame? FrameByUrl(Regex url) { var f = _inner.FrameByUrl(url); return f == null ? null : Wrap(f); }
|
||||
public IFrame? FrameByUrl(System.Func<string, bool> url) { var f = _inner.FrameByUrl(url); return f == null ? null : Wrap(f); }
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Navigation - invalidate the isolated world after a navigation.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
public async Task<IResponse?> GotoAsync(string url, PageGotoOptions? options = null)
|
||||
{
|
||||
var resp = await _inner.GotoAsync(url, options).ConfigureAwait(false);
|
||||
_cursor.InvalidateStealth();
|
||||
return resp;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
using CloakBrowser.Human;
|
||||
using Microsoft.Playwright;
|
||||
|
||||
namespace CloakBrowser.Wrappers;
|
||||
|
||||
/// <summary>
|
||||
/// Humanized actions that operate directly on an <see cref="ILocator"/>.
|
||||
///
|
||||
/// The selector-based <see cref="HumanPage"/> drives motion from a CSS/XPath selector;
|
||||
/// locators don't expose their selector string publicly, so this helper drives the
|
||||
/// same Bezier-curve / human-typing engine from the locator's own bounding box and
|
||||
/// the shared <see cref="HumanCursor"/> state of the page it belongs to. The behavior
|
||||
/// (curves, aim points, timing, typing stealth path) is identical to <see cref="HumanPage"/>;
|
||||
/// only the element-resolution path differs.
|
||||
/// </summary>
|
||||
internal static class LocatorHumanizer
|
||||
{
|
||||
private static double RemainingMs(double deadline) => CloakBrowser.Human.Actionability.RemainingMs(deadline);
|
||||
|
||||
private static async Task<BoundingBox?> GetBoxAsync(ILocator locator, double timeoutMs)
|
||||
{
|
||||
try
|
||||
{
|
||||
var box = await locator.First.BoundingBoxAsync(new LocatorBoundingBoxOptions
|
||||
{
|
||||
Timeout = (float)System.Math.Max(1, timeoutMs),
|
||||
}).ConfigureAwait(false);
|
||||
return box == null ? null : new BoundingBox(box.X, box.Y, box.Width, box.Height);
|
||||
}
|
||||
catch (System.Exception)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<bool> IsInputAsync(ILocator locator)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await locator.First.EvaluateAsync<bool>(
|
||||
@"el => {
|
||||
const tag = el.tagName.toLowerCase();
|
||||
return tag === 'input' || tag === 'textarea'
|
||||
|| el.getAttribute('contenteditable') === 'true';
|
||||
}").ConfigureAwait(false);
|
||||
}
|
||||
catch (System.Exception) { return false; }
|
||||
}
|
||||
|
||||
private static async Task<bool> IsFocusedAsync(ILocator locator)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await locator.First.EvaluateAsync<bool>(
|
||||
"el => el === document.activeElement").ConfigureAwait(false);
|
||||
}
|
||||
catch (System.Exception) { return false; }
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Core motion-to-target used by click/hover/dblclick.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private static async Task<(double X, double Y, bool IsInput)> MoveToTargetAsync(
|
||||
ILocator locator, HumanCursor cursor, HumanConfig cfg, double deadline, bool force)
|
||||
{
|
||||
await cursor.EnsureInitializedAsync(cfg).ConfigureAwait(false);
|
||||
|
||||
if (cfg.IdleBetweenActions)
|
||||
await HumanMouse.HumanIdleAsync(cursor.RawMouse,
|
||||
HumanRandom.Rand(cfg.IdleBetweenDuration.Min, cfg.IdleBetweenDuration.Max),
|
||||
cursor.X, cursor.Y, cfg).ConfigureAwait(false);
|
||||
|
||||
var box = await GetBoxAsync(locator, RemainingMs(deadline)).ConfigureAwait(false);
|
||||
bool isInput = await IsInputAsync(locator).ConfigureAwait(false);
|
||||
var target = HumanMouse.ClickTarget(
|
||||
box ?? new BoundingBox(cursor.X, cursor.Y, 1, 1), isInput, cfg);
|
||||
|
||||
await HumanMouse.HumanMoveAsync(cursor.RawMouse, cursor.X, cursor.Y, target.X, target.Y, cfg)
|
||||
.ConfigureAwait(false);
|
||||
cursor.Set(target.X, target.Y);
|
||||
return (target.X, target.Y, isInput);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Public humanized actions
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
public static async Task ClickAsync(
|
||||
ILocator locator, HumanCursor cursor, HumanConfig cfg, double timeout, bool force)
|
||||
{
|
||||
double deadline = System.Environment.TickCount64 + timeout;
|
||||
// Let Playwright wait for actionability (raises on real problems / cancellation).
|
||||
if (!force)
|
||||
await locator.First.ScrollIntoViewIfNeededAsync(
|
||||
new LocatorScrollIntoViewIfNeededOptions { Timeout = (float)RemainingMs(deadline) })
|
||||
.ConfigureAwait(false);
|
||||
var t = await MoveToTargetAsync(locator, cursor, cfg, deadline, force).ConfigureAwait(false);
|
||||
await HumanMouse.HumanClickAsync(cursor.RawMouse, t.IsInput, cfg).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public static async Task DblClickAsync(
|
||||
ILocator locator, HumanCursor cursor, HumanConfig cfg, double timeout, bool force)
|
||||
{
|
||||
double deadline = System.Environment.TickCount64 + timeout;
|
||||
if (!force)
|
||||
await locator.First.ScrollIntoViewIfNeededAsync(
|
||||
new LocatorScrollIntoViewIfNeededOptions { Timeout = (float)RemainingMs(deadline) })
|
||||
.ConfigureAwait(false);
|
||||
await MoveToTargetAsync(locator, cursor, cfg, deadline, force).ConfigureAwait(false);
|
||||
await cursor.RawMouseDownAsync(2).ConfigureAwait(false);
|
||||
await HumanRandom.SleepMsAsync(HumanRandom.Rand(30, 60)).ConfigureAwait(false);
|
||||
await cursor.RawMouseUpAsync(2).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public static async Task HoverAsync(
|
||||
ILocator locator, HumanCursor cursor, HumanConfig cfg, double timeout, bool force)
|
||||
{
|
||||
double deadline = System.Environment.TickCount64 + timeout;
|
||||
if (!force)
|
||||
await locator.First.ScrollIntoViewIfNeededAsync(
|
||||
new LocatorScrollIntoViewIfNeededOptions { Timeout = (float)RemainingMs(deadline) })
|
||||
.ConfigureAwait(false);
|
||||
await MoveToTargetAsync(locator, cursor, cfg, deadline, force).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public static async Task TapAsync(
|
||||
ILocator locator, HumanCursor cursor, HumanConfig cfg, double timeout, bool force) =>
|
||||
await ClickAsync(locator, cursor, cfg, timeout, force).ConfigureAwait(false);
|
||||
|
||||
public static async Task FillAsync(
|
||||
ILocator locator, HumanCursor cursor, HumanConfig cfg, double timeout, bool force, string value)
|
||||
{
|
||||
double deadline = System.Environment.TickCount64 + timeout;
|
||||
await ClickAsync(locator, cursor, cfg, RemainingMs(deadline), force).ConfigureAwait(false);
|
||||
await HumanRandom.SleepMsAsync(HumanRandom.Rand(100, 250)).ConfigureAwait(false);
|
||||
await cursor.SelectAllAsync().ConfigureAwait(false);
|
||||
await HumanRandom.SleepMsAsync(HumanRandom.Rand(30, 80)).ConfigureAwait(false);
|
||||
await cursor.PressAsync("Backspace").ConfigureAwait(false);
|
||||
await HumanRandom.SleepMsAsync(HumanRandom.Rand(50, 150)).ConfigureAwait(false);
|
||||
await cursor.HumanTypeAsync(value, cfg).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public static async Task TypeAsync(
|
||||
ILocator locator, HumanCursor cursor, HumanConfig cfg, double timeout, bool force, string text)
|
||||
{
|
||||
double deadline = System.Environment.TickCount64 + timeout;
|
||||
if (!await IsFocusedAsync(locator).ConfigureAwait(false))
|
||||
await ClickAsync(locator, cursor, cfg, RemainingMs(deadline), force).ConfigureAwait(false);
|
||||
await HumanRandom.SleepMsAsync(HumanRandom.Rand(100, 250)).ConfigureAwait(false);
|
||||
await cursor.HumanTypeAsync(text, cfg).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public static async Task PressSequentiallyAsync(
|
||||
ILocator locator, HumanCursor cursor, HumanConfig cfg, double timeout, bool force, string text) =>
|
||||
await TypeAsync(locator, cursor, cfg, timeout, force, text).ConfigureAwait(false);
|
||||
|
||||
public static async Task PressAsync(
|
||||
ILocator locator, HumanCursor cursor, HumanConfig cfg, double timeout, bool force, string key)
|
||||
{
|
||||
double deadline = System.Environment.TickCount64 + timeout;
|
||||
if (!await IsFocusedAsync(locator).ConfigureAwait(false))
|
||||
await ClickAsync(locator, cursor, cfg, RemainingMs(deadline), force).ConfigureAwait(false);
|
||||
await HumanRandom.SleepMsAsync(HumanRandom.Rand(50, 150)).ConfigureAwait(false);
|
||||
await cursor.PressAsync(key).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Human pre-roll for <c>select_option</c>: move the cursor along a Bezier curve to
|
||||
/// the <select> element (humanized hover) and pause, mirroring the Python
|
||||
/// <c>_humanized_select_option</c>. The real Playwright select call is performed by
|
||||
/// the caller afterwards (native <select> popups can't be driven by mouse).
|
||||
/// </summary>
|
||||
public static async Task SelectOptionPrologueAsync(
|
||||
ILocator locator, HumanCursor cursor, HumanConfig cfg, double timeout, bool force)
|
||||
{
|
||||
await HoverAsync(locator, cursor, cfg, timeout, force).ConfigureAwait(false);
|
||||
await HumanRandom.SleepMsAsync(HumanRandom.Rand(100, 300)).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Human <c>clear</c>: focus the field (humanized click if not already focused),
|
||||
/// select-all, then press Backspace - instead of an instant value reset. Mirrors the
|
||||
/// Python <c>_humanized_clear</c>.
|
||||
/// </summary>
|
||||
public static async Task ClearAsync(
|
||||
ILocator locator, HumanCursor cursor, HumanConfig cfg, double timeout, bool force)
|
||||
{
|
||||
double deadline = System.Environment.TickCount64 + timeout;
|
||||
if (!await IsFocusedAsync(locator).ConfigureAwait(false))
|
||||
await ClickAsync(locator, cursor, cfg, RemainingMs(deadline), force).ConfigureAwait(false);
|
||||
await HumanRandom.SleepMsAsync(HumanRandom.Rand(50, 100)).ConfigureAwait(false);
|
||||
await cursor.SelectAllAsync().ConfigureAwait(false);
|
||||
await HumanRandom.SleepMsAsync(HumanRandom.Rand(30, 80)).ConfigureAwait(false);
|
||||
await cursor.PressAsync("Backspace").ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
using CloakBrowser.Human;
|
||||
using Xunit;
|
||||
|
||||
namespace CloakBrowser.Tests;
|
||||
|
||||
public class BezierMathTests
|
||||
{
|
||||
// Mirrors Python _FakeRawMouse - records every movement point.
|
||||
private sealed class FakeRawMouse : IRawMouse
|
||||
{
|
||||
public List<(double X, double Y)> Moves { get; } = new();
|
||||
public Task MoveAsync(double x, double y) { Moves.Add((x, y)); return Task.CompletedTask; }
|
||||
public Task DownAsync() => Task.CompletedTask;
|
||||
public Task UpAsync() => Task.CompletedTask;
|
||||
public Task WheelAsync(double dx, double dy) => Task.CompletedTask;
|
||||
}
|
||||
|
||||
private static HumanConfig Cfg() => new()
|
||||
{
|
||||
MouseOvershootChance = 0, // deterministic so the end-point assertion is stable
|
||||
MouseBurstPause = (0, 0),
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public async Task GeneratesMultiplePoints_AndEndsNearTarget()
|
||||
{
|
||||
var raw = new FakeRawMouse();
|
||||
await HumanMouse.HumanMoveAsync(raw, 0, 0, 500, 300, Cfg());
|
||||
|
||||
Assert.True(raw.Moves.Count >= 10, $"too few points: {raw.Moves.Count}");
|
||||
var (lx, ly) = raw.Moves[^1];
|
||||
Assert.True(Math.Abs(lx - 500) < 10, $"end X off: {lx}");
|
||||
Assert.True(Math.Abs(ly - 300) < 10, $"end Y off: {ly}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NoLargeJumps_BetweenConsecutivePoints()
|
||||
{
|
||||
var raw = new FakeRawMouse();
|
||||
await HumanMouse.HumanMoveAsync(raw, 0, 0, 400, 400, Cfg());
|
||||
|
||||
double total = Math.Sqrt(400 * 400 + 400 * 400);
|
||||
double maxJump = total * 0.5;
|
||||
for (int i = 1; i < raw.Moves.Count; i++)
|
||||
{
|
||||
double dx = raw.Moves[i].X - raw.Moves[i - 1].X;
|
||||
double dy = raw.Moves[i].Y - raw.Moves[i - 1].Y;
|
||||
Assert.True(Math.Sqrt(dx * dx + dy * dy) < maxJump, $"jump too big at {i}");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NotStraightLine_HasDeviation()
|
||||
{
|
||||
double maxDev = 0;
|
||||
for (int t = 0; t < 5; t++)
|
||||
{
|
||||
var raw = new FakeRawMouse();
|
||||
await HumanMouse.HumanMoveAsync(raw, 0, 0, 500, 0, Cfg());
|
||||
foreach (var (_, y) in raw.Moves)
|
||||
maxDev = Math.Max(maxDev, Math.Abs(y));
|
||||
}
|
||||
Assert.True(maxDev > 0.5, $"path is basically straight: maxDev={maxDev}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ShortDistance_StillMovesOrNoOp()
|
||||
{
|
||||
var raw = new FakeRawMouse();
|
||||
await HumanMouse.HumanMoveAsync(raw, 100, 100, 103, 102, Cfg());
|
||||
// Python requires >= 1; in .NET dist < 1 -> early return, here dist ~3.6 so it must move.
|
||||
Assert.True(raw.Moves.Count >= 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
using CloakBrowser;
|
||||
using Xunit;
|
||||
|
||||
namespace CloakBrowser.Tests;
|
||||
|
||||
public class BuildArgsTests
|
||||
{
|
||||
[Fact]
|
||||
public void Dedupes_By_FlagKey_UserOverridesStealth()
|
||||
{
|
||||
var args = CloakLauncher.BuildArgs(
|
||||
stealthArgs: true,
|
||||
extraArgs: new List<string> { "--no-sandbox=foo" },
|
||||
headless: true);
|
||||
// --no-sandbox should appear once, with the user's value winning.
|
||||
Assert.Single(args, a => a.StartsWith("--no-sandbox"));
|
||||
Assert.Contains("--no-sandbox=foo", args);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Timezone_And_Locale_Flags_Injected()
|
||||
{
|
||||
var args = CloakLauncher.BuildArgs(
|
||||
stealthArgs: false,
|
||||
extraArgs: null,
|
||||
timezone: "America/New_York",
|
||||
locale: "en-US",
|
||||
headless: true);
|
||||
Assert.Contains("--fingerprint-timezone=America/New_York", args);
|
||||
Assert.Contains("--lang=en-US", args);
|
||||
Assert.Contains("--fingerprint-locale=en-US", args);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Headed_Adds_IgnoreGpuBlocklist()
|
||||
{
|
||||
var args = CloakLauncher.BuildArgs(stealthArgs: false, extraArgs: null, headless: false);
|
||||
Assert.Contains("--ignore-gpu-blocklist", args);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DedicatedParams_Override_UserArgs()
|
||||
{
|
||||
var args = CloakLauncher.BuildArgs(
|
||||
stealthArgs: false,
|
||||
extraArgs: new List<string> { "--fingerprint-timezone=Europe/London" },
|
||||
timezone: "Asia/Tokyo",
|
||||
headless: true);
|
||||
Assert.Single(args, a => a.StartsWith("--fingerprint-timezone"));
|
||||
Assert.Contains("--fingerprint-timezone=Asia/Tokyo", args);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtensionPaths_Produce_LoadExtension_And_DisableExcept()
|
||||
{
|
||||
var tmp = Directory.CreateTempSubdirectory().FullName;
|
||||
try
|
||||
{
|
||||
var args = CloakLauncher.BuildArgs(
|
||||
stealthArgs: false,
|
||||
extraArgs: null,
|
||||
extensionPaths: new List<string> { tmp });
|
||||
Assert.Contains(args, a => a.StartsWith("--load-extension="));
|
||||
Assert.Contains(args, a => a.StartsWith("--disable-extensions-except="));
|
||||
}
|
||||
finally { Directory.Delete(tmp); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NoLocale_NoTimezone_NoFlags()
|
||||
{
|
||||
var args = CloakLauncher.BuildArgs(stealthArgs: false, extraArgs: null, headless: true);
|
||||
Assert.DoesNotContain(args, a => a.StartsWith("--lang="));
|
||||
Assert.DoesNotContain(args, a => a.StartsWith("--fingerprint-timezone="));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StartMaximized_True_AddsFlag()
|
||||
{
|
||||
var args = CloakLauncher.BuildArgs(stealthArgs: true, extraArgs: null, startMaximized: true);
|
||||
Assert.Contains("--start-maximized", args);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StartMaximized_DefaultOff_NoFlag()
|
||||
{
|
||||
var args = CloakLauncher.BuildArgs(stealthArgs: true, extraArgs: null);
|
||||
Assert.DoesNotContain("--start-maximized", args);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StartMaximized_SuppressedByUserWindowSize()
|
||||
{
|
||||
var args = CloakLauncher.BuildArgs(
|
||||
stealthArgs: true,
|
||||
extraArgs: new List<string> { "--window-size=1000,800" },
|
||||
startMaximized: true);
|
||||
Assert.DoesNotContain("--start-maximized", args);
|
||||
Assert.Contains("--window-size=1000,800", args);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StartMaximized_NotDoubled()
|
||||
{
|
||||
var args = CloakLauncher.BuildArgs(
|
||||
stealthArgs: true,
|
||||
extraArgs: new List<string> { "--start-maximized" },
|
||||
startMaximized: true);
|
||||
Assert.Single(args, a => a == "--start-maximized");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\CloakBrowser\CloakBrowser.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,265 @@
|
||||
using CloakBrowser;
|
||||
using Xunit;
|
||||
|
||||
namespace CloakBrowser.Tests;
|
||||
|
||||
public class ConfigTests
|
||||
{
|
||||
[Fact]
|
||||
public void ChromiumVersion_IsPinned()
|
||||
{
|
||||
Assert.Equal("146.0.7680.177.5", Config.ChromiumVersion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DefaultViewport_Matches_Python()
|
||||
{
|
||||
Assert.Equal(1920, Config.DefaultViewportWidth);
|
||||
Assert.Equal(947, Config.DefaultViewportHeight);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IgnoreDefaultArgs_IsNonEmpty()
|
||||
{
|
||||
Assert.NotEmpty(Config.IgnoreDefaultArgs);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetPlatformTag_ReturnsKnownTag()
|
||||
{
|
||||
var tag = Config.GetPlatformTag();
|
||||
Assert.Contains(tag, new[] { "linux-x64", "linux-arm64", "darwin-arm64", "darwin-x64", "windows-x64" });
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("146.0.7680.177.5", "146.0.7680.177.5", 0)]
|
||||
[InlineData("146.0.7680.177.6", "146.0.7680.177.5", 1)]
|
||||
[InlineData("146.0.7680.177.4", "146.0.7680.177.5", -1)]
|
||||
[InlineData("147.0.0.0.0", "146.9.9.9.9", 1)]
|
||||
public void VersionTuple_Comparison(string a, string b, int sign)
|
||||
{
|
||||
var ta = Config.VersionTuple(a);
|
||||
var tb = Config.VersionTuple(b);
|
||||
int cmp = 0;
|
||||
int n = System.Math.Max(ta.Length, tb.Length);
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
int va = i < ta.Length ? ta[i] : 0;
|
||||
int vb = i < tb.Length ? tb[i] : 0;
|
||||
if (va != vb) { cmp = va.CompareTo(vb); break; }
|
||||
}
|
||||
Assert.Equal(sign, System.Math.Sign(cmp));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VersionNewer_Works()
|
||||
{
|
||||
Assert.True(Config.VersionNewer("147.0.0.0.0", "146.0.0.0.0"));
|
||||
Assert.False(Config.VersionNewer("146.0.0.0.0", "146.0.0.0.0"));
|
||||
Assert.False(Config.VersionNewer("145.0.0.0.0", "146.0.0.0.0"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetDefaultStealthArgs_IncludesNoSandboxAndFingerprint()
|
||||
{
|
||||
var args = Config.GetDefaultStealthArgs();
|
||||
Assert.Contains("--no-sandbox", args);
|
||||
Assert.Contains(args, a => a.StartsWith("--fingerprint="));
|
||||
Assert.Contains(args, a => a.StartsWith("--fingerprint-platform="));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetDefaultStealthArgs_RandomSeed_InRange()
|
||||
{
|
||||
var args = Config.GetDefaultStealthArgs();
|
||||
var seedArg = args.First(a => a.StartsWith("--fingerprint="));
|
||||
int seed = int.Parse(seedArg.Split('=')[1]);
|
||||
Assert.InRange(seed, 10000, 99999);
|
||||
}
|
||||
|
||||
// NormalizeRequestedVersion tests (port of Python/JS browser_version pinning)
|
||||
|
||||
[Theory]
|
||||
[InlineData("146.0.7680.177")]
|
||||
[InlineData("146.0.7680.177.5")]
|
||||
[InlineData("148.0.7778.215.2")]
|
||||
[InlineData("1.2.3.4")]
|
||||
[InlineData("123.456.789.012")]
|
||||
public void NormalizeRequestedVersion_ValidFormats(string version)
|
||||
{
|
||||
Assert.Equal(version, Config.NormalizeRequestedVersion(version));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(" 146.0.7680.177 ", "146.0.7680.177")]
|
||||
public void NormalizeRequestedVersion_TrimsWhitespace(string input, string expected)
|
||||
{
|
||||
Assert.Equal(expected, Config.NormalizeRequestedVersion(input));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("v146.0.7680.177")]
|
||||
[InlineData("146")]
|
||||
[InlineData("146.0")]
|
||||
[InlineData("146.0.7680")]
|
||||
[InlineData("146.0.7680.177.5.6")] // 6 segments — too many
|
||||
[InlineData("latest")]
|
||||
[InlineData("../etc/passwd")]
|
||||
[InlineData("١٤٦.0.7680.177")] // non-ASCII digits — parity with JS [0-9]
|
||||
public void NormalizeRequestedVersion_InvalidFormats_Throw(string version)
|
||||
{
|
||||
var ex = Assert.Throws<ArgumentException>(() => Config.NormalizeRequestedVersion(version));
|
||||
Assert.Contains("browser version pin", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NormalizeRequestedVersion_Null_ReturnsNull()
|
||||
{
|
||||
// Isolate the env so a CLOAKBROWSER_VERSION set in the test runner can't
|
||||
// make null fall through to the env value (parity with Python/JS).
|
||||
var prev = Environment.GetEnvironmentVariable("CLOAKBROWSER_VERSION");
|
||||
try
|
||||
{
|
||||
Environment.SetEnvironmentVariable("CLOAKBROWSER_VERSION", null);
|
||||
Assert.Null(Config.NormalizeRequestedVersion(null));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.SetEnvironmentVariable("CLOAKBROWSER_VERSION", prev);
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData(" ")]
|
||||
public void NormalizeRequestedVersion_EmptyOrWhitespace_ReturnsNull(string version)
|
||||
{
|
||||
// Explicit empty/whitespace returns null without reading the env, but
|
||||
// isolate anyway so a set CLOAKBROWSER_VERSION can't perturb the result.
|
||||
var prev = Environment.GetEnvironmentVariable("CLOAKBROWSER_VERSION");
|
||||
try
|
||||
{
|
||||
Environment.SetEnvironmentVariable("CLOAKBROWSER_VERSION", null);
|
||||
Assert.Null(Config.NormalizeRequestedVersion(version));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.SetEnvironmentVariable("CLOAKBROWSER_VERSION", prev);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NormalizeRequestedVersion_ReadsEnv()
|
||||
{
|
||||
var prev = Environment.GetEnvironmentVariable("CLOAKBROWSER_VERSION");
|
||||
try
|
||||
{
|
||||
Environment.SetEnvironmentVariable("CLOAKBROWSER_VERSION", "148.0.7778.215.2");
|
||||
Assert.Equal("148.0.7778.215.2", Config.NormalizeRequestedVersion(null));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.SetEnvironmentVariable("CLOAKBROWSER_VERSION", prev);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NormalizeRequestedVersion_ExplicitWinsOverEnv()
|
||||
{
|
||||
var prev = Environment.GetEnvironmentVariable("CLOAKBROWSER_VERSION");
|
||||
try
|
||||
{
|
||||
Environment.SetEnvironmentVariable("CLOAKBROWSER_VERSION", "146.0.0.0");
|
||||
Assert.Equal("148.0.7778.215.2", Config.NormalizeRequestedVersion("148.0.7778.215.2"));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.SetEnvironmentVariable("CLOAKBROWSER_VERSION", prev);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Config.BinarySupportsHeadlessNoViewport() — parity-critical: Python and JS mirror
|
||||
/// this gate. Threshold is an unshipped version, so the resolved-version path is a
|
||||
/// no-op today; the declared-version path is what these tests pin. In env-serial
|
||||
/// because the override tests mutate CLOAKBROWSER_BINARY_PATH.
|
||||
/// </summary>
|
||||
[Collection("env-serial")]
|
||||
public class HeadlessNoViewportGateTests
|
||||
{
|
||||
[Fact]
|
||||
public void DeclaredBelowThreshold_Off()
|
||||
{
|
||||
// Current live Pro version — one build below the threshold => feature OFF.
|
||||
Assert.False(Config.BinarySupportsHeadlessNoViewport(browserVersion: "148.0.7778.215.3"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeclaredAtThreshold_On()
|
||||
{
|
||||
Assert.True(Config.BinarySupportsHeadlessNoViewport(browserVersion: "148.0.7778.215.4"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeclaredAboveThreshold_On()
|
||||
{
|
||||
Assert.True(Config.BinarySupportsHeadlessNoViewport(browserVersion: "149.0.0.0"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeclaredWinsOverLocalOverride()
|
||||
{
|
||||
var prev = Environment.GetEnvironmentVariable("CLOAKBROWSER_BINARY_PATH");
|
||||
try
|
||||
{
|
||||
Environment.SetEnvironmentVariable("CLOAKBROWSER_BINARY_PATH", "/fake/chrome");
|
||||
Assert.True(Config.BinarySupportsHeadlessNoViewport(browserVersion: "149.0.0.0"));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.SetEnvironmentVariable("CLOAKBROWSER_BINARY_PATH", prev);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LocalOverrideWithoutDeclared_Off()
|
||||
{
|
||||
var prev = Environment.GetEnvironmentVariable("CLOAKBROWSER_BINARY_PATH");
|
||||
try
|
||||
{
|
||||
Environment.SetEnvironmentVariable("CLOAKBROWSER_BINARY_PATH", "/fake/chrome");
|
||||
Assert.False(Config.BinarySupportsHeadlessNoViewport());
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.SetEnvironmentVariable("CLOAKBROWSER_BINARY_PATH", prev);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// BinarySupportsMaximizedWindow() — parity-critical: Python and JS mirror this gate.
|
||||
/// Shares the no_viewport threshold today.
|
||||
/// </summary>
|
||||
public class MaximizedWindowGateTests
|
||||
{
|
||||
[Fact]
|
||||
public void DeclaredBelowThreshold_Off()
|
||||
{
|
||||
Assert.False(Config.BinarySupportsMaximizedWindow(browserVersion: "148.0.7778.215.3"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeclaredAtThreshold_On()
|
||||
{
|
||||
Assert.True(Config.BinarySupportsMaximizedWindow(browserVersion: "148.0.7778.215.4"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeclaredAboveThreshold_On()
|
||||
{
|
||||
Assert.True(Config.BinarySupportsMaximizedWindow(browserVersion: "149.0.0.0"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Runtime.InteropServices;
|
||||
using CloakBrowser;
|
||||
using Xunit;
|
||||
|
||||
namespace CloakBrowser.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Diagnostics.Collect drives the `info` / `doctor` CLI. In quick mode it never
|
||||
/// spawns the binary, so an isolated temp cache dir yields a deterministic
|
||||
/// "free / not installed" result with no network. Env-serial because it pins
|
||||
/// CLOAKBROWSER_CACHE_DIR / CLOAKBROWSER_LICENSE_KEY for the duration.
|
||||
/// </summary>
|
||||
[Collection("env-serial")]
|
||||
public class DiagnosticsTests
|
||||
{
|
||||
[Fact]
|
||||
public void Quick_skips_launch_and_reports_free_license()
|
||||
{
|
||||
var tmp = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName());
|
||||
Directory.CreateDirectory(tmp);
|
||||
string? prevCache = Environment.GetEnvironmentVariable("CLOAKBROWSER_CACHE_DIR");
|
||||
string? prevKey = Environment.GetEnvironmentVariable("CLOAKBROWSER_LICENSE_KEY");
|
||||
try
|
||||
{
|
||||
Environment.SetEnvironmentVariable("CLOAKBROWSER_CACHE_DIR", tmp);
|
||||
Environment.SetEnvironmentVariable("CLOAKBROWSER_LICENSE_KEY", null);
|
||||
|
||||
var diag = Diagnostics.Collect(quick: true);
|
||||
|
||||
var env = Assert.IsType<Dictionary<string, object?>>(diag["environment"]);
|
||||
Assert.NotNull(env["dotnet"]);
|
||||
|
||||
var launch = Assert.IsType<Dictionary<string, object?>>(diag["launch"]);
|
||||
Assert.Equal(false, launch["tested"]);
|
||||
Assert.Contains("--quick", (string)launch["reason"]!);
|
||||
|
||||
var license = Assert.IsType<Dictionary<string, object?>>(diag["license"]);
|
||||
Assert.Equal("free", license["tier"]);
|
||||
|
||||
Assert.True(diag.ContainsKey("binary"));
|
||||
Assert.True(diag.ContainsKey("geoip"));
|
||||
// modules section mirrors Python/JS for cross-language parity
|
||||
var modules = Assert.IsType<Dictionary<string, object?>>(diag["modules"]);
|
||||
Assert.True((bool)modules["playwright"]!);
|
||||
// fonts section is Linux-only
|
||||
Assert.Equal(RuntimeInformation.IsOSPlatform(OSPlatform.Linux), diag.ContainsKey("fonts"));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.SetEnvironmentVariable("CLOAKBROWSER_CACHE_DIR", prevCache);
|
||||
Environment.SetEnvironmentVariable("CLOAKBROWSER_LICENSE_KEY", prevKey);
|
||||
try { Directory.Delete(tmp, recursive: true); } catch { /* best-effort */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
using System.IO;
|
||||
using System.IO.Compression;
|
||||
using System.Runtime.InteropServices;
|
||||
using CloakBrowser;
|
||||
using Xunit;
|
||||
|
||||
namespace CloakBrowser.Tests;
|
||||
|
||||
public class ConfigVersionTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("146.0.7680.177.5", new[] { 146, 0, 7680, 177, 5 })]
|
||||
[InlineData("131.0.6778.33", new[] { 131, 0, 6778, 33 })]
|
||||
[InlineData("1.2", new[] { 1, 2 })]
|
||||
public void VersionTuple_parses_segments(string v, int[] expected)
|
||||
=> Assert.Equal(expected, Config.VersionTuple(v));
|
||||
|
||||
[Theory]
|
||||
[InlineData("146.0.7680.178", "146.0.7680.177", true)] // higher patch
|
||||
[InlineData("147.0.0.0", "146.0.7680.177", true)] // higher major
|
||||
[InlineData("146.0.7680.177", "146.0.7680.177", false)] // equal
|
||||
[InlineData("146.0.7680.176", "146.0.7680.177", false)] // lower
|
||||
[InlineData("146.0.7680", "146.0.7680.177", false)] // shorter == older on the tail
|
||||
[InlineData("146.0.7680.177.5", "146.0.7680.177", true)] // longer == newer
|
||||
public void VersionNewer_compares_correctly(string a, string b, bool expected)
|
||||
=> Assert.Equal(expected, Config.VersionNewer(a, b));
|
||||
|
||||
[Fact]
|
||||
public void PlatformTag_is_known_value()
|
||||
=> Assert.Contains(Config.GetPlatformTag(), Config.AvailablePlatforms);
|
||||
|
||||
[Fact]
|
||||
public void ArchiveName_uses_tag_and_ext()
|
||||
{
|
||||
var name = Config.GetArchiveName("linux-x64");
|
||||
Assert.StartsWith("cloakbrowser-linux-x64", name);
|
||||
Assert.EndsWith(Config.GetArchiveExt(), name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DownloadUrl_contains_base_and_archive()
|
||||
{
|
||||
var url = Config.GetDownloadUrl();
|
||||
Assert.StartsWith(Config.DownloadBaseUrl, url);
|
||||
Assert.Contains("cloakbrowser-", url);
|
||||
}
|
||||
}
|
||||
|
||||
public class ChecksumParseTests
|
||||
{
|
||||
// Real 64-char hex digests (the parser now requires exactly 64 hex chars,
|
||||
// matching the Python/JS parser, so short placeholders are rejected).
|
||||
private const string H1 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
|
||||
private const string H2 = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
|
||||
[Fact]
|
||||
public void ParseChecksums_reads_sha256sums_format()
|
||||
{
|
||||
// Standard format: "<64-hex hash> <filename>" (two spaces).
|
||||
var text =
|
||||
$"{H1} cloakbrowser-linux-x64.tar.gz\n" +
|
||||
$"{H2} cloakbrowser-win-x64.zip\n";
|
||||
var map = Download.ParseChecksums(text);
|
||||
Assert.Equal(H1, map["cloakbrowser-linux-x64.tar.gz"]);
|
||||
Assert.Equal(H2, map["cloakbrowser-win-x64.zip"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseChecksums_uppercase_hash_is_lowercased()
|
||||
{
|
||||
var text = $"{H1.ToUpperInvariant()} file.zip\n";
|
||||
var map = Download.ParseChecksums(text);
|
||||
Assert.Equal(H1, map["file.zip"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseChecksums_ignores_blank_malformed_and_non_64hex_lines()
|
||||
{
|
||||
// Blank lines, junk, short hashes ("abc") and the version= line are all dropped;
|
||||
// only a genuine 64-hex digest line survives.
|
||||
var text = $"\n \nnotavalidline\nabc short.zip\nversion=146.0.7680.177.5\n{H1} file.zip\n";
|
||||
var map = Download.ParseChecksums(text);
|
||||
Assert.Single(map);
|
||||
Assert.Equal(H1, map["file.zip"]);
|
||||
Assert.False(map.ContainsKey("short.zip"));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="Download.WrapperVersionNewer(string, string)"/>, the dotted
|
||||
/// SemVer-ish comparison used by the NuGet wrapper-update check (faithful analog of
|
||||
/// Python's <c>_check_wrapper_update</c> version compare).
|
||||
/// </summary>
|
||||
public class WrapperVersionNewerTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("0.4.0", "0.3.32", true)] // higher minor beats higher patch on older minor
|
||||
[InlineData("0.4.1", "0.4.0", true)] // higher patch
|
||||
[InlineData("1.0.0", "0.9.9", true)] // higher major
|
||||
[InlineData("0.4.0", "0.4.0", false)] // equal
|
||||
[InlineData("0.3.32", "0.4.0", false)] // lower minor
|
||||
[InlineData("0.4.0", "0.4.1", false)] // lower patch
|
||||
[InlineData("0.4", "0.4.0", false)] // shorter == equal on the zero-padded tail
|
||||
[InlineData("0.4.0.1", "0.4.0", true)] // longer == newer when tail is non-zero
|
||||
public void WrapperVersionNewer_compares_correctly(string a, string b, bool expected)
|
||||
=> Assert.Equal(expected, Download.WrapperVersionNewer(a, b));
|
||||
|
||||
[Fact]
|
||||
public void WrapperVersionNewer_tolerates_non_numeric_segments()
|
||||
{
|
||||
// Non-numeric segments parse to 0 rather than throwing.
|
||||
Assert.False(Download.WrapperVersionNewer("0.x.0", "0.4.0"));
|
||||
Assert.True(Download.WrapperVersionNewer("0.4.0", "0.x.0"));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the archive-extraction path-traversal (zip-slip) guard
|
||||
/// <see cref="Download.ResolveSafeEntryPath(string, string)"/>, shared by
|
||||
/// <c>ExtractTar</c> and <c>ExtractZip</c>.
|
||||
/// </summary>
|
||||
public class PathTraversalTests
|
||||
{
|
||||
private static string DestDir() =>
|
||||
Path.Combine(Path.GetTempPath(), "cloak-extract-test");
|
||||
|
||||
[Fact]
|
||||
public void Normal_entry_resolves_inside_destination()
|
||||
{
|
||||
var dest = DestDir();
|
||||
var resolved = Download.ResolveSafeEntryPath(dest, "sub/file.txt");
|
||||
|
||||
var destFull = Path.GetFullPath(dest);
|
||||
var expected = Path.GetFullPath(Path.Combine(destFull, "sub/file.txt"));
|
||||
|
||||
Assert.Equal(expected, resolved);
|
||||
// The resolved path stays under the destination directory.
|
||||
Assert.StartsWith(destFull + Path.DirectorySeparatorChar, resolved, System.StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parent_relative_entry_throws()
|
||||
{
|
||||
var dest = DestDir();
|
||||
var ex = Assert.Throws<System.InvalidOperationException>(
|
||||
() => Download.ResolveSafeEntryPath(dest, "../evil.txt"));
|
||||
// The message names the offending entry.
|
||||
Assert.Contains("../evil.txt", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Absolute_entry_path_throws()
|
||||
{
|
||||
var dest = DestDir();
|
||||
// An absolute entry path escapes the destination via Path.Combine semantics.
|
||||
var absolute = RuntimeInformation.IsOSPlatform(OSPlatform.Windows)
|
||||
? @"C:\Windows\evil.txt"
|
||||
: "/etc/evil.txt";
|
||||
|
||||
var ex = Assert.Throws<System.InvalidOperationException>(
|
||||
() => Download.ResolveSafeEntryPath(dest, absolute));
|
||||
Assert.Contains(absolute, ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Windows_backslash_traversal_throws()
|
||||
{
|
||||
// "..\..\evil" is only a traversal where backslash is a path separator (Windows).
|
||||
// On other platforms backslash is an ordinary filename character, so skip.
|
||||
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
return;
|
||||
|
||||
var dest = DestDir();
|
||||
const string entry = @"..\..\evil";
|
||||
var ex = Assert.Throws<System.InvalidOperationException>(
|
||||
() => Download.ResolveSafeEntryPath(dest, entry));
|
||||
Assert.Contains(entry, ex.Message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using Xunit;
|
||||
|
||||
namespace CloakBrowser.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Serializes test classes that mutate process environment variables
|
||||
/// (CLOAKBROWSER_DOWNLOAD_URL, CLOAKBROWSER_LICENSE_KEY, CLOAKBROWSER_CACHE_DIR, ...)
|
||||
/// so they don't race under xUnit's default cross-collection parallelism.
|
||||
/// </summary>
|
||||
[CollectionDefinition("env-serial")]
|
||||
public sealed class EnvSerialCollection { }
|
||||
@@ -0,0 +1,156 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Runtime.InteropServices;
|
||||
using CloakBrowser;
|
||||
using Xunit;
|
||||
|
||||
namespace CloakBrowser.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Welcome-banner cadence (Download.WelcomeDue): free re-shows every 3 days,
|
||||
/// Pro shows once ever. Pure function over a marker file — fully deterministic.
|
||||
/// </summary>
|
||||
public class WelcomeCadenceTests
|
||||
{
|
||||
private static string TempMarker() => Path.Combine(Path.GetTempPath(), Path.GetRandomFileName());
|
||||
|
||||
[Fact]
|
||||
public void Pro_shows_once_then_never()
|
||||
{
|
||||
var marker = TempMarker();
|
||||
try
|
||||
{
|
||||
Assert.True(Download.WelcomeDue(marker, pro: true)); // absent -> show
|
||||
File.WriteAllText(marker, DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString());
|
||||
Assert.False(Download.WelcomeDue(marker, pro: true)); // exists -> never again
|
||||
}
|
||||
finally { if (File.Exists(marker)) File.Delete(marker); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Free_reshows_after_interval()
|
||||
{
|
||||
var marker = TempMarker();
|
||||
try
|
||||
{
|
||||
Assert.True(Download.WelcomeDue(marker, pro: false)); // absent -> show
|
||||
var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
||||
File.WriteAllText(marker, now.ToString());
|
||||
Assert.False(Download.WelcomeDue(marker, pro: false)); // fresh -> skip
|
||||
File.WriteAllText(marker, (now - Download.WelcomeFreeInterval - 10).ToString());
|
||||
Assert.True(Download.WelcomeDue(marker, pro: false)); // stale -> show again
|
||||
}
|
||||
finally { if (File.Exists(marker)) File.Delete(marker); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Legacy_empty_marker_free_reshows_pro_silent()
|
||||
{
|
||||
var marker = TempMarker();
|
||||
try
|
||||
{
|
||||
File.WriteAllText(marker, ""); // pre-cadence empty marker
|
||||
Assert.True(Download.WelcomeDue(marker, pro: false)); // unparseable -> free re-shows
|
||||
Assert.False(Download.WelcomeDue(marker, pro: true)); // pro: existence = already shown
|
||||
}
|
||||
finally { if (File.Exists(marker)) File.Delete(marker); }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Linux Windows-font mismatch warning. Platform detection and fc-list aren't
|
||||
/// mockable without a DI refactor, so these cover the deterministic paths: the
|
||||
/// non-windows short-circuit (returns before any probe on every OS) and the
|
||||
/// once-per-process guard. Serialized: mutates a static flag + CLOAKBROWSER_CACHE_DIR.
|
||||
/// </summary>
|
||||
[Collection("env-serial")]
|
||||
public class FontWarningTests
|
||||
{
|
||||
[Fact]
|
||||
public void No_warn_no_marker_when_platform_overridden()
|
||||
{
|
||||
var prev = Environment.GetEnvironmentVariable("CLOAKBROWSER_CACHE_DIR");
|
||||
var tmp = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName());
|
||||
Directory.CreateDirectory(tmp);
|
||||
try
|
||||
{
|
||||
Environment.SetEnvironmentVariable("CLOAKBROWSER_CACHE_DIR", tmp);
|
||||
CloakLauncher._fontWarningChecked = false;
|
||||
|
||||
var ex = Record.Exception(() =>
|
||||
CloakLauncher.MaybeWarnWindowsFonts(new[] { "--fingerprint-platform=linux", "--no-sandbox" }));
|
||||
|
||||
Assert.Null(ex); // never throws
|
||||
Assert.False(File.Exists(Path.Combine(tmp, ".font_warning_shown")));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.SetEnvironmentVariable("CLOAKBROWSER_CACHE_DIR", prev);
|
||||
CloakLauncher._fontWarningChecked = false;
|
||||
try { Directory.Delete(tmp, recursive: true); } catch { /* best-effort */ }
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WindowsFontsPresent_returns_within_timeout_when_fc_list_hangs()
|
||||
{
|
||||
// Issue 1 regression guard: a hanging fc-list must not stall the probe past
|
||||
// the 5s ceiling. Shim a sleeping "fc-list" on PATH (POSIX hosts only).
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) return;
|
||||
|
||||
var dir = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName());
|
||||
Directory.CreateDirectory(dir);
|
||||
var shim = Path.Combine(dir, "fc-list");
|
||||
File.WriteAllText(shim, "#!/bin/sh\nsleep 30\n");
|
||||
using (var chmod = Process.Start(new ProcessStartInfo("chmod", $"+x \"{shim}\"") { UseShellExecute = false }))
|
||||
{
|
||||
chmod!.WaitForExit();
|
||||
}
|
||||
|
||||
var prevPath = Environment.GetEnvironmentVariable("PATH");
|
||||
var sw = Stopwatch.StartNew();
|
||||
try
|
||||
{
|
||||
Environment.SetEnvironmentVariable("PATH", dir + Path.PathSeparator + prevPath);
|
||||
var result = CloakLauncher.WindowsFontsPresent();
|
||||
sw.Stop();
|
||||
Assert.Null(result); // timed out -> undeterminable, never "no fonts"
|
||||
Assert.True(sw.Elapsed.TotalSeconds < 8,
|
||||
$"probe took {sw.Elapsed.TotalSeconds:F1}s; the 5s timeout did not bound it");
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.SetEnvironmentVariable("PATH", prevPath);
|
||||
try { Directory.Delete(dir, recursive: true); } catch { /* best-effort */ }
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Probes_at_most_once_per_process()
|
||||
{
|
||||
var prev = Environment.GetEnvironmentVariable("CLOAKBROWSER_CACHE_DIR");
|
||||
var tmp = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName());
|
||||
Directory.CreateDirectory(tmp);
|
||||
try
|
||||
{
|
||||
// Redirect the cache dir so a Linux host without Windows fonts writes
|
||||
// its marker here, not into the real ~/.cloakbrowser.
|
||||
Environment.SetEnvironmentVariable("CLOAKBROWSER_CACHE_DIR", tmp);
|
||||
CloakLauncher._fontWarningChecked = false;
|
||||
var ex = Record.Exception(() =>
|
||||
{
|
||||
CloakLauncher.MaybeWarnWindowsFonts(new[] { "--fingerprint-platform=windows" });
|
||||
CloakLauncher.MaybeWarnWindowsFonts(new[] { "--fingerprint-platform=windows" });
|
||||
});
|
||||
Assert.Null(ex);
|
||||
Assert.True(CloakLauncher._fontWarningChecked); // guard set after first call
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.SetEnvironmentVariable("CLOAKBROWSER_CACHE_DIR", prev);
|
||||
CloakLauncher._fontWarningChecked = false;
|
||||
try { Directory.Delete(tmp, recursive: true); } catch { /* best-effort */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
using CloakBrowser.Human;
|
||||
using Xunit;
|
||||
|
||||
namespace CloakBrowser.Tests.Human;
|
||||
|
||||
/// <summary>
|
||||
/// Ports Python <c>TestNonAsciiKeyboard</c>: non-ASCII characters (Cyrillic, CJK)
|
||||
/// must be entered via <see cref="IRawKeyboard.InsertTextAsync"/> character-by-character,
|
||||
/// while ASCII characters go through <see cref="IRawKeyboard.DownAsync"/> /
|
||||
/// <see cref="IRawKeyboard.UpAsync"/>. Mistyping is disabled (MistypeChance = 0) so the
|
||||
/// key streams are deterministic.
|
||||
/// </summary>
|
||||
public class NonAsciiKeyboardTests
|
||||
{
|
||||
/// <summary>A fake raw keyboard that records the keys pressed and the text inserted.</summary>
|
||||
private sealed class RecordingKeyboard : IRawKeyboard
|
||||
{
|
||||
public List<string> DownKeys { get; } = new();
|
||||
public List<string> UpKeys { get; } = new();
|
||||
public List<string> Inserted { get; } = new();
|
||||
public List<string> Typed { get; } = new();
|
||||
|
||||
public Task DownAsync(string key) { DownKeys.Add(key); return Task.CompletedTask; }
|
||||
public Task UpAsync(string key) { UpKeys.Add(key); return Task.CompletedTask; }
|
||||
public Task TypeAsync(string text) { Typed.Add(text); return Task.CompletedTask; }
|
||||
public Task InsertTextAsync(string text) { Inserted.Add(text); return Task.CompletedTask; }
|
||||
}
|
||||
|
||||
// No delays, no typos: deterministic key streams.
|
||||
private static HumanConfig FastConfig() => new()
|
||||
{
|
||||
TypingDelay = 0,
|
||||
TypingDelaySpread = 0,
|
||||
TypingPauseChance = 0,
|
||||
MistypeChance = 0,
|
||||
ShiftDownDelay = (0, 0),
|
||||
ShiftUpDelay = (0, 0),
|
||||
KeyHold = (0, 0),
|
||||
};
|
||||
|
||||
private static bool IsAscii(string s) => s.All(c => c <= 0x7F);
|
||||
|
||||
[Fact]
|
||||
public async Task Cyrillic_uses_insert_text()
|
||||
{
|
||||
var kb = new RecordingKeyboard();
|
||||
|
||||
await HumanKeyboard.HumanTypeAsync(evaluator: null, kb, "Привет", FastConfig());
|
||||
|
||||
// Whole word arrives through insertText, one character at a time.
|
||||
Assert.Equal("Привет", string.Concat(kb.Inserted));
|
||||
// No non-ASCII character was ever pressed as a key.
|
||||
Assert.All(kb.DownKeys, k => Assert.True(IsAscii(k), $"unexpected non-ASCII key down: {k}"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Mixed_ascii_cyrillic_routes_each_char_correctly()
|
||||
{
|
||||
var kb = new RecordingKeyboard();
|
||||
|
||||
await HumanKeyboard.HumanTypeAsync(evaluator: null, kb, "Hi Мир", FastConfig());
|
||||
|
||||
// ASCII letters are pressed as keys...
|
||||
Assert.Contains("H", kb.DownKeys);
|
||||
Assert.Contains("i", kb.DownKeys);
|
||||
// ...Cyrillic letters are inserted as text.
|
||||
Assert.Contains("М", string.Concat(kb.Inserted));
|
||||
Assert.Contains("и", string.Concat(kb.Inserted));
|
||||
Assert.Contains("р", string.Concat(kb.Inserted));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Cjk_uses_insert_text()
|
||||
{
|
||||
var kb = new RecordingKeyboard();
|
||||
|
||||
await HumanKeyboard.HumanTypeAsync(evaluator: null, kb, "你好", FastConfig());
|
||||
|
||||
Assert.Equal("你好", string.Concat(kb.Inserted));
|
||||
Assert.All(kb.DownKeys, k => Assert.True(IsAscii(k), $"unexpected non-ASCII key down: {k}"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Ascii_uppercase_goes_through_shifted_key_presses_not_insert()
|
||||
{
|
||||
var kb = new RecordingKeyboard();
|
||||
|
||||
await HumanKeyboard.HumanTypeAsync(evaluator: null, kb, "Hi", FastConfig());
|
||||
|
||||
// ASCII never uses insertText.
|
||||
Assert.Empty(kb.Inserted);
|
||||
// Uppercase 'H' is produced by holding Shift.
|
||||
Assert.Contains("Shift", kb.DownKeys);
|
||||
Assert.Contains("H", kb.DownKeys);
|
||||
Assert.Contains("i", kb.DownKeys);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
using System.Diagnostics;
|
||||
using CloakBrowser.Human;
|
||||
using CloakBrowser.Tests.Wrappers; // Fake / FakeProxy test infrastructure
|
||||
using Microsoft.Playwright;
|
||||
using Xunit;
|
||||
|
||||
namespace CloakBrowser.Tests.Human;
|
||||
|
||||
/// <summary>
|
||||
/// Ports Python <c>TestPointerEventsFailOpen</c>: the pointer-events check must "fail open".
|
||||
/// When it cannot run (bounding box / evaluate throws - stale handle, execution context
|
||||
/// destroyed), the check returns promptly instead of blocking until the timeout. But a
|
||||
/// genuine "covered" result (hit == false) must still raise
|
||||
/// <see cref="ElementNotReceivingEventsError"/>.
|
||||
/// </summary>
|
||||
public class PointerEventsFailOpenTests
|
||||
{
|
||||
// Build a Task<PointerResult?> handler value for the FakeProxy. PointerResult is
|
||||
// internal (visible to tests via InternalsVisibleTo), so we can construct one.
|
||||
private static Task<Actionability.PointerResult?> CoveredResult(string covering) =>
|
||||
Task.FromResult<Actionability.PointerResult?>(
|
||||
new Actionability.PointerResult { Hit = false, Covering = covering });
|
||||
|
||||
// --- Locator (selector) variant ----------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task Locator_fails_open_when_evaluate_throws()
|
||||
{
|
||||
var (locator, locRec) = Fake.Of<ILocator>();
|
||||
locRec.On("First", locator);
|
||||
locRec.On("BoundingBoxAsync", _ => throw new PlaywrightException("no element"));
|
||||
locRec.On("EvaluateAsync", _ => throw new PlaywrightException("execution context destroyed"));
|
||||
|
||||
var (page, pageRec) = Fake.Of<IPage>();
|
||||
pageRec.On("Locator", locator);
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
// Must NOT throw and must return promptly (well under the 2000ms timeout).
|
||||
await Actionability.CheckPointerEventsAsync(page, "#x", 100, 100, timeoutMs: 2000);
|
||||
sw.Stop();
|
||||
|
||||
Assert.True(sw.ElapsedMilliseconds < 500,
|
||||
$"fail-open should return promptly, took {sw.ElapsedMilliseconds}ms");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Locator_raises_when_covered()
|
||||
{
|
||||
var (locator, locRec) = Fake.Of<ILocator>();
|
||||
locRec.On("First", locator);
|
||||
locRec.On("BoundingBoxAsync", Task.FromResult<LocatorBoundingBoxResult?>(
|
||||
new LocatorBoundingBoxResult { X = 0, Y = 0, Width = 10, Height = 10 }));
|
||||
locRec.On("EvaluateAsync", _ => CoveredResult("DIV"));
|
||||
|
||||
var (page, pageRec) = Fake.Of<IPage>();
|
||||
pageRec.On("Locator", locator);
|
||||
|
||||
var ex = await Assert.ThrowsAsync<ElementNotReceivingEventsError>(
|
||||
() => Actionability.CheckPointerEventsAsync(page, "#x", 5, 5, timeoutMs: 200));
|
||||
Assert.Contains("DIV", ex.Message);
|
||||
}
|
||||
|
||||
// --- ElementHandle variant ----------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_fails_open_when_evaluate_throws()
|
||||
{
|
||||
var (el, elRec) = Fake.Of<IElementHandle>();
|
||||
elRec.On("BoundingBoxAsync", _ => throw new PlaywrightException("stale handle"));
|
||||
elRec.On("EvaluateAsync", _ => throw new PlaywrightException("execution context destroyed"));
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
await Actionability.CheckPointerEventsHandleAsync(el, 100, 100, timeoutMs: 2000);
|
||||
sw.Stop();
|
||||
|
||||
Assert.True(sw.ElapsedMilliseconds < 500,
|
||||
$"fail-open should return promptly, took {sw.ElapsedMilliseconds}ms");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_raises_when_covered()
|
||||
{
|
||||
var (el, elRec) = Fake.Of<IElementHandle>();
|
||||
elRec.On("BoundingBoxAsync", Task.FromResult<ElementHandleBoundingBoxResult?>(
|
||||
new ElementHandleBoundingBoxResult { X = 0, Y = 0, Width = 10, Height = 10 }));
|
||||
elRec.On("EvaluateAsync", _ => CoveredResult("SPAN"));
|
||||
|
||||
var ex = await Assert.ThrowsAsync<ElementNotReceivingEventsError>(
|
||||
() => Actionability.CheckPointerEventsHandleAsync(el, 5, 5, timeoutMs: 200));
|
||||
Assert.Contains("SPAN", ex.Message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
using CloakBrowser.Human;
|
||||
using Xunit;
|
||||
|
||||
namespace CloakBrowser.Tests.Human;
|
||||
|
||||
/// <summary>
|
||||
/// Ports the spirit of Python <c>TestTimeoutBudget307</c> (issue #307): sequential
|
||||
/// operations share one deadline so the overall timeout budget is never multiplied.
|
||||
///
|
||||
/// The full end-to-end timing test needs a real browser to drive the retry loops, so it
|
||||
/// is marked <c>Skip</c>. What we CAN unit-test without a browser is the shared remaining
|
||||
/// time helper every step uses (<see cref="Actionability.RemainingMs(double)"/>): given a
|
||||
/// single deadline, it must never go negative and must monotonically shrink as time passes.
|
||||
/// </summary>
|
||||
public class TimeoutBudgetTests
|
||||
{
|
||||
[Fact]
|
||||
public void RemainingMs_never_negative_past_deadline()
|
||||
{
|
||||
double pastDeadline = System.Environment.TickCount64 - 1000; // already expired
|
||||
Assert.Equal(0, Actionability.RemainingMs(pastDeadline));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RemainingMs_at_deadline_is_zero()
|
||||
{
|
||||
double now = System.Environment.TickCount64;
|
||||
Assert.True(Actionability.RemainingMs(now) <= 0.0 + 1.0); // ~0 (clamped, never < 0)
|
||||
Assert.True(Actionability.RemainingMs(now) >= 0.0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RemainingMs_positive_before_deadline()
|
||||
{
|
||||
double deadline = System.Environment.TickCount64 + 5000;
|
||||
double remaining = Actionability.RemainingMs(deadline);
|
||||
Assert.InRange(remaining, 1, 5000);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RemainingMs_shrinks_as_time_passes()
|
||||
{
|
||||
double deadline = System.Environment.TickCount64 + 5000;
|
||||
double first = Actionability.RemainingMs(deadline);
|
||||
await Task.Delay(60);
|
||||
double second = Actionability.RemainingMs(deadline);
|
||||
|
||||
Assert.True(second < first, $"remaining should shrink: {first} -> {second}");
|
||||
Assert.True(second >= 0, "remaining must never go negative");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RemainingMs_budget_is_shared_not_multiplied()
|
||||
{
|
||||
// Three sequential "steps" computed from a SINGLE deadline must sum to <= the
|
||||
// original budget - they carve out of one budget rather than each getting the full
|
||||
// timeout (the bug behind issue #307).
|
||||
const double budget = 1000;
|
||||
double deadline = System.Environment.TickCount64 + budget;
|
||||
|
||||
double step1 = Actionability.RemainingMs(deadline);
|
||||
double step2 = Actionability.RemainingMs(deadline);
|
||||
double step3 = Actionability.RemainingMs(deadline);
|
||||
|
||||
// Each subsequent read is <= the previous (time only moves forward) and never
|
||||
// exceeds the single budget.
|
||||
Assert.True(step1 <= budget + 1);
|
||||
Assert.True(step2 <= step1 + 1);
|
||||
Assert.True(step3 <= step2 + 1);
|
||||
}
|
||||
|
||||
[Fact(Skip = "requires browser: drives the full page.click retry loop to measure end-to-end timing")]
|
||||
public void Page_click_total_time_within_budget()
|
||||
{
|
||||
// Python TestTimeoutBudget307.test_page_click_total_time_within_budget patches a
|
||||
// live page and asserts the wall-clock click time stays < 1.8x the timeout. That
|
||||
// exercises real Playwright locator waits and cannot be faithfully reproduced with
|
||||
// DispatchProxy fakes, so it is covered by the browser-backed integration suite.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
using CloakBrowser.Human;
|
||||
using Xunit;
|
||||
|
||||
namespace CloakBrowser.Tests;
|
||||
|
||||
public class HumanConfigTests
|
||||
{
|
||||
[Fact]
|
||||
public void DefaultPreset_HasExpectedDefaults()
|
||||
{
|
||||
var cfg = HumanConfigFactory.Resolve(HumanPreset.Default);
|
||||
Assert.Equal(70, cfg.TypingDelay);
|
||||
Assert.Equal((15, 35), (cfg.KeyHold.Min, cfg.KeyHold.Max));
|
||||
Assert.False(cfg.IdleBetweenActions);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CarefulPreset_IsSlower()
|
||||
{
|
||||
var cfg = HumanConfigFactory.Resolve(HumanPreset.Careful);
|
||||
Assert.Equal(100, cfg.TypingDelay);
|
||||
Assert.True(cfg.IdleBetweenActions);
|
||||
Assert.Equal((20, 45), (cfg.KeyHold.Min, cfg.KeyHold.Max));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Overrides_SnakeCase_Keys_Applied()
|
||||
{
|
||||
var cfg = HumanConfigFactory.Resolve(HumanPreset.Default, new Dictionary<string, object>
|
||||
{
|
||||
["typing_delay"] = 200.0,
|
||||
["mistype_chance"] = 0.5,
|
||||
});
|
||||
Assert.Equal(200, cfg.TypingDelay);
|
||||
Assert.Equal(0.5, cfg.MistypeChance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Overrides_PascalCase_Keys_Applied()
|
||||
{
|
||||
var cfg = HumanConfigFactory.Resolve(HumanPreset.Default, new Dictionary<string, object>
|
||||
{
|
||||
["TypingDelay"] = 250.0,
|
||||
});
|
||||
Assert.Equal(250, cfg.TypingDelay);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Overrides_Range_From_Tuple()
|
||||
{
|
||||
var cfg = HumanConfigFactory.Resolve(HumanPreset.Default, new Dictionary<string, object>
|
||||
{
|
||||
["key_hold"] = (50.0, 100.0),
|
||||
});
|
||||
Assert.Equal((50, 100), (cfg.KeyHold.Min, cfg.KeyHold.Max));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Overrides_Range_From_Array()
|
||||
{
|
||||
var cfg = HumanConfigFactory.Resolve(HumanPreset.Default, new Dictionary<string, object>
|
||||
{
|
||||
["key_hold"] = new object[] { 60, 120 },
|
||||
});
|
||||
Assert.Equal((60, 120), (cfg.KeyHold.Min, cfg.KeyHold.Max));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Unknown_Keys_Ignored()
|
||||
{
|
||||
var cfg = HumanConfigFactory.Resolve(HumanPreset.Default, new Dictionary<string, object>
|
||||
{
|
||||
["does_not_exist"] = 5,
|
||||
});
|
||||
Assert.Equal(70, cfg.TypingDelay); // unchanged
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParsePreset_CaseInsensitive()
|
||||
{
|
||||
Assert.Equal(HumanPreset.Careful, HumanConfigFactory.ParsePreset("CAREFUL"));
|
||||
Assert.Equal(HumanPreset.Default, HumanConfigFactory.ParsePreset(null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParsePreset_Invalid_Throws()
|
||||
{
|
||||
Assert.Throws<System.ArgumentException>(() => HumanConfigFactory.ParsePreset("nope"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void With_DoesNotMutate_Base()
|
||||
{
|
||||
var baseCfg = new HumanConfig();
|
||||
var merged = baseCfg.With(new Dictionary<string, object> { ["typing_delay"] = 999.0 });
|
||||
Assert.Equal(70, baseCfg.TypingDelay);
|
||||
Assert.Equal(999, merged.TypingDelay);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void With_ReturnsNewInstance_NotSameReference()
|
||||
{
|
||||
var baseCfg = new HumanConfig();
|
||||
var merged = baseCfg.With(new Dictionary<string, object> { ["typing_delay"] = 123.0 });
|
||||
Assert.NotSame(baseCfg, merged);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void With_NullOverrides_ReturnsEquivalentClone()
|
||||
{
|
||||
var baseCfg = new HumanConfig { TypingDelay = 42, MistypeChance = 0.3 };
|
||||
var merged = baseCfg.With(null);
|
||||
|
||||
// Equivalent values but a distinct instance (never the same reference).
|
||||
Assert.NotSame(baseCfg, merged);
|
||||
Assert.Equal(42, merged.TypingDelay);
|
||||
Assert.Equal(0.3, merged.MistypeChance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void With_EmptyOverrides_ReturnsEquivalentClone()
|
||||
{
|
||||
var baseCfg = new HumanConfig { TypingDelay = 55 };
|
||||
var merged = baseCfg.With(new Dictionary<string, object>());
|
||||
|
||||
Assert.NotSame(baseCfg, merged);
|
||||
Assert.Equal(55, merged.TypingDelay);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void With_PreservesNonOverriddenFields()
|
||||
{
|
||||
var baseCfg = new HumanConfig(); // defaults
|
||||
var merged = baseCfg.With(new Dictionary<string, object> { ["typing_delay"] = 500.0 });
|
||||
|
||||
// The overridden field changed...
|
||||
Assert.Equal(500, merged.TypingDelay);
|
||||
// ...while every non-overridden field keeps its base value.
|
||||
Assert.Equal(baseCfg.MistypeChance, merged.MistypeChance);
|
||||
Assert.Equal((baseCfg.KeyHold.Min, baseCfg.KeyHold.Max), (merged.KeyHold.Min, merged.KeyHold.Max));
|
||||
Assert.Equal(baseCfg.MouseMinSteps, merged.MouseMinSteps);
|
||||
Assert.Equal(baseCfg.IdleBetweenActions, merged.IdleBetweenActions);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void With_UnknownKeys_DoNotThrow_AndLeaveConfigValid()
|
||||
{
|
||||
var baseCfg = new HumanConfig();
|
||||
var ex = Record.Exception(() => baseCfg.With(new Dictionary<string, object>
|
||||
{
|
||||
["totally_unknown_key"] = 5,
|
||||
["another_bogus_one"] = "x",
|
||||
["typing_delay"] = 88.0, // a valid one alongside the junk
|
||||
}));
|
||||
|
||||
Assert.Null(ex);
|
||||
Assert.Equal(88, baseCfg.With(new Dictionary<string, object>
|
||||
{
|
||||
["totally_unknown_key"] = 5,
|
||||
["typing_delay"] = 88.0,
|
||||
}).TypingDelay);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,437 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using CloakBrowser;
|
||||
using Xunit;
|
||||
|
||||
namespace CloakBrowser.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// CloakBrowser Pro license validation, caching, key resolution, Pro-aware config,
|
||||
/// and the binary_info tier - port of Python <c>tests/test_license.py</c> and JS
|
||||
/// <c>js/tests/license.test.ts</c>.
|
||||
///
|
||||
/// Tests are serialized (a shared collection) because they manipulate process env
|
||||
/// vars and a temp cache dir.
|
||||
/// </summary>
|
||||
[Collection("env-serial")]
|
||||
public class LicenseTests : IDisposable
|
||||
{
|
||||
private readonly string _tmp;
|
||||
private readonly string? _prevCacheDir;
|
||||
private readonly string? _prevLicenseEnv;
|
||||
private readonly string? _prevDownloadUrl;
|
||||
|
||||
public LicenseTests()
|
||||
{
|
||||
_tmp = Path.Combine(Path.GetTempPath(), $"cloak-lic-test-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(_tmp);
|
||||
_prevCacheDir = Environment.GetEnvironmentVariable("CLOAKBROWSER_CACHE_DIR");
|
||||
_prevLicenseEnv = Environment.GetEnvironmentVariable("CLOAKBROWSER_LICENSE_KEY");
|
||||
_prevDownloadUrl = Environment.GetEnvironmentVariable("CLOAKBROWSER_DOWNLOAD_URL");
|
||||
Environment.SetEnvironmentVariable("CLOAKBROWSER_CACHE_DIR", _tmp);
|
||||
Environment.SetEnvironmentVariable("CLOAKBROWSER_LICENSE_KEY", null);
|
||||
Environment.SetEnvironmentVariable("CLOAKBROWSER_DOWNLOAD_URL", null);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Environment.SetEnvironmentVariable("CLOAKBROWSER_CACHE_DIR", _prevCacheDir);
|
||||
Environment.SetEnvironmentVariable("CLOAKBROWSER_LICENSE_KEY", _prevLicenseEnv);
|
||||
Environment.SetEnvironmentVariable("CLOAKBROWSER_DOWNLOAD_URL", _prevDownloadUrl);
|
||||
License.ValidateLicenseOverride = null;
|
||||
License.ProLatestVersionOverride = null;
|
||||
try { if (Directory.Exists(_tmp)) Directory.Delete(_tmp, recursive: true); } catch (IOException) { }
|
||||
}
|
||||
|
||||
private static string Sha256Hex(string s) =>
|
||||
Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(s))).ToLowerInvariant();
|
||||
|
||||
private void WriteCache(string key, bool valid, string plan, string? expires, double validatedAt)
|
||||
{
|
||||
var payload = JsonSerializer.Serialize(new Dictionary<string, object?>
|
||||
{
|
||||
["key_sha256"] = Sha256Hex(key),
|
||||
["valid"] = valid,
|
||||
["plan"] = plan,
|
||||
["expires"] = expires,
|
||||
["validated_at"] = validatedAt,
|
||||
});
|
||||
File.WriteAllText(Path.Combine(_tmp, ".license_cache"), payload);
|
||||
}
|
||||
|
||||
private static double Now() => DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() / 1000.0;
|
||||
|
||||
// =======================================================================
|
||||
// ResolveLicenseKey - param > env > file > null
|
||||
// =======================================================================
|
||||
|
||||
[Fact]
|
||||
public void ExplicitParam_wins()
|
||||
{
|
||||
Environment.SetEnvironmentVariable("CLOAKBROWSER_LICENSE_KEY", "env-key");
|
||||
Assert.Equal("param-key", License.ResolveLicenseKey("param-key"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnvVar_fallback()
|
||||
{
|
||||
Environment.SetEnvironmentVariable("CLOAKBROWSER_LICENSE_KEY", "env-key");
|
||||
Assert.Equal("env-key", License.ResolveLicenseKey(null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Returns_null_when_absent()
|
||||
{
|
||||
Assert.Null(License.ResolveLicenseKey(null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EmptyString_param_uses_env()
|
||||
{
|
||||
Environment.SetEnvironmentVariable("CLOAKBROWSER_LICENSE_KEY", "env-key");
|
||||
Assert.Equal("env-key", License.ResolveLicenseKey(" "));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void File_fallback()
|
||||
{
|
||||
File.WriteAllText(Path.Combine(_tmp, "license.key"), "file-key\n");
|
||||
Assert.Equal("file-key", License.ResolveLicenseKey(null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Env_takes_precedence_over_file()
|
||||
{
|
||||
File.WriteAllText(Path.Combine(_tmp, "license.key"), "file-key");
|
||||
Environment.SetEnvironmentVariable("CLOAKBROWSER_LICENSE_KEY", "env-key");
|
||||
Assert.Equal("env-key", License.ResolveLicenseKey(null));
|
||||
}
|
||||
|
||||
// =======================================================================
|
||||
// ValidateLicense - cache + server + stale fallback
|
||||
// =======================================================================
|
||||
|
||||
[Fact]
|
||||
public void FreshCache_skips_server()
|
||||
{
|
||||
WriteCache("k", valid: true, plan: "team", expires: null, validatedAt: Now());
|
||||
// No override set, but a fresh cache must short-circuit before any HTTP.
|
||||
var info = License.ValidateLicense("k");
|
||||
Assert.NotNull(info);
|
||||
Assert.True(info!.Valid);
|
||||
Assert.Equal("team", info.Plan);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StaleCache_is_ignored_by_fresh_read()
|
||||
{
|
||||
// Older than 24h -> not returned from the fresh read; server override supplies a new one.
|
||||
WriteCache("k", valid: true, plan: "solo", expires: null, validatedAt: Now() - 90000);
|
||||
License.ValidateLicenseOverride = key => new LicenseInfo(true, "team", null);
|
||||
var info = License.ValidateLicense("k");
|
||||
Assert.Equal("team", info!.Plan);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Server_rejection_returns_invalid()
|
||||
{
|
||||
License.ValidateLicenseOverride = key => new LicenseInfo(false, "solo", null);
|
||||
var info = License.ValidateLicense("bad");
|
||||
Assert.NotNull(info);
|
||||
Assert.False(info!.Valid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Cache_stores_hash_not_raw_key()
|
||||
{
|
||||
// The on-disk cache must store a SHA-256 of the key, never the raw secret.
|
||||
WriteCache("super-secret-key", valid: true, plan: "team", expires: null, validatedAt: Now());
|
||||
var contents = File.ReadAllText(Path.Combine(_tmp, ".license_cache"));
|
||||
Assert.DoesNotContain("super-secret-key", contents);
|
||||
Assert.Contains(Sha256Hex("super-secret-key"), contents);
|
||||
// And a fresh read of that hashed entry round-trips.
|
||||
var info = License.ValidateLicense("super-secret-key");
|
||||
Assert.True(info!.Valid);
|
||||
Assert.Equal("team", info.Plan);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WrongKey_cache_ignored()
|
||||
{
|
||||
WriteCache("other-key", valid: true, plan: "team", expires: null, validatedAt: Now());
|
||||
License.ValidateLicenseOverride = key => new LicenseInfo(true, "solo", null);
|
||||
var info = License.ValidateLicense("my-key");
|
||||
// Cache belongs to a different key -> ignored; server override result used.
|
||||
Assert.Equal("solo", info!.Plan);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExpiredLicense_rejected_from_cache()
|
||||
{
|
||||
var pastIso = DateTimeOffset.UtcNow.AddDays(-1).ToString("o");
|
||||
WriteCache("k", valid: true, plan: "solo", expires: pastIso, validatedAt: Now());
|
||||
var info = License.ValidateLicense("k");
|
||||
Assert.NotNull(info);
|
||||
Assert.False(info!.Valid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CorruptedValidatedAt_does_not_crash()
|
||||
{
|
||||
var payload = JsonSerializer.Serialize(new Dictionary<string, object?>
|
||||
{
|
||||
["key_sha256"] = Sha256Hex("k"),
|
||||
["valid"] = true,
|
||||
["plan"] = "solo",
|
||||
["expires"] = null,
|
||||
["validated_at"] = "not-a-number",
|
||||
});
|
||||
File.WriteAllText(Path.Combine(_tmp, ".license_cache"), payload);
|
||||
License.ValidateLicenseOverride = key => new LicenseInfo(true, "team", null);
|
||||
// Corrupt cache treated as absent -> server override consulted, no crash.
|
||||
var info = License.ValidateLicense("k");
|
||||
Assert.Equal("team", info!.Plan);
|
||||
}
|
||||
|
||||
// =======================================================================
|
||||
// GetProLatestVersion - rate limiting + marker
|
||||
// =======================================================================
|
||||
|
||||
[Fact]
|
||||
public void ProLatestVersion_rate_limited_reads_marker()
|
||||
{
|
||||
var marker = Path.Combine(_tmp, $".last_pro_version_check_{Config.GetPlatformTag()}");
|
||||
File.WriteAllText(marker, "148.0.7778.215.2");
|
||||
// Fresh marker (just written) -> returns cached value without server.
|
||||
Assert.Equal("148.0.7778.215.2", License.GetProLatestVersion());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProLatestVersion_override_used()
|
||||
{
|
||||
License.ProLatestVersionOverride = () => "149.0.0.0";
|
||||
Assert.Equal("149.0.0.0", License.GetProLatestVersion());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProLatestVersion_sends_platform_header()
|
||||
{
|
||||
// Exercise the real SendAsync path (no override) via a recording handler.
|
||||
var recorder = new RecordingHandler("{\"version\":\"147.0.1234.5\"}");
|
||||
var original = License.Http;
|
||||
License.Http = new HttpClient(recorder);
|
||||
try
|
||||
{
|
||||
var version = License.GetProLatestVersion();
|
||||
Assert.Equal("147.0.1234.5", version);
|
||||
Assert.Equal(Config.GetPlatformTag(), recorder.LastPlatform);
|
||||
}
|
||||
finally
|
||||
{
|
||||
License.Http.Dispose();
|
||||
License.Http = original;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Captures the X-Platform header off the outgoing request and returns a canned body.</summary>
|
||||
private sealed class RecordingHandler : HttpMessageHandler
|
||||
{
|
||||
private readonly string _body;
|
||||
public string? LastPlatform { get; private set; }
|
||||
|
||||
public RecordingHandler(string body) => _body = body;
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
// Read the header value here — `request` is disposed by the caller after the call.
|
||||
LastPlatform = request.Headers.TryGetValues("X-Platform", out var values)
|
||||
? values.FirstOrDefault()
|
||||
: null;
|
||||
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(_body),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// =======================================================================
|
||||
// Config Pro paths
|
||||
// =======================================================================
|
||||
|
||||
[Fact]
|
||||
public void BinaryDir_pro_suffix()
|
||||
{
|
||||
var dir = Config.GetBinaryDir("148.0.7778.215.2", pro: true);
|
||||
Assert.EndsWith("chromium-148.0.7778.215.2-pro", dir);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BinaryDir_default_no_suffix()
|
||||
{
|
||||
var dir = Config.GetBinaryDir("146.0.7680.177.5", pro: false);
|
||||
Assert.EndsWith("chromium-146.0.7680.177.5", dir);
|
||||
Assert.DoesNotContain("-pro", Path.GetFileName(dir));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EffectiveVersion_pro_marker_without_binary_returns_null()
|
||||
{
|
||||
var marker = Path.Combine(_tmp, $"latest_pro_version_{Config.GetPlatformTag()}");
|
||||
File.WriteAllText(marker, "148.0.7778.215.2");
|
||||
// Ticket 431 Fix 4: marker present but no Pro binary on disk -> null, NOT the
|
||||
// free base. A valid Pro license must never fall back to the free binary.
|
||||
Assert.Null(Config.GetEffectiveVersion(pro: true));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EffectiveVersion_pro_no_marker_returns_null_free_returns_base()
|
||||
{
|
||||
// No Pro marker at all -> null for Pro; free tier still resolves to a version.
|
||||
Assert.Null(Config.GetEffectiveVersion(pro: true));
|
||||
Assert.Equal(Config.GetChromiumVersion(), Config.GetEffectiveVersion(pro: false));
|
||||
}
|
||||
|
||||
// Create a fake cached, executable Pro binary for `version`.
|
||||
private static void MakeProBinary(string version)
|
||||
{
|
||||
var p = Config.GetBinaryPath(version, pro: true);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(p)!);
|
||||
File.WriteAllText(p, "binary");
|
||||
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
File.SetUnixFileMode(p,
|
||||
UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CheckForProUpdate_already_latest_returns_null()
|
||||
{
|
||||
// Ticket 431 Fix 1: `update` on a Pro install already at latest is a no-op.
|
||||
File.WriteAllText(
|
||||
Path.Combine(_tmp, $"latest_pro_version_{Config.GetPlatformTag()}"),
|
||||
"148.0.7778.215.5");
|
||||
MakeProBinary("148.0.7778.215.5");
|
||||
License.ProLatestVersionOverride = () => "148.0.7778.215.5";
|
||||
try
|
||||
{
|
||||
Assert.Null(Download.CheckForProUpdate("cb_key"));
|
||||
}
|
||||
finally { License.ProLatestVersionOverride = null; }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CheckForProUpdate_server_down_returns_null()
|
||||
{
|
||||
License.ProLatestVersionOverride = () => null;
|
||||
try
|
||||
{
|
||||
Assert.Null(Download.CheckForProUpdate("cb_key"));
|
||||
}
|
||||
finally { License.ProLatestVersionOverride = null; }
|
||||
}
|
||||
|
||||
// =======================================================================
|
||||
// BuildLaunchEnv
|
||||
// =======================================================================
|
||||
|
||||
[Fact]
|
||||
public void BuildLaunchEnv_no_key_returns_null()
|
||||
{
|
||||
Assert.Null(License.BuildLaunchEnv());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildLaunchEnv_explicit_param_injects_env()
|
||||
{
|
||||
var result = License.BuildLaunchEnv("cb_test_key");
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("cb_test_key", result["CLOAKBROWSER_LICENSE_KEY"]);
|
||||
// Parent env vars should be present
|
||||
Assert.Contains("PATH", result.Keys);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildLaunchEnv_env_source_no_user_env_returns_null()
|
||||
{
|
||||
var prev = Environment.GetEnvironmentVariable("CLOAKBROWSER_LICENSE_KEY");
|
||||
try
|
||||
{
|
||||
Environment.SetEnvironmentVariable("CLOAKBROWSER_LICENSE_KEY", "cb_env");
|
||||
Assert.Null(License.BuildLaunchEnv());
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.SetEnvironmentVariable("CLOAKBROWSER_LICENSE_KEY", prev);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildLaunchEnv_env_source_with_user_env_preserves_key()
|
||||
{
|
||||
var prev = Environment.GetEnvironmentVariable("CLOAKBROWSER_LICENSE_KEY");
|
||||
try
|
||||
{
|
||||
Environment.SetEnvironmentVariable("CLOAKBROWSER_LICENSE_KEY", "cb_env");
|
||||
var result = License.BuildLaunchEnv(null, new Dictionary<string, string> { ["MY_VAR"] = "1" });
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("cb_env", result["CLOAKBROWSER_LICENSE_KEY"]);
|
||||
Assert.Equal("1", result["MY_VAR"]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.SetEnvironmentVariable("CLOAKBROWSER_LICENSE_KEY", prev);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildLaunchEnv_default_file_skips_injection()
|
||||
{
|
||||
// Place license.key in the default ~/.cloakbrowser path
|
||||
var homeDir = Path.Combine(_tmp, "home");
|
||||
var defaultCache = Path.Combine(homeDir, ".cloakbrowser");
|
||||
Directory.CreateDirectory(defaultCache);
|
||||
File.WriteAllText(Path.Combine(defaultCache, "license.key"), "cb_file");
|
||||
|
||||
var prevCacheDir = Environment.GetEnvironmentVariable("CLOAKBROWSER_CACHE_DIR");
|
||||
try
|
||||
{
|
||||
Environment.SetEnvironmentVariable("CLOAKBROWSER_CACHE_DIR", defaultCache);
|
||||
// Mock the OS home path via the test seam so the cache dir is
|
||||
// recognized as the default ~/.cloakbrowser path.
|
||||
License.HomeDirOverride = () => homeDir;
|
||||
Assert.Null(License.BuildLaunchEnv());
|
||||
|
||||
// With a custom userEnv, Playwright replaces the child env (which
|
||||
// could drop HOME and hide the file), so the key IS injected.
|
||||
var withUser = License.BuildLaunchEnv(null, new Dictionary<string, string> { ["KEEP"] = "me" });
|
||||
Assert.NotNull(withUser);
|
||||
Assert.Equal("me", withUser["KEEP"]);
|
||||
Assert.Equal("cb_file", withUser["CLOAKBROWSER_LICENSE_KEY"]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.SetEnvironmentVariable("CLOAKBROWSER_CACHE_DIR", prevCacheDir);
|
||||
License.HomeDirOverride = null;
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildLaunchEnv_user_env_preserved()
|
||||
{
|
||||
var result = License.BuildLaunchEnv("cb_mine", new Dictionary<string, string> { ["PATH"] = "/custom/bin" });
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("cb_mine", result["CLOAKBROWSER_LICENSE_KEY"]);
|
||||
Assert.Equal("/custom/bin", result["PATH"]);
|
||||
// Only the user env + injected key — NOT the full parent environment.
|
||||
Assert.Equal(2, result.Count);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
using CloakBrowser;
|
||||
using CloakBrowser.Human;
|
||||
using Xunit;
|
||||
using Range = CloakBrowser.Human.Range;
|
||||
|
||||
namespace CloakBrowser.Tests;
|
||||
|
||||
public class GeoIpTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("10.0.0.1", true)]
|
||||
[InlineData("192.168.1.1", true)]
|
||||
[InlineData("172.16.0.1", true)]
|
||||
[InlineData("127.0.0.1", true)]
|
||||
[InlineData("8.8.8.8", false)]
|
||||
[InlineData("1.1.1.1", false)]
|
||||
public void IsPrivateIp_Classifies(string ip, bool isPrivate)
|
||||
{
|
||||
Assert.Equal(isPrivate, GeoIp.IsPrivateIp(ip));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CountryLocaleMap_HasCommonCountries()
|
||||
{
|
||||
Assert.True(GeoIp.CountryLocaleMap.ContainsKey("US"));
|
||||
Assert.True(GeoIp.CountryLocaleMap.ContainsKey("DE"));
|
||||
// Extended-coverage entries (parity with Python/JS COUNTRY_LOCALE_MAP).
|
||||
Assert.Equal("sl-SI", GeoIp.CountryLocaleMap["SI"]);
|
||||
Assert.Equal("es-PE", GeoIp.CountryLocaleMap["PE"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MaybeResolveGeoIp_Disabled_ReturnsInputWithoutResolving()
|
||||
{
|
||||
var (tz, loc, ip) = await CloakLauncher.MaybeResolveGeoIpAsync(
|
||||
geoip: false, proxy: "http://proxy:8080", timezone: null, locale: null);
|
||||
Assert.Null(tz);
|
||||
Assert.Null(loc);
|
||||
Assert.Null(ip);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MaybeResolveGeoIp_NoProxy_BothExplicit_SkipsExitIpFetch()
|
||||
{
|
||||
// No proxy + explicit tz/locale → the WebRTC IP would just be the real
|
||||
// connection IP the site already sees (a no-op), so no echo call is made
|
||||
// and the exit IP is null. Hermetic: this path touches no network.
|
||||
var (tz, loc, ip) = await CloakLauncher.MaybeResolveGeoIpAsync(
|
||||
geoip: true, proxy: null, timezone: "Europe/Berlin", locale: "de-DE");
|
||||
Assert.Equal("Europe/Berlin", tz);
|
||||
Assert.Equal("de-DE", loc);
|
||||
Assert.Null(ip);
|
||||
}
|
||||
}
|
||||
|
||||
public class HumanRandomTests
|
||||
{
|
||||
[Fact]
|
||||
public void Rand_InRange()
|
||||
{
|
||||
for (int i = 0; i < 1000; i++)
|
||||
{
|
||||
double v = HumanRandom.Rand(5, 10);
|
||||
Assert.InRange(v, 5, 10);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RandInt_Inclusive()
|
||||
{
|
||||
var seen = new HashSet<int>();
|
||||
for (int i = 0; i < 1000; i++)
|
||||
seen.Add(HumanRandom.RandInt(1, 3));
|
||||
Assert.Equal(new HashSet<int> { 1, 2, 3 }, seen);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RandRange_FromRange()
|
||||
{
|
||||
var r = new Range(2, 4);
|
||||
for (int i = 0; i < 500; i++)
|
||||
Assert.InRange(HumanRandom.RandRange(r), 2, 4);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Choice_FromString()
|
||||
{
|
||||
var seen = new HashSet<char>();
|
||||
for (int i = 0; i < 500; i++)
|
||||
seen.Add(HumanRandom.Choice("abc"));
|
||||
Assert.Equal(new HashSet<char> { 'a', 'b', 'c' }, seen);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SleepMs_Zero_IsNoOp()
|
||||
{
|
||||
// Should not throw and should return quickly.
|
||||
HumanRandom.SleepMs(0);
|
||||
Assert.True(HumanRandom.SleepMsAsync(0).IsCompleted);
|
||||
}
|
||||
}
|
||||
|
||||
public class KeyboardTests
|
||||
{
|
||||
[Fact]
|
||||
public void ShiftSymbols_Contains_Expected()
|
||||
{
|
||||
foreach (char c in "@#!$%^&*()_+{}|:\"<>?~")
|
||||
Assert.Contains(c, HumanKeyboard.ShiftSymbols);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NearbyKeys_HasQwertyNeighbors()
|
||||
{
|
||||
Assert.Equal("sqwz", HumanKeyboard.NearbyKeys['a']);
|
||||
Assert.Equal("ol", HumanKeyboard.NearbyKeys['p']);
|
||||
}
|
||||
}
|
||||
|
||||
public class ActionabilityTests
|
||||
{
|
||||
[Fact]
|
||||
public void CheckSets_Match_Python()
|
||||
{
|
||||
Assert.Equal(new HashSet<string> { "attached", "visible", "enabled", "pointer_events" },
|
||||
new HashSet<string>(Actionability.ChecksClick));
|
||||
Assert.Equal(new HashSet<string> { "attached", "visible", "pointer_events" },
|
||||
new HashSet<string>(Actionability.ChecksHover));
|
||||
Assert.Equal(new HashSet<string> { "attached", "visible", "enabled", "editable", "pointer_events" },
|
||||
new HashSet<string>(Actionability.ChecksInput));
|
||||
Assert.Equal(new HashSet<string> { "attached", "visible", "enabled" },
|
||||
new HashSet<string>(Actionability.ChecksFocus));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ErrorHierarchy_AllSubclassActionabilityError()
|
||||
{
|
||||
Assert.IsAssignableFrom<ActionabilityError>(new ElementNotAttachedError("#x"));
|
||||
Assert.IsAssignableFrom<ActionabilityError>(new ElementNotVisibleError("#x"));
|
||||
Assert.IsAssignableFrom<ActionabilityError>(new ElementNotStableError("#x"));
|
||||
Assert.IsAssignableFrom<ActionabilityError>(new ElementNotEnabledError("#x"));
|
||||
Assert.IsAssignableFrom<ActionabilityError>(new ElementNotEditableError("#x"));
|
||||
Assert.IsAssignableFrom<ActionabilityError>(new ElementNotReceivingEventsError("#x", "div"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ElementNotReceivingEvents_Message_IncludesCoveringTag()
|
||||
{
|
||||
var e = new ElementNotReceivingEventsError("#x", "span");
|
||||
Assert.Contains("span", e.Message);
|
||||
Assert.Equal("pointer_events", e.Check);
|
||||
}
|
||||
}
|
||||
|
||||
public class MouseMathTests
|
||||
{
|
||||
[Fact]
|
||||
public void ClickTarget_Input_Within_Box()
|
||||
{
|
||||
var box = new BoundingBox(100, 200, 300, 40);
|
||||
var cfg = new HumanConfig();
|
||||
for (int i = 0; i < 200; i++)
|
||||
{
|
||||
var p = HumanMouse.ClickTarget(box, isInput: true, cfg);
|
||||
Assert.InRange(p.X, box.X, box.X + box.Width);
|
||||
Assert.InRange(p.Y, box.Y, box.Y + box.Height);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClickTarget_Button_Clusters_Center()
|
||||
{
|
||||
var box = new BoundingBox(0, 0, 100, 100);
|
||||
var cfg = new HumanConfig();
|
||||
for (int i = 0; i < 200; i++)
|
||||
{
|
||||
var p = HumanMouse.ClickTarget(box, isInput: false, cfg);
|
||||
Assert.InRange(p.X, 35, 65);
|
||||
Assert.InRange(p.Y, 35, 65);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
using CloakBrowser;
|
||||
using Xunit;
|
||||
|
||||
namespace CloakBrowser.Tests;
|
||||
|
||||
public class ProxyResolverTests
|
||||
{
|
||||
[Fact]
|
||||
public void Null_Proxy_Returns_Empty()
|
||||
{
|
||||
var r = ProxyResolver.Resolve(null);
|
||||
Assert.Null(r.PlaywrightProxy);
|
||||
Assert.Empty(r.ExtraArgs);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Socks5_String_With_Creds_Uses_ProxyServerArg()
|
||||
{
|
||||
var r = ProxyResolver.Resolve("socks5://user:pass@host:1080");
|
||||
Assert.Null(r.PlaywrightProxy);
|
||||
Assert.Single(r.ExtraArgs);
|
||||
Assert.StartsWith("--proxy-server=socks5://", r.ExtraArgs[0]);
|
||||
Assert.Contains("user:pass@host:1080", r.ExtraArgs[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Socks5_Dict_With_Creds_And_Bypass()
|
||||
{
|
||||
var r = ProxyResolver.Resolve(new ProxySettings
|
||||
{
|
||||
Server = "socks5://host:1080",
|
||||
Username = "u",
|
||||
Password = "p",
|
||||
Bypass = ".google.com",
|
||||
});
|
||||
Assert.Null(r.PlaywrightProxy);
|
||||
Assert.Contains(r.ExtraArgs, a => a.StartsWith("--proxy-server=socks5://u:p@host:1080"));
|
||||
Assert.Contains("--proxy-bypass-list=.google.com", r.ExtraArgs);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Socks5_Creds_With_Special_Chars_Are_Encoded()
|
||||
{
|
||||
// Password contains '=' and '@' which Chromium would truncate; expect encoding.
|
||||
var r = ProxyResolver.Resolve("socks5://user:p=ss@word@host:1080");
|
||||
Assert.Single(r.ExtraArgs);
|
||||
// '=' -> %3D, the literal '@' inside the password -> %40
|
||||
Assert.Contains("%3D", r.ExtraArgs[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Http_Without_Creds_Uses_PlaywrightProxy()
|
||||
{
|
||||
var r = ProxyResolver.Resolve("http://host:8080");
|
||||
Assert.NotNull(r.PlaywrightProxy);
|
||||
Assert.Equal("http://host:8080", r.PlaywrightProxy!.Server);
|
||||
Assert.Empty(r.ExtraArgs);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Http_Dict_Without_Creds_Uses_PlaywrightProxy()
|
||||
{
|
||||
var r = ProxyResolver.Resolve(new ProxySettings { Server = "http://host:8080" });
|
||||
Assert.NotNull(r.PlaywrightProxy);
|
||||
Assert.Equal("http://host:8080", r.PlaywrightProxy!.Server);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Http_With_Creds_Uses_InlineAuth_On_Binary_With_Patch()
|
||||
{
|
||||
// Pin a version at/above every platform floor → inline on any host.
|
||||
var r = ProxyResolver.Resolve("http://user:pass@host:8080", "148.0.7778.215.3");
|
||||
Assert.Null(r.PlaywrightProxy);
|
||||
Assert.Equal("--proxy-server=http://user:pass@host:8080", Assert.Single(r.ExtraArgs));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Http_With_Creds_Falls_Back_On_Binary_Without_Patch()
|
||||
{
|
||||
// Pin a version below every platform floor → Playwright proxy on any host.
|
||||
var r = ProxyResolver.Resolve("http://user:pass@host:8080", "146.0.7680.177.3");
|
||||
Assert.Empty(r.ExtraArgs);
|
||||
Assert.NotNull(r.PlaywrightProxy);
|
||||
Assert.Equal("http://host:8080", r.PlaywrightProxy!.Server);
|
||||
Assert.Equal("user", r.PlaywrightProxy.Username);
|
||||
Assert.Equal("pass", r.PlaywrightProxy.Password);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractProxyUrl_AddsScheme_For_Bare()
|
||||
{
|
||||
Assert.Equal("http://host:8080", ProxyResolver.ExtractProxyUrl("host:8080"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractProxyUrl_Socks_Dict_Reconstructs_With_Creds()
|
||||
{
|
||||
var url = ProxyResolver.ExtractProxyUrl(new ProxySettings
|
||||
{
|
||||
Server = "socks5://host:1080", Username = "u", Password = "p",
|
||||
});
|
||||
Assert.Equal("socks5://u:p@host:1080", url);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsSocksProxy_Detects_Variants()
|
||||
{
|
||||
Assert.True(ProxyResolver.IsSocksProxy("socks5://h:1"));
|
||||
Assert.True(ProxyResolver.IsSocksProxy("socks5h://h:1"));
|
||||
Assert.False(ProxyResolver.IsSocksProxy("http://h:1"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using CloakBrowser.Human;
|
||||
using Xunit;
|
||||
|
||||
namespace CloakBrowser.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the headed no-viewport scroll fallback (port of upstream 9c3ed2d /
|
||||
/// v0.4.1). Headed launches default to no_viewport so <c>page.ViewportSize</c> is
|
||||
/// null; human scroll must fall back to the live <c>window.innerWidth/innerHeight</c>
|
||||
/// instead of crashing with "Viewport size not available".
|
||||
/// </summary>
|
||||
public class ScrollFallbackTests
|
||||
{
|
||||
private sealed class FakeRawMouse : IRawMouse
|
||||
{
|
||||
public Task MoveAsync(double x, double y) => Task.CompletedTask;
|
||||
public Task DownAsync() => Task.CompletedTask;
|
||||
public Task UpAsync() => Task.CompletedTask;
|
||||
public Task WheelAsync(double dx, double dy) => Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>Scroll page whose ViewportSize is null (headed) but live window dims resolve.</summary>
|
||||
private sealed class NoViewportPage : IRawScrollPage
|
||||
{
|
||||
private readonly (int, int)? _live;
|
||||
public int LiveCalls { get; private set; }
|
||||
public NoViewportPage((int, int)? live) => _live = live;
|
||||
|
||||
public (int Width, int Height)? ViewportSize => null;
|
||||
|
||||
public Task<(int Width, int Height)?> GetLiveWindowSizeAsync()
|
||||
{
|
||||
LiveCalls++;
|
||||
return Task.FromResult(_live);
|
||||
}
|
||||
}
|
||||
|
||||
// Zero out the timing ranges so the scroll loop runs instantly in tests.
|
||||
private static HumanConfig FastConfig() => new()
|
||||
{
|
||||
IdleBetweenActions = false,
|
||||
ScrollPreMoveDelay = (0, 0),
|
||||
ScrollPauseFast = (0, 0),
|
||||
ScrollPauseSlow = (0, 0),
|
||||
ScrollSettleDelay = (0, 0),
|
||||
ScrollOvershootChance = 0,
|
||||
MouseMinSteps = 1,
|
||||
MouseMaxSteps = 2,
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public async Task Null_viewport_falls_back_to_live_window_dimensions()
|
||||
{
|
||||
var page = new NoViewportPage((1280, 800));
|
||||
var raw = new FakeRawMouse();
|
||||
// Element far below the fold so a scroll is required (forces use of viewport height).
|
||||
BoundingBox? boxBelowFold = new BoundingBox(100, 5000, 50, 20);
|
||||
Func<Task<BoundingBox?>> getBox = () => Task.FromResult(boxBelowFold);
|
||||
|
||||
var result = await HumanScroll.HumanScrollIntoViewAsync(
|
||||
page, raw, getBox, cursorX: 0, cursorY: 0, FastConfig());
|
||||
|
||||
Assert.Equal(1, page.LiveCalls); // the fallback was consulted
|
||||
Assert.True(result.DidScroll); // and it actually scrolled (no crash)
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Null_viewport_and_no_live_dims_throws()
|
||||
{
|
||||
var page = new NoViewportPage(null); // live fallback also unavailable
|
||||
var raw = new FakeRawMouse();
|
||||
Func<Task<BoundingBox?>> getBox = () => Task.FromResult<BoundingBox?>(new BoundingBox(0, 0, 10, 10));
|
||||
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
HumanScroll.HumanScrollIntoViewAsync(page, raw, getBox, 0, 0, FastConfig()));
|
||||
Assert.Equal("Viewport size not available", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Null_viewport_with_zero_height_live_dims_throws()
|
||||
{
|
||||
// A live read that returns a 0 height is treated as unusable (matches the
|
||||
// Python `not viewport.get("height")` guard).
|
||||
var page = new NoViewportPage((1280, 0));
|
||||
var raw = new FakeRawMouse();
|
||||
Func<Task<BoundingBox?>> getBox = () => Task.FromResult<BoundingBox?>(new BoundingBox(0, 0, 10, 10));
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
HumanScroll.HumanScrollIntoViewAsync(page, raw, getBox, 0, 0, FastConfig()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,490 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using CloakBrowser;
|
||||
using Org.BouncyCastle.Crypto.Generators;
|
||||
using Org.BouncyCastle.Crypto.Parameters;
|
||||
using Org.BouncyCastle.Crypto.Signers;
|
||||
using Org.BouncyCastle.Security;
|
||||
using Xunit;
|
||||
|
||||
namespace CloakBrowser.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Ed25519 binary-signature verification - port of Python <c>tests/test_update.py</c>
|
||||
/// (TestSignatureVerification, TestVerifyDownloadChecksumSigned, TestVersionBinding)
|
||||
/// and JS <c>js/tests/signature.test.ts</c>. Closes #308: a compromised download
|
||||
/// mirror can no longer certify a tampered binary.
|
||||
/// </summary>
|
||||
[Collection("env-serial")]
|
||||
public class SignatureTests
|
||||
{
|
||||
// -----------------------------------------------------------------------
|
||||
// Ed25519 key/signature helpers (mirror _make_key / _sign in Python).
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private static (Ed25519PrivateKeyParameters Priv, string PubB64) MakeKey()
|
||||
{
|
||||
var gen = new Ed25519KeyPairGenerator();
|
||||
gen.Init(new Ed25519KeyGenerationParameters(new SecureRandom()));
|
||||
var pair = gen.GenerateKeyPair();
|
||||
var priv = (Ed25519PrivateKeyParameters)pair.Private;
|
||||
var pub = (Ed25519PublicKeyParameters)pair.Public;
|
||||
return (priv, Convert.ToBase64String(pub.GetEncoded()));
|
||||
}
|
||||
|
||||
/// <summary>Return SHA256SUMS.sig content (base64 of the raw signature), as served.</summary>
|
||||
private static byte[] Sign(Ed25519PrivateKeyParameters priv, byte[] manifest)
|
||||
{
|
||||
var signer = new Ed25519Signer();
|
||||
signer.Init(true, priv);
|
||||
signer.BlockUpdate(manifest, 0, manifest.Length);
|
||||
var raw = signer.GenerateSignature();
|
||||
return Encoding.ASCII.GetBytes(Convert.ToBase64String(raw));
|
||||
}
|
||||
|
||||
private static byte[] Utf8(string s) => Encoding.UTF8.GetBytes(s);
|
||||
|
||||
private static string Sha256Hex(byte[] data)
|
||||
{
|
||||
using var sha = SHA256.Create();
|
||||
return Convert.ToHexString(sha.ComputeHash(data)).ToLowerInvariant();
|
||||
}
|
||||
|
||||
/// <summary>Run an action with overridden pinned keys / manifest, always restoring afterwards.</summary>
|
||||
private static void WithOverrides(
|
||||
string[]? pubkeys,
|
||||
Func<string?, (byte[], byte[])?>? manifest,
|
||||
Action body)
|
||||
{
|
||||
var prevKeys = Download.SigningPubkeysOverride;
|
||||
var prevManifest = Download.SignedManifestOverride;
|
||||
var prevCustomUrl = Environment.GetEnvironmentVariable("CLOAKBROWSER_DOWNLOAD_URL");
|
||||
try
|
||||
{
|
||||
Download.SigningPubkeysOverride = pubkeys;
|
||||
Download.SignedManifestOverride = manifest;
|
||||
// Force the official path (no custom mirror).
|
||||
Environment.SetEnvironmentVariable("CLOAKBROWSER_DOWNLOAD_URL", null);
|
||||
body();
|
||||
}
|
||||
finally
|
||||
{
|
||||
Download.SigningPubkeysOverride = prevKeys;
|
||||
Download.SignedManifestOverride = prevManifest;
|
||||
Environment.SetEnvironmentVariable("CLOAKBROWSER_DOWNLOAD_URL", prevCustomUrl);
|
||||
}
|
||||
}
|
||||
|
||||
private static string Tarball() => Config.GetArchiveName();
|
||||
|
||||
private static byte[] Manifest(string body, string? version = null)
|
||||
{
|
||||
var v = version ?? Config.GetChromiumVersion();
|
||||
return Utf8($"version={v}\n{body}");
|
||||
}
|
||||
|
||||
// =======================================================================
|
||||
// TestSignatureVerification - the cryptographic gate over manifest bytes.
|
||||
// =======================================================================
|
||||
|
||||
[Fact]
|
||||
public void ValidSignature_passes()
|
||||
{
|
||||
var (priv, pub) = MakeKey();
|
||||
var manifest = Utf8("abc cloakbrowser-linux-x64.tar.gz\n");
|
||||
var sig = Sign(priv, manifest);
|
||||
WithOverrides(new[] { pub }, null, () => Download.VerifySignature(manifest, sig));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TamperedManifest_fails()
|
||||
{
|
||||
var (priv, pub) = MakeKey();
|
||||
var manifest = Utf8("abc cloakbrowser-linux-x64.tar.gz\n");
|
||||
var sig = Sign(priv, manifest);
|
||||
var tampered = Utf8("xyz cloakbrowser-linux-x64.tar.gz\n");
|
||||
WithOverrides(new[] { pub }, null, () =>
|
||||
{
|
||||
var ex = Assert.Throws<InvalidOperationException>(() => Download.VerifySignature(tampered, sig));
|
||||
Assert.Contains("signature verification failed", ex.Message);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WrongKey_fails()
|
||||
{
|
||||
var (priv, _) = MakeKey();
|
||||
var (_, otherPub) = MakeKey();
|
||||
var manifest = Utf8("data\n");
|
||||
var sig = Sign(priv, manifest);
|
||||
WithOverrides(new[] { otherPub }, null, () =>
|
||||
{
|
||||
var ex = Assert.Throws<InvalidOperationException>(() => Download.VerifySignature(manifest, sig));
|
||||
Assert.Contains("signature verification failed", ex.Message);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MalformedSignature_fails()
|
||||
{
|
||||
var (_, pub) = MakeKey();
|
||||
WithOverrides(new[] { pub }, null, () =>
|
||||
{
|
||||
var ex = Assert.Throws<InvalidOperationException>(
|
||||
() => Download.VerifySignature(Utf8("data\n"), Encoding.ASCII.GetBytes("!!!not base64!!!")));
|
||||
Assert.Contains("Malformed", ex.Message);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PlaceholderKey_is_skipped_not_crashing()
|
||||
{
|
||||
// An unparseable pinned key (placeholder) must not abort - a real key still validates.
|
||||
var (priv, pub) = MakeKey();
|
||||
var manifest = Utf8("data\n");
|
||||
var sig = Sign(priv, manifest);
|
||||
WithOverrides(
|
||||
new[] { "REPLACE_WITH_REAL_ED25519_PUBLIC_KEY_BASE64", pub },
|
||||
null,
|
||||
() => Download.VerifySignature(manifest, sig));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void KeyRotation_second_key_accepts()
|
||||
{
|
||||
// A manifest signed with the new key validates while the old key stays pinned.
|
||||
var (_, oldPub) = MakeKey();
|
||||
var (newPriv, newPub) = MakeKey();
|
||||
var manifest = Utf8("rotated\n");
|
||||
var sig = Sign(newPriv, manifest);
|
||||
WithOverrides(new[] { oldPub, newPub }, null, () => Download.VerifySignature(manifest, sig));
|
||||
}
|
||||
|
||||
// =======================================================================
|
||||
// TestVerifyDownloadChecksumSigned - official path: sig + version + hash, fail-closed.
|
||||
// =======================================================================
|
||||
|
||||
private static string WriteTemp(byte[] bytes)
|
||||
{
|
||||
var path = Path.Combine(Path.GetTempPath(), $"cloak-sig-test-{Guid.NewGuid():N}");
|
||||
File.WriteAllBytes(path, bytes);
|
||||
return path;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidManifestAndHash_passes()
|
||||
{
|
||||
var (priv, pub) = MakeKey();
|
||||
var binary = Utf8("the real binary");
|
||||
var archive = WriteTemp(binary);
|
||||
var manifest = Manifest($"{Sha256Hex(binary)} {Tarball()}\n");
|
||||
var sig = Sign(priv, manifest);
|
||||
try
|
||||
{
|
||||
WithOverrides(new[] { pub }, _ => (manifest, sig), () =>
|
||||
Download.VerifyDownloadChecksum(archive));
|
||||
}
|
||||
finally { File.Delete(archive); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TamperedBinary_fails_hash()
|
||||
{
|
||||
var (priv, pub) = MakeKey();
|
||||
var archive = WriteTemp(Utf8("a malicious binary"));
|
||||
var manifest = Manifest($"{Sha256Hex(Utf8("the real binary"))} {Tarball()}\n");
|
||||
var sig = Sign(priv, manifest);
|
||||
try
|
||||
{
|
||||
WithOverrides(new[] { pub }, _ => (manifest, sig), () =>
|
||||
{
|
||||
var ex = Assert.Throws<InvalidOperationException>(() =>
|
||||
Download.VerifyDownloadChecksum(archive));
|
||||
Assert.Contains("Checksum verification failed", ex.Message);
|
||||
});
|
||||
}
|
||||
finally { File.Delete(archive); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WrongVersion_fails_downgrade()
|
||||
{
|
||||
var (priv, pub) = MakeKey();
|
||||
var binary = Utf8("the real binary");
|
||||
var archive = WriteTemp(binary);
|
||||
// Manifest declares an old version, but we ask for the current one.
|
||||
var manifest = Manifest($"{Sha256Hex(binary)} {Tarball()}\n", version: "1.0.0.0");
|
||||
var sig = Sign(priv, manifest);
|
||||
try
|
||||
{
|
||||
WithOverrides(new[] { pub }, _ => (manifest, sig), () =>
|
||||
{
|
||||
var ex = Assert.Throws<InvalidOperationException>(() =>
|
||||
Download.VerifyDownloadChecksum(archive));
|
||||
Assert.Contains("Version mismatch", ex.Message);
|
||||
});
|
||||
}
|
||||
finally { File.Delete(archive); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MissingVersionLine_fails()
|
||||
{
|
||||
var (priv, pub) = MakeKey();
|
||||
var binary = Utf8("the real binary");
|
||||
var archive = WriteTemp(binary);
|
||||
var manifest = Utf8($"{Sha256Hex(binary)} {Tarball()}\n"); // no version=
|
||||
var sig = Sign(priv, manifest);
|
||||
try
|
||||
{
|
||||
WithOverrides(new[] { pub }, _ => (manifest, sig), () =>
|
||||
{
|
||||
var ex = Assert.Throws<InvalidOperationException>(() =>
|
||||
Download.VerifyDownloadChecksum(archive));
|
||||
Assert.Contains("Version mismatch", ex.Message);
|
||||
});
|
||||
}
|
||||
finally { File.Delete(archive); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MissingSignedManifest_fails_closed()
|
||||
{
|
||||
var archive = WriteTemp(Utf8("x"));
|
||||
try
|
||||
{
|
||||
WithOverrides(null, _ => null, () =>
|
||||
{
|
||||
var ex = Assert.Throws<InvalidOperationException>(() =>
|
||||
Download.VerifyDownloadChecksum(archive));
|
||||
Assert.Contains("signed SHA256SUMS", ex.Message);
|
||||
});
|
||||
}
|
||||
finally { File.Delete(archive); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ManifestWithoutEntry_fails()
|
||||
{
|
||||
var (priv, pub) = MakeKey();
|
||||
var archive = WriteTemp(Utf8("x"));
|
||||
var manifest = Manifest($"{new string('d', 64)} some-other-file.tar.gz\n"); // no entry for our tarball
|
||||
var sig = Sign(priv, manifest);
|
||||
try
|
||||
{
|
||||
WithOverrides(new[] { pub }, _ => (manifest, sig), () =>
|
||||
{
|
||||
var ex = Assert.Throws<InvalidOperationException>(() =>
|
||||
Download.VerifyDownloadChecksum(archive));
|
||||
Assert.Contains("no entry for", ex.Message);
|
||||
});
|
||||
}
|
||||
finally { File.Delete(archive); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CustomUrl_uses_plain_checksum_and_skip()
|
||||
{
|
||||
// Self-hosted CLOAKBROWSER_DOWNLOAD_URL keeps the legacy skippable path,
|
||||
// and the signature path must NOT be consulted for a custom mirror.
|
||||
var archive = WriteTemp(Utf8("x"));
|
||||
var prevDl = Environment.GetEnvironmentVariable("CLOAKBROWSER_DOWNLOAD_URL");
|
||||
var prevSkip = Environment.GetEnvironmentVariable("CLOAKBROWSER_SKIP_CHECKSUM");
|
||||
var prevManifest = Download.SignedManifestOverride;
|
||||
bool manifestConsulted = false;
|
||||
try
|
||||
{
|
||||
Environment.SetEnvironmentVariable("CLOAKBROWSER_DOWNLOAD_URL", "https://my-mirror.test");
|
||||
Environment.SetEnvironmentVariable("CLOAKBROWSER_SKIP_CHECKSUM", "true");
|
||||
Download.SignedManifestOverride = _ => { manifestConsulted = true; return null; };
|
||||
|
||||
// Skip honored, no throw.
|
||||
Download.VerifyDownloadChecksum(archive);
|
||||
Assert.False(manifestConsulted, "signature path must not be consulted for a custom mirror");
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.SetEnvironmentVariable("CLOAKBROWSER_DOWNLOAD_URL", prevDl);
|
||||
Environment.SetEnvironmentVariable("CLOAKBROWSER_SKIP_CHECKSUM", prevSkip);
|
||||
Download.SignedManifestOverride = prevManifest;
|
||||
File.Delete(archive);
|
||||
}
|
||||
}
|
||||
|
||||
// =======================================================================
|
||||
// TestVersionBinding - the 'version=<v>' line.
|
||||
// =======================================================================
|
||||
|
||||
[Fact]
|
||||
public void ParseManifestVersion_reads_line()
|
||||
{
|
||||
var manifest = "version=146.0.7680.177.5\nabc cloakbrowser-linux-x64.tar.gz\n";
|
||||
Assert.Equal("146.0.7680.177.5", Download.ParseManifestVersion(manifest));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseManifestVersion_absent_returns_null()
|
||||
{
|
||||
Assert.Null(Download.ParseManifestVersion("abc cloakbrowser-linux-x64.tar.gz\n"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OldChecksumParser_ignores_version_line()
|
||||
{
|
||||
// Regression: the version line must not pollute the hash map, and a short
|
||||
// (non-64-hex) hash like "abc" must be rejected too.
|
||||
var h = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
|
||||
var manifest = $"version=146.0.7680.177.5\n{h} cloakbrowser-linux-x64.tar.gz\n";
|
||||
var result = Download.ParseChecksums(manifest);
|
||||
Assert.Single(result);
|
||||
Assert.Equal(h, result["cloakbrowser-linux-x64.tar.gz"]);
|
||||
}
|
||||
|
||||
// =======================================================================
|
||||
// Pro download verification - VerifyProDownloadAsync uses the SAME pinned
|
||||
// Ed25519 signature gate as the free path, but classifies failures:
|
||||
// tampering (bad sig / wrong version / bad hash) -> BinaryVerificationError
|
||||
// transient (manifest fetch failed) -> InvalidOperationException
|
||||
// Port of JS js/tests/signature.test.ts Pro cases + Python TestVerifyProDownload.
|
||||
// =======================================================================
|
||||
|
||||
private const string ProVersion = "148.0.7778.215.2";
|
||||
|
||||
private static byte[] ProManifest(string body, string? version = null) =>
|
||||
Utf8($"version={version ?? ProVersion}\n{body}");
|
||||
|
||||
private static void WithProOverrides(
|
||||
string[]? pubkeys, Func<string, (byte[], byte[])?>? manifest, Action body)
|
||||
{
|
||||
var prevKeys = Download.SigningPubkeysOverride;
|
||||
var prevManifest = Download.ProSignedManifestOverride;
|
||||
try
|
||||
{
|
||||
Download.SigningPubkeysOverride = pubkeys;
|
||||
Download.ProSignedManifestOverride = manifest;
|
||||
body();
|
||||
}
|
||||
finally
|
||||
{
|
||||
Download.SigningPubkeysOverride = prevKeys;
|
||||
Download.ProSignedManifestOverride = prevManifest;
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Pro_validManifestAndHash_passes()
|
||||
{
|
||||
var (priv, pub) = MakeKey();
|
||||
var binary = Utf8("the real pro binary");
|
||||
var archive = WriteTemp(binary);
|
||||
var manifest = ProManifest($"{Sha256Hex(binary)} {Tarball()}\n");
|
||||
var sig = Sign(priv, manifest);
|
||||
try
|
||||
{
|
||||
WithProOverrides(new[] { pub }, _ => (manifest, sig), () =>
|
||||
Download.VerifyProDownloadAsync(archive, ProVersion, default).GetAwaiter().GetResult());
|
||||
}
|
||||
finally { File.Delete(archive); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Pro_tamperedBinary_throws_verificationError()
|
||||
{
|
||||
var (priv, pub) = MakeKey();
|
||||
var archive = WriteTemp(Utf8("a malicious pro binary"));
|
||||
var manifest = ProManifest($"{Sha256Hex(Utf8("the real pro binary"))} {Tarball()}\n");
|
||||
var sig = Sign(priv, manifest);
|
||||
try
|
||||
{
|
||||
WithProOverrides(new[] { pub }, _ => (manifest, sig), () =>
|
||||
{
|
||||
var ex = Assert.Throws<BinaryVerificationError>(() =>
|
||||
Download.VerifyProDownloadAsync(archive, ProVersion, default).GetAwaiter().GetResult());
|
||||
Assert.Contains("Checksum verification failed", ex.Message);
|
||||
});
|
||||
}
|
||||
finally { File.Delete(archive); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Pro_badSignature_throws_verificationError()
|
||||
{
|
||||
var (priv, _) = MakeKey();
|
||||
var (_, otherPub) = MakeKey();
|
||||
var binary = Utf8("pro binary");
|
||||
var archive = WriteTemp(binary);
|
||||
var manifest = ProManifest($"{Sha256Hex(binary)} {Tarball()}\n");
|
||||
var sig = Sign(priv, manifest);
|
||||
try
|
||||
{
|
||||
WithProOverrides(new[] { otherPub }, _ => (manifest, sig), () =>
|
||||
{
|
||||
var ex = Assert.Throws<BinaryVerificationError>(() =>
|
||||
Download.VerifyProDownloadAsync(archive, ProVersion, default).GetAwaiter().GetResult());
|
||||
Assert.Contains("signature verification failed", ex.Message);
|
||||
});
|
||||
}
|
||||
finally { File.Delete(archive); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Pro_wrongVersion_throws_verificationError_downgrade()
|
||||
{
|
||||
var (priv, pub) = MakeKey();
|
||||
var binary = Utf8("pro binary");
|
||||
var archive = WriteTemp(binary);
|
||||
// Manifest declares an older version than the one requested.
|
||||
var manifest = ProManifest($"{Sha256Hex(binary)} {Tarball()}\n", version: "1.0.0.0");
|
||||
var sig = Sign(priv, manifest);
|
||||
try
|
||||
{
|
||||
WithProOverrides(new[] { pub }, _ => (manifest, sig), () =>
|
||||
{
|
||||
var ex = Assert.Throws<BinaryVerificationError>(() =>
|
||||
Download.VerifyProDownloadAsync(archive, ProVersion, default).GetAwaiter().GetResult());
|
||||
Assert.Contains("Version mismatch", ex.Message);
|
||||
});
|
||||
}
|
||||
finally { File.Delete(archive); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Pro_missingEntry_throws_verificationError()
|
||||
{
|
||||
var (priv, pub) = MakeKey();
|
||||
var archive = WriteTemp(Utf8("x"));
|
||||
var manifest = ProManifest($"{new string('d', 64)} some-other-file.tar.gz\n");
|
||||
var sig = Sign(priv, manifest);
|
||||
try
|
||||
{
|
||||
WithProOverrides(new[] { pub }, _ => (manifest, sig), () =>
|
||||
{
|
||||
var ex = Assert.Throws<BinaryVerificationError>(() =>
|
||||
Download.VerifyProDownloadAsync(archive, ProVersion, default).GetAwaiter().GetResult());
|
||||
Assert.Contains("no entry for", ex.Message);
|
||||
});
|
||||
}
|
||||
finally { File.Delete(archive); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Pro_manifestFetchFails_is_transient_not_tampering()
|
||||
{
|
||||
// A failed manifest fetch (null) is transient -> plain InvalidOperationException,
|
||||
// NOT a BinaryVerificationError, so the router can surface "unavailable, retry".
|
||||
var archive = WriteTemp(Utf8("x"));
|
||||
try
|
||||
{
|
||||
WithProOverrides(null, _ => null, () =>
|
||||
{
|
||||
var ex = Assert.Throws<InvalidOperationException>(() =>
|
||||
Download.VerifyProDownloadAsync(archive, ProVersion, default).GetAwaiter().GetResult());
|
||||
Assert.IsNotType<BinaryVerificationError>(ex);
|
||||
Assert.Contains("Could not fetch", ex.Message);
|
||||
});
|
||||
}
|
||||
finally { File.Delete(archive); }
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user