Add .NET 8 / C# client (dotnet/) (#385)

This commit is contained in:
lilos
2026-06-23 21:22:43 +03:00
committed by GitHub
parent 7e9388e981
commit 1497bf501d
67 changed files with 12734 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
# .NET build artifacts
bin/
obj/
*.user
.vs/
*.png
cloak-persist-test/
+59
View File
@@ -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
+637
View File
@@ -0,0 +1,637 @@
# 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
# add to your project
dotnet add package CloakBrowser
# the patched Chromium binary downloads automatically on first launch,
# or pre-install it:
dotnet run --project src/CloakBrowser.Cli -- install
```
The binary is 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.
---
## 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`) |
### CloakBrowser Pro
Pass a `LicenseKey` (or set `CLOAKBROWSER_LICENSE_KEY`, or write the key to
`~/.cloakbrowser/license.key`) to download the latest **Pro** binary from
cloakbrowser.dev. Without a key, the free binary downloads 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. `Download.BinaryInfo()`
exposes a `Tier` field (`"pro"` / `"free"`) and `License.ValidateLicense` /
`License.LicenseInfo` mirror the Python `validate_license` / `LicenseInfo` exports.
```csharp
await using var browser = await CloakLauncher.LaunchAsync(new LaunchOptions
{
LicenseKey = "ck_live_...", // or via CLOAKBROWSER_LICENSE_KEY / ~/.cloakbrowser/license.key
});
```
---
## `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
```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_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>
+108
View File
@@ -0,0 +1,108 @@
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 # Show binary version, path, platform
// cloakbrowser update # Check for and download newer binary
// cloakbrowser clear-cache # Remove cached binaries
// Route CloakBrowser logs to stderr at Info level (clean output).
CloakLog.MinLevel = CloakLogLevel.Info;
string? command = args.Length > 0 ? args[0] : null;
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":
CmdInfo();
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()
{
var info = Download.BinaryInfo();
string? over = Config.GetLocalBinaryOverride();
Console.WriteLine($"Version: {info.Version}");
Console.WriteLine($"Platform: {info.Platform}");
Console.WriteLine($"Binary: {info.BinaryPath}");
Console.WriteLine($"Installed: {info.Installed}");
Console.WriteLine($"Cache: {info.CacheDir}");
if (!string.IsNullOrEmpty(over))
Console.WriteLine($"Override: {over} (CLOAKBROWSER_BINARY_PATH)");
}
static async Task CmdUpdate()
{
CloakLog.Info("Checking for updates...");
string? newVersion = await Download.CheckForUpdateAsync().ConfigureAwait(false);
Console.WriteLine(newVersion != null
? $"Updated to Chromium {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 Show binary version, path, and platform");
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,43 @@
<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.2</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>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);CS1591</NoWarn>
</PropertyGroup>
<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" />
</ItemGroup>
</Project>
+380
View File
@@ -0,0 +1,380 @@
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).ConfigureAwait(false);
var (timezone, locale, exitIp) = await MaybeResolveGeoIpAsync(
options.GeoIp, options.Proxy, options.Timezone, options.Locale).ConfigureAwait(false);
var proxyResolution = ProxyResolver.Resolve(options.Proxy);
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);
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,
}).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).
return new CloakBrowserHandle(playwright, browser, options.Humanize, humanCfg, options.Headless);
}
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,
// geoip already resolved above; don't re-resolve.
GeoIp = false,
}).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).ConfigureAwait(false);
var (timezone, locale, exitIp) = await MaybeResolveGeoIpAsync(
options.GeoIp, options.Proxy, options.Timezone, options.Locale).ConfigureAwait(false);
var proxyResolution = ProxyResolver.Resolve(options.Proxy);
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);
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,
};
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 proxy IP when geoip is enabled. Returns
/// (timezone, locale, exitIp). The exit IP is a free bonus used for WebRTC spoofing.
/// </summary>
public static async Task<(string? Timezone, string? Locale, string? ExitIp)> MaybeResolveGeoIpAsync(
bool geoip, object? proxy, string? timezone, string? locale)
{
if (!geoip || proxy == null)
return (timezone, locale, null);
string? proxyUrl = ProxyResolver.ExtractProxyUrl(proxy);
if (string.IsNullOrEmpty(proxyUrl))
return (timezone, locale, null);
// When both tz/locale are explicit, still resolve the exit IP for WebRTC.
if (timezone != null && locale != null)
{
string? exitIpOnly = await GeoIp.ResolveProxyExitIpAsync(proxyUrl).ConfigureAwait(false);
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 &lt; user args &lt; 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)
{
// 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}");
}
return order.Select(k => seen[k]).ToList();
}
// -----------------------------------------------------------------------
// 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 &lt; 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 gets the fixed default.
return options.Headless
? 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;
}
+47
View File
@@ -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}");
}
}
+8
View File
@@ -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.2";
}
+345
View File
@@ -0,0 +1,345 @@
using System.Runtime.InteropServices;
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;
}
/// <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 files.
/// </summary>
public static string GetEffectiveVersion(bool pro = false)
{
var baseVersion = GetChromiumVersion();
var cache = GetCacheDir();
if (pro)
{
// Pro marker is authoritative for the Pro tier - no VersionNewer guard
// (Pro versions are independent of the bundled free version, e.g. 148 vs 146).
var proMarker = Path.Combine(cache, $"latest_pro_version_{GetPlatformTag()}");
if (File.Exists(proMarker))
{
try
{
var version = File.ReadAllText(proMarker).Trim();
if (!string.IsNullOrEmpty(version) && File.Exists(GetBinaryPath(version, pro: true)))
return version;
}
catch (Exception ex) when (ex is FormatException or IOException) { }
}
return baseVersion;
}
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>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");
}
File diff suppressed because it is too large Load Diff
+369
View File
@@ -0,0 +1,369 @@
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",
};
/// <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.
/// </summary>
public static async Task<(string? Timezone, string? Locale, string? ExitIp)> ResolveProxyGeoWithIpAsync(
string proxyUrl, CancellationToken ct = default)
{
var dbPath = await EnsureGeoIpDbAsync(ct).ConfigureAwait(false);
if (dbPath == null)
return (null, null, null);
var timeout = GetGeoIpTimeoutSeconds();
var deadline = DeadlineFromTimeout(timeout);
// Exit IP (through proxy) is most accurate - gateway DNS may differ from exit.
var ip = await ResolveExitIpAsync(proxyUrl, RemainingSeconds(deadline), ct).ConfigureAwait(false);
if (ip == null && !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);
}
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 only the proxy exit IP, bounded by the GeoIP timeout.</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);
HttpClient client;
try
{
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("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"); }
});
}
}
+178
View File
@@ -0,0 +1,178 @@
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;
/// <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)
{
_playwright = playwright;
_rawBrowser = browser;
_humanize = humanize;
_humanCfg = humanCfg;
_headless = headless;
// 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)
: 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);
private BrowserNewContextOptions ApplyDefaultNoViewport(BrowserNewContextOptions? options) =>
ViewportDefaults.ApplyHeadedNoViewport(options, _headless);
/// <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);
}
}
}
+180
View File
@@ -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);
+667
View File
@@ -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 &lt;= 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 &lt;= 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; }
}
}
+69
View File
@@ -0,0 +1,69 @@
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>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; }
}
+272
View File
@@ -0,0 +1,272 @@
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>
/// 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;
private static readonly 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>Resolve the license key: explicit param &gt; env var &gt; file &gt; null.</summary>
public static string? ResolveLicenseKey(string? licenseKey = null)
{
var trimmed = licenseKey?.Trim();
if (!string.IsNullOrEmpty(trimmed))
return trimmed;
var envKey = (Environment.GetEnvironmentVariable("CLOAKBROWSER_LICENSE_KEY") ?? "").Trim();
if (!string.IsNullOrEmpty(envKey))
return envKey;
try
{
var keyFile = Path.Combine(Config.GetCacheDir(), "license.key");
var content = File.ReadAllText(keyFile).Trim();
if (!string.IsNullOrEmpty(content))
return content;
}
catch (IOException) { /* file missing/unreadable */ }
catch (UnauthorizedAccessException) { }
return null;
}
/// <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");
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 resp = Http.GetAsync(ProVersionUrl).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();
}
}
+370
View File
@@ -0,0 +1,370 @@
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
{
private const string HttpProxyInlineAuthMinVersion = "146.0.7680.177.5";
private static readonly IReadOnlySet<string> HttpProxyInlineAuthPlatforms =
new HashSet<string> { "linux-x64", "windows-x64" };
/// <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;
}
private static bool SupportsHttpProxyInlineAuth()
{
string tag = Config.GetPlatformTag();
if (!HttpProxyInlineAuthPlatforms.Contains(tag))
return false;
return Compare(Config.GetChromiumVersion(), HttpProxyInlineAuthMinVersion) >= 0;
}
private static int Compare(string a, string b)
{
var ta = Config.VersionTuple(a);
var tb = Config.VersionTuple(b);
int n = 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) return va.CompareTo(vb);
}
return 0;
}
/// <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)
{
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 on supported platforms: inline creds via --proxy-server.
bool hasCreds = proxy switch
{
ProxySettings ps => HasCredentials(ps),
string s => HasCredentials(s),
_ => false,
};
if (hasCreds && SupportsHttpProxyInlineAuth())
{
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>());
}
}
+20
View File
@@ -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,34 @@
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 &lt; 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)
{
if (headless) return options ?? new BrowserNewPageOptions();
var o = options ?? new BrowserNewPageOptions();
o.ViewportSize ??= ViewportSize.NoViewport;
return o;
}
public static BrowserNewContextOptions ApplyHeadedNoViewport(BrowserNewContextOptions? options, bool headless)
{
if (headless) return options ?? new BrowserNewContextOptions();
var o = options ?? new BrowserNewContextOptions();
o.ViewportSize ??= ViewportSize.NoViewport;
return o;
}
}
+124
View File
@@ -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>&lt;dir of the chrome binary&gt;/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,114 @@
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)
{
if (browser is HumanizedBrowser) return browser;
return new HumanizedBrowser(browser, config ?? new HumanConfig(), headless);
}
// -----------------------------------------------------------------------
// 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,45 @@
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;
internal HumanizedBrowser(IBrowser inner, HumanConfig cfg, bool headless = true)
{
_inner = inner;
_cfg = cfg;
_headless = headless;
}
/// <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)).ConfigureAwait(false),
_cfg).ConfigureAwait(false);
public async Task<IBrowserContext> NewContextAsync(BrowserNewContextOptions? options = null) =>
Humanize.Context(
await _inner.NewContextAsync(ViewportDefaults.ApplyHeadedNoViewport(options, _headless)).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 &lt;select&gt; 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 &lt;select&gt; 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,76 @@
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="));
}
}
@@ -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,79 @@
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);
}
}
@@ -0,0 +1,206 @@
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 <see cref="DownloadHttpError"/>, the typed HTTP-status carrier the
/// v0.4.2 macOS Pro fallback relies on to distinguish a 404 (no Pro binary built
/// for this platform yet) from transient download failures.
/// </summary>
public class DownloadHttpErrorTests
{
[Fact]
public void Carries_status_code_and_includes_it_in_message()
{
var ex = new DownloadHttpError(System.Net.HttpStatusCode.NotFound, "Not Found");
Assert.Equal(System.Net.HttpStatusCode.NotFound, ex.Status);
Assert.Contains("404", ex.Message);
Assert.Contains("Not Found", ex.Message);
}
[Fact]
public void Distinguishes_404_from_transient_5xx()
{
var notFound = new DownloadHttpError(System.Net.HttpStatusCode.NotFound, "Not Found");
var transient = new DownloadHttpError(System.Net.HttpStatusCode.ServiceUnavailable, "Unavailable");
// The macOS Pro fallback only triggers on 404; a 5xx stays a hard failure.
Assert.Equal(System.Net.HttpStatusCode.NotFound, notFound.Status);
Assert.NotEqual(System.Net.HttpStatusCode.NotFound, transient.Status);
}
}
/// <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,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,246 @@
using System;
using System.IO;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
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");
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());
}
// =======================================================================
// 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_falls_back()
{
var marker = Path.Combine(_tmp, $"latest_pro_version_{Config.GetPlatformTag()}");
File.WriteAllText(marker, "148.0.7778.215.2");
// Marker present but no Pro binary on disk -> falls back to bundled version.
Assert.Equal(Config.GetChromiumVersion(), Config.GetEffectiveVersion(pro: true));
}
}
@@ -0,0 +1,156 @@
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"));
}
}
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,109 @@
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_Parses_Into_PlaywrightFields_When_InlineAuth_Unsupported()
{
// On platforms NOT in the inline-auth set (e.g. linux-arm64, darwin-*),
// HTTP creds go to the Playwright proxy dict. On linux-x64/windows-x64 with
// a recent binary they go to --proxy-server. Accept either valid outcome.
var r = ProxyResolver.Resolve("http://user:pass@host:8080");
bool inlineArg = r.ExtraArgs.Any(a => a.StartsWith("--proxy-server="));
bool pwProxy = r.PlaywrightProxy != null;
Assert.True(inlineArg ^ pwProxy, "Exactly one of inline-arg or playwright-proxy should be set");
if (pwProxy)
{
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); }
}
}
@@ -0,0 +1,144 @@
using CloakBrowser;
using Microsoft.Playwright;
using Xunit;
namespace CloakBrowser.Tests;
/// <summary>
/// Window-geometry / no_viewport behavior - port of upstream 50bf14b tests
/// (Python tests/test_launch_context.py + test_persistent_context.py, JS
/// js/tests/launch.test.ts + puppeteer.test.ts).
///
/// Headed launches must NOT layer an emulated viewport on top of the real
/// browser window: CDP viewport emulation forces outerWidth &lt; innerWidth, a
/// physically impossible window (= bot tell). Headless keeps a fixed, coherent
/// (outer == inner) DEFAULT_VIEWPORT for deterministic dimensions.
/// </summary>
public class ViewportTests
{
// ViewportSize uses reference equality, so compare by the sentinel value that
// Playwright's ViewportSize.NoViewport carries ({ Width = -1, Height = -1 }).
private static bool IsNoViewport(ViewportSize? vp) =>
vp != null && vp.Width == -1 && vp.Height == -1;
// -----------------------------------------------------------------------
// ResolveContextViewport - the headless-aware viewport selector used by
// launch_context / launch_persistent_context.
// -----------------------------------------------------------------------
[Fact]
public void Headless_unset_viewport_uses_default()
{
var vp = CloakLauncher.ResolveContextViewport(new LaunchContextOptions { Headless = true });
Assert.NotNull(vp);
Assert.False(IsNoViewport(vp));
Assert.Equal(Config.DefaultViewportWidth, vp!.Width);
Assert.Equal(Config.DefaultViewportHeight, vp.Height);
}
[Fact]
public void Headed_unset_viewport_uses_no_viewport()
{
// The core fix: headed + no explicit viewport => track the real window.
var vp = CloakLauncher.ResolveContextViewport(new LaunchContextOptions { Headless = false });
Assert.True(IsNoViewport(vp));
}
[Fact]
public void Explicit_viewport_honored_when_headed()
{
var vp = CloakLauncher.ResolveContextViewport(new LaunchContextOptions
{
Headless = false,
Viewport = (1280, 720),
});
Assert.NotNull(vp);
Assert.Equal(1280, vp!.Width);
Assert.Equal(720, vp.Height);
}
[Fact]
public void Explicit_viewport_honored_when_headless()
{
var vp = CloakLauncher.ResolveContextViewport(new LaunchContextOptions
{
Headless = true,
Viewport = (800, 600),
});
Assert.NotNull(vp);
Assert.Equal(800, vp!.Width);
Assert.Equal(600, vp.Height);
}
[Fact]
public void Explicit_no_viewport_honored_when_headless()
{
// NoViewport always wins, even headless (caller opted out of emulation).
var vp = CloakLauncher.ResolveContextViewport(new LaunchContextOptions
{
Headless = true,
NoViewport = true,
});
Assert.True(IsNoViewport(vp));
}
[Fact]
public void NoViewport_takes_precedence_over_viewport()
{
// Explicit NoViewport beats an explicit viewport (matches Python's
// _drop_conflicting_viewport: no_viewport wins).
var vp = CloakLauncher.ResolveContextViewport(new LaunchContextOptions
{
Headless = true,
NoViewport = true,
Viewport = (1920, 1080),
});
Assert.True(IsNoViewport(vp));
}
// -----------------------------------------------------------------------
// ViewportDefaults.ApplyHeadedNoViewport - the shared headed-default applied
// on EVERY page/context creation path (the raw handle AND the humanize
// wrapper), mirroring Python's _default_no_viewport which patches the browser
// so the default holds regardless of which path creates the page.
// -----------------------------------------------------------------------
[Fact]
public void HeadedDefault_page_unset_becomes_no_viewport()
{
var o = ViewportDefaults.ApplyHeadedNoViewport((BrowserNewPageOptions?)null, headless: false);
Assert.True(IsNoViewport(o.ViewportSize));
}
[Fact]
public void HeadedDefault_context_unset_becomes_no_viewport()
{
var o = ViewportDefaults.ApplyHeadedNoViewport((BrowserNewContextOptions?)null, headless: false);
Assert.True(IsNoViewport(o.ViewportSize));
}
[Fact]
public void HeadlessDefault_page_left_untouched()
{
// Headless: don't impose no_viewport - Playwright's default stays (coherent there).
var o = ViewportDefaults.ApplyHeadedNoViewport((BrowserNewPageOptions?)null, headless: true);
Assert.Null(o.ViewportSize);
}
[Fact]
public void HeadedDefault_explicit_viewport_honored()
{
var input = new BrowserNewPageOptions { ViewportSize = new ViewportSize { Width = 1024, Height = 768 } };
var o = ViewportDefaults.ApplyHeadedNoViewport(input, headless: false);
Assert.Equal(1024, o.ViewportSize!.Width);
Assert.Equal(768, o.ViewportSize.Height);
}
[Fact]
public void HeadedDefault_explicit_no_viewport_honored()
{
var input = new BrowserNewContextOptions { ViewportSize = ViewportSize.NoViewport };
var o = ViewportDefaults.ApplyHeadedNoViewport(input, headless: false);
Assert.True(IsNoViewport(o.ViewportSize));
}
}
@@ -0,0 +1,226 @@
using System;
using System.IO;
using System.Runtime.InteropServices;
using System.Text;
using CloakBrowser;
using Xunit;
namespace CloakBrowser.Tests;
/// <summary>
/// Port-parity tests for <see cref="Widevine"/> (mirrors Python tests/test_widevine.py
/// and js/tests/widevine.test.ts). Seeding is Linux-only, so the write-path
/// assertions are gated on Linux; the platform-independent behaviour (no-op
/// gates, CDM resolution, kill switch) is exercised everywhere.
///
/// Mutates CLOAKBROWSER_WIDEVINE* env vars, so it joins the env-serial
/// collection to avoid racing other env-mutating suites.
/// </summary>
[Collection("env-serial")]
public sealed class WidevineTests : IDisposable
{
private const string HintFilename = "latest-component-updated-widevine-cdm";
private readonly string? _prevWidevine;
private readonly string? _prevCdm;
private readonly string _tmp;
public WidevineTests()
{
_prevWidevine = Environment.GetEnvironmentVariable("CLOAKBROWSER_WIDEVINE");
_prevCdm = Environment.GetEnvironmentVariable("CLOAKBROWSER_WIDEVINE_CDM");
Environment.SetEnvironmentVariable("CLOAKBROWSER_WIDEVINE", null);
Environment.SetEnvironmentVariable("CLOAKBROWSER_WIDEVINE_CDM", null);
_tmp = Path.Combine(Path.GetTempPath(), "cb-widevine-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(_tmp);
}
public void Dispose()
{
Environment.SetEnvironmentVariable("CLOAKBROWSER_WIDEVINE", _prevWidevine);
Environment.SetEnvironmentVariable("CLOAKBROWSER_WIDEVINE_CDM", _prevCdm);
try { if (Directory.Exists(_tmp)) Directory.Delete(_tmp, recursive: true); } catch (IOException) { }
}
private static bool OnLinux => RuntimeInformation.IsOSPlatform(OSPlatform.Linux);
/// <summary>Create a fake chrome binary with a sideloaded WidevineCdm/manifest.json next to it.</summary>
private string MakeBinaryWithCdm()
{
var binDir = Path.Combine(_tmp, "bin");
Directory.CreateDirectory(binDir);
var binaryPath = Path.Combine(binDir, "chrome");
File.WriteAllText(binaryPath, "#!/bin/sh\n");
var cdmDir = Path.Combine(binDir, "WidevineCdm");
Directory.CreateDirectory(cdmDir);
File.WriteAllText(Path.Combine(cdmDir, "manifest.json"), "{\"version\":\"1.0\"}");
return binaryPath;
}
// ---- ResolveWidevineCdmDir ------------------------------------------------
[Fact]
public void Resolve_FindsCdmNextToBinary()
{
var binaryPath = MakeBinaryWithCdm();
var resolved = Widevine.ResolveWidevineCdmDir(binaryPath);
Assert.NotNull(resolved);
Assert.Equal(Path.GetFullPath(Path.Combine(Path.GetDirectoryName(binaryPath)!, "WidevineCdm")),
resolved);
}
[Fact]
public void Resolve_NoManifest_ReturnsNull()
{
var binDir = Path.Combine(_tmp, "nomanifest");
Directory.CreateDirectory(binDir);
var binaryPath = Path.Combine(binDir, "chrome");
File.WriteAllText(binaryPath, "x");
Directory.CreateDirectory(Path.Combine(binDir, "WidevineCdm")); // dir but no manifest.json
Assert.Null(Widevine.ResolveWidevineCdmDir(binaryPath));
}
[Fact]
public void Resolve_EnvVar_OverridesAutoDetect()
{
// Auto-detect would find the binary-adjacent CDM, but the env var wins exclusively.
var binaryPath = MakeBinaryWithCdm();
var customDir = Path.Combine(_tmp, "custom");
Directory.CreateDirectory(customDir);
File.WriteAllText(Path.Combine(customDir, "manifest.json"), "{}");
Environment.SetEnvironmentVariable("CLOAKBROWSER_WIDEVINE_CDM", customDir);
var resolved = Widevine.ResolveWidevineCdmDir(binaryPath);
Assert.Equal(Path.GetFullPath(customDir), resolved);
}
[Fact]
public void Resolve_EnvVar_InvalidPath_SkipsSeeding()
{
// A present-but-bogus env var is used exclusively => null (does NOT fall back).
var binaryPath = MakeBinaryWithCdm();
Environment.SetEnvironmentVariable("CLOAKBROWSER_WIDEVINE_CDM",
Path.Combine(_tmp, "does-not-exist"));
Assert.Null(Widevine.ResolveWidevineCdmDir(binaryPath));
}
[Fact]
public void Resolve_EnvVar_Whitespace_SkipsSeeding()
{
// A present-but-blank value must be treated as an unusable path and skip
// seeding (rather than probing the CWD via Path.Combine). Note: .NET's
// SetEnvironmentVariable("") *unsets* the variable, so a truly-empty value
// is unreachable through the managed API and only happens via the shell
// (export X=); whitespace is the testable proxy for that "set-but-blank"
// case and exercises the same guard in ResolveWidevineCdmDir.
var binaryPath = MakeBinaryWithCdm();
Environment.SetEnvironmentVariable("CLOAKBROWSER_WIDEVINE_CDM", " ");
Assert.Null(Widevine.ResolveWidevineCdmDir(binaryPath));
}
// ---- SeedWidevineHint (write path, Linux only) ---------------------------
[Fact]
public void Seed_WritesHintFile_WithCompactJson()
{
if (!OnLinux) return;
var binaryPath = MakeBinaryWithCdm();
var profile = Path.Combine(_tmp, "profile");
Directory.CreateDirectory(profile);
Widevine.SeedWidevineHint(profile, binaryPath);
var hintFile = Path.Combine(profile, "WidevineCdm", HintFilename);
Assert.True(File.Exists(hintFile));
var cdmDir = Path.GetFullPath(Path.Combine(Path.GetDirectoryName(binaryPath)!, "WidevineCdm"));
// Byte-match the JS wrapper's JSON.stringify({ Path }) output: compact, no BOM.
var expected = "{\"Path\":\"" + cdmDir + "\"}";
var actual = File.ReadAllText(hintFile);
Assert.Equal(expected, actual);
// No UTF-8 BOM (the JS/Python wrappers write raw UTF-8).
var bytes = File.ReadAllBytes(hintFile);
Assert.False(bytes.Length >= 3 && bytes[0] == 0xEF && bytes[1] == 0xBB && bytes[2] == 0xBF);
}
[Fact]
public void Seed_Idempotent_DoesNotRewriteIdenticalHint()
{
if (!OnLinux) return;
var binaryPath = MakeBinaryWithCdm();
var profile = Path.Combine(_tmp, "profile");
Directory.CreateDirectory(profile);
Widevine.SeedWidevineHint(profile, binaryPath);
var hintFile = Path.Combine(profile, "WidevineCdm", HintFilename);
var firstWrite = File.GetLastWriteTimeUtc(hintFile);
System.Threading.Thread.Sleep(20);
Widevine.SeedWidevineHint(profile, binaryPath); // second seed, identical content
var secondWrite = File.GetLastWriteTimeUtc(hintFile);
Assert.Equal(firstWrite, secondWrite); // untouched
}
[Fact]
public void Seed_NoCdm_NoHintWritten()
{
if (!OnLinux) return;
var binDir = Path.Combine(_tmp, "bare");
Directory.CreateDirectory(binDir);
var binaryPath = Path.Combine(binDir, "chrome");
File.WriteAllText(binaryPath, "x");
var profile = Path.Combine(_tmp, "profile2");
Directory.CreateDirectory(profile);
Widevine.SeedWidevineHint(profile, binaryPath);
Assert.False(File.Exists(Path.Combine(profile, "WidevineCdm", HintFilename)));
}
[Fact]
public void Seed_KillSwitch_NoHintWritten()
{
if (!OnLinux) return;
Environment.SetEnvironmentVariable("CLOAKBROWSER_WIDEVINE", "0");
var binaryPath = MakeBinaryWithCdm();
var profile = Path.Combine(_tmp, "profile3");
Directory.CreateDirectory(profile);
Widevine.SeedWidevineHint(profile, binaryPath);
Assert.False(File.Exists(Path.Combine(profile, "WidevineCdm", HintFilename)));
}
[Theory]
[InlineData("0")]
[InlineData("false")]
[InlineData("off")]
[InlineData("no")]
[InlineData("FALSE")]
[InlineData(" Off ")]
public void Seed_KillSwitch_AcceptsFalseyVariants(string value)
{
if (!OnLinux) return;
Environment.SetEnvironmentVariable("CLOAKBROWSER_WIDEVINE", value);
var binaryPath = MakeBinaryWithCdm();
var profile = Path.Combine(_tmp, "profile-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(profile);
Widevine.SeedWidevineHint(profile, binaryPath);
Assert.False(File.Exists(Path.Combine(profile, "WidevineCdm", HintFilename)));
}
[Fact]
public void Seed_EmptyUserDataDir_NoOp()
{
// No exception, no write — ephemeral profile path.
var binaryPath = MakeBinaryWithCdm();
Widevine.SeedWidevineHint("", binaryPath);
Widevine.SeedWidevineHint(null, binaryPath);
// Nothing to assert beyond "did not throw"; the binary-adjacent CDM must
// not have been touched.
Assert.True(File.Exists(binaryPath));
}
}
@@ -0,0 +1,173 @@
using CloakBrowser.Human;
using CloakBrowser.Wrappers;
using Microsoft.Playwright;
using Xunit;
namespace CloakBrowser.Tests.Wrappers;
/// <summary>
/// Tests for <see cref="HumanizedBrowser"/> / <see cref="HumanizedBrowserContext"/>:
/// the wrapping chain must be complete - a wrapped browser produces wrapped contexts
/// and pages, and a wrapped context produces wrapped pages, so there are no raw leaks.
/// </summary>
public class BrowserContextWrapperTests
{
private static IPage MakeFakePage()
{
var (mouse, _) = Fake.Of<IMouse>();
var (keyboard, _) = Fake.Of<IKeyboard>();
var (page, pageRec) = Fake.Of<IPage>();
pageRec.On("Mouse", mouse);
pageRec.On("Keyboard", keyboard);
pageRec.On("ViewportSize", new PageViewportSizeResult { Width = 800, Height = 600 });
return page;
}
// -----------------------------------------------------------------------
// Context
// -----------------------------------------------------------------------
[Fact]
public async Task Context_NewPageAsync_returns_wrapped_page()
{
var (ctx, ctxRec) = Fake.Of<IBrowserContext>();
ctxRec.On("NewPageAsync", Task.FromResult(MakeFakePage()));
var human = Humanize.Context(ctx, new HumanConfig());
var page = await human.NewPageAsync();
Assert.IsType<HumanizedPage>(page);
}
[Fact]
public void Context_Pages_returns_wrapped_pages()
{
var (ctx, ctxRec) = Fake.Of<IBrowserContext>();
ctxRec.On("Pages", new List<IPage> { MakeFakePage(), MakeFakePage() });
var human = Humanize.Context(ctx, new HumanConfig());
Assert.Equal(2, human.Pages.Count);
Assert.All(human.Pages, p => Assert.IsType<HumanizedPage>(p));
}
[Fact]
public async Task Context_delegated_member_forwards_to_inner()
{
var (ctx, ctxRec) = Fake.Of<IBrowserContext>();
ctxRec.On("CookiesAsync", Task.FromResult<IReadOnlyList<BrowserContextCookiesResult>>(
new List<BrowserContextCookiesResult>()));
var human = Humanize.Context(ctx, new HumanConfig());
await human.CookiesAsync();
Assert.True(ctxRec.WasCalled("CookiesAsync"));
}
[Fact]
public void Context_Original_exposes_inner()
{
var (ctx, _) = Fake.Of<IBrowserContext>();
var human = (HumanizedBrowserContext)Humanize.Context(ctx, new HumanConfig());
Assert.Same(ctx, human.Original);
Assert.Same(ctx, human.Inner);
}
[Fact]
public void Context_wrapping_is_idempotent()
{
var (ctx, _) = Fake.Of<IBrowserContext>();
var once = Humanize.Context(ctx, new HumanConfig());
var twice = Humanize.Context(once, new HumanConfig());
Assert.Same(once, twice);
}
// -----------------------------------------------------------------------
// Browser
// -----------------------------------------------------------------------
[Fact]
public async Task Browser_NewPageAsync_returns_wrapped_page()
{
var (browser, browserRec) = Fake.Of<IBrowser>();
browserRec.On("NewPageAsync", Task.FromResult(MakeFakePage()));
var human = Humanize.Browser(browser, new HumanConfig());
var page = await human.NewPageAsync();
Assert.IsType<HumanizedPage>(page);
}
[Fact]
public async Task Browser_NewContextAsync_returns_wrapped_context()
{
var (ctx, _) = Fake.Of<IBrowserContext>();
var (browser, browserRec) = Fake.Of<IBrowser>();
browserRec.On("NewContextAsync", Task.FromResult(ctx));
var human = Humanize.Browser(browser, new HumanConfig());
var context = await human.NewContextAsync();
Assert.IsType<HumanizedBrowserContext>(context);
}
[Fact]
public void Browser_Contexts_returns_wrapped_contexts()
{
var (ctx1, _) = Fake.Of<IBrowserContext>();
var (ctx2, _) = Fake.Of<IBrowserContext>();
var (browser, browserRec) = Fake.Of<IBrowser>();
browserRec.On("Contexts", new List<IBrowserContext> { ctx1, ctx2 });
var human = Humanize.Browser(browser, new HumanConfig());
Assert.Equal(2, human.Contexts.Count);
Assert.All(human.Contexts, c => Assert.IsType<HumanizedBrowserContext>(c));
}
[Fact]
public async Task Browser_full_chain_browser_to_context_to_page_is_all_wrapped()
{
var (page, pageRec) = Fake.Of<IPage>();
var (mouse, _) = Fake.Of<IMouse>();
var (keyboard, _) = Fake.Of<IKeyboard>();
pageRec.On("Mouse", mouse);
pageRec.On("Keyboard", keyboard);
var (ctx, ctxRec) = Fake.Of<IBrowserContext>();
ctxRec.On("NewPageAsync", Task.FromResult<IPage>(page));
var (browser, browserRec) = Fake.Of<IBrowser>();
browserRec.On("NewContextAsync", Task.FromResult(ctx));
var human = Humanize.Browser(browser, new HumanConfig());
var context = await human.NewContextAsync();
var leaf = await context.NewPageAsync();
// No raw leaks anywhere along the chain.
Assert.IsType<HumanizedBrowserContext>(context);
Assert.IsType<HumanizedPage>(leaf);
Assert.IsType<HumanizedMouse>(leaf.Mouse);
Assert.IsType<HumanizedKeyboard>(leaf.Keyboard);
}
[Fact]
public async Task Browser_delegated_member_exception_propagates()
{
var (browser, browserRec) = Fake.Of<IBrowser>();
browserRec.On("NewContextAsync", _ => throw new PlaywrightException("launch failed"));
var human = Humanize.Browser(browser, new HumanConfig());
await Assert.ThrowsAsync<PlaywrightException>(() => human.NewContextAsync());
}
[Fact]
public void Browser_Original_exposes_inner()
{
var (browser, _) = Fake.Of<IBrowser>();
var human = (HumanizedBrowser)Humanize.Browser(browser, new HumanConfig());
Assert.Same(browser, human.Original);
Assert.Same(browser, human.Inner);
}
}
@@ -0,0 +1,91 @@
using System.Reflection;
namespace CloakBrowser.Tests.Wrappers;
/// <summary>
/// A tiny <see cref="DispatchProxy"/>-based fake for any interface. Records every
/// member access and lets a test register handlers for specific members by name;
/// unregistered members return a sensible default (e.g. <c>Task.CompletedTask</c>).
///
/// This is test-only infrastructure - it lets us verify the production wrappers
/// (which use the source generator, not reflection) without spinning up a real
/// browser. The wrappers forward to <c>_inner</c>; here <c>_inner</c> is one of these
/// fakes, so we can assert exactly which inner calls happened.
/// </summary>
public sealed class CallRecord
{
public string Member { get; init; } = "";
public object?[] Args { get; init; } = System.Array.Empty<object?>();
}
public class FakeProxy : DispatchProxy
{
private readonly List<CallRecord> _calls = new();
private readonly Dictionary<string, System.Func<object?[], object?>> _handlers = new();
public IReadOnlyList<CallRecord> Calls => _calls;
public IEnumerable<string> CallNames => _calls.Select(c => c.Member);
public int CountOf(string member) => _calls.Count(c => c.Member == member);
public bool WasCalled(string member) => _calls.Any(c => c.Member == member);
public CallRecord? Last(string member) => _calls.LastOrDefault(c => c.Member == member);
/// <summary>Register a handler for a member (method or property getter) by name.</summary>
public void On(string member, System.Func<object?[], object?> handler) => _handlers[member] = handler;
public void On(string member, object? returnValue) => _handlers[member] = _ => returnValue;
protected override object? Invoke(MethodInfo? targetMethod, object?[]? args)
{
if (targetMethod == null)
return null;
string name = NormalizeName(targetMethod.Name);
args ??= System.Array.Empty<object?>();
_calls.Add(new CallRecord { Member = name, Args = args });
if ((_handlers.TryGetValue(targetMethod.Name, out var handler) ||
_handlers.TryGetValue(name, out handler)) && handler != null)
{
return handler(args);
}
return DefaultFor(targetMethod.ReturnType);
}
private static string NormalizeName(string methodName)
{
// Property getters/setters arrive as get_X / set_X.
if (methodName.StartsWith("get_") || methodName.StartsWith("set_"))
return methodName.Substring(4);
return methodName;
}
private static object? DefaultFor(System.Type t)
{
if (t == typeof(void)) return null;
if (t == typeof(Task)) return Task.CompletedTask;
if (t.IsGenericType && t.GetGenericTypeDefinition() == typeof(Task<>))
{
var inner = t.GetGenericArguments()[0];
object? innerDefault = inner.IsValueType ? System.Activator.CreateInstance(inner) : null;
var fromResult = typeof(Task).GetMethod(nameof(Task.FromResult))!.MakeGenericMethod(inner);
return fromResult.Invoke(null, new[] { innerDefault });
}
return t.IsValueType ? System.Activator.CreateInstance(t) : null;
}
}
/// <summary>Factory helpers for creating recording fakes.</summary>
public static class Fake
{
public static (T Proxy, FakeProxy Recorder) Of<T>() where T : class
{
var proxy = DispatchProxy.Create<T, FakeProxy>();
var recorder = (FakeProxy)(object)proxy;
return ((T)(object)proxy, recorder);
}
}
@@ -0,0 +1,116 @@
using CloakBrowser.Human;
using CloakBrowser.Wrappers;
using Microsoft.Playwright;
using Xunit;
namespace CloakBrowser.Tests.Wrappers;
/// <summary>
/// Ports Python <c>TestFocusCheck</c>: <see cref="HumanizedLocator.PressAsync"/> and
/// <see cref="HumanizedLocator.ClearAsync"/> only perform the humanized focus-click when
/// the element is not already focused. The focus state is probed via
/// <c>EvaluateAsync&lt;bool&gt;("el =&gt; el === document.activeElement")</c>; we mock that
/// to return true/false and assert whether the cursor moved (i.e. whether a click ran).
/// </summary>
public class FocusCheckTests
{
private static HumanConfig FastConfig() => new()
{
IdleBetweenActions = false,
MouseMinSteps = 2,
MouseMaxSteps = 3,
MouseBurstPause = (0, 0),
MouseOvershootChance = 0,
ClickAimDelayButton = (0, 0),
ClickHoldButton = (0, 0),
ClickAimDelayInput = (0, 0),
ClickHoldInput = (0, 0),
TypingDelay = 0,
TypingDelaySpread = 0,
TypingPauseChance = 0,
MistypeChance = 0,
ShiftDownDelay = (0, 0),
ShiftUpDelay = (0, 0),
KeyHold = (0, 0),
InitialCursorX = (100, 100),
InitialCursorY = (100, 100),
};
private static (IPage page, FakeProxy mouseRec, FakeProxy kbRec) BuildPage()
{
var (mouse, mouseRec) = Fake.Of<IMouse>();
var (keyboard, kbRec) = Fake.Of<IKeyboard>();
var (page, pageRec) = Fake.Of<IPage>();
pageRec.On("Mouse", mouse);
pageRec.On("Keyboard", keyboard);
pageRec.On("ViewportSize", new PageViewportSizeResult { Width = 1280, Height = 720 });
return (page, mouseRec, kbRec);
}
private static ILocator BuildLocator(bool focused)
{
var (locator, locRec) = Fake.Of<ILocator>();
locRec.On("First", locator);
locRec.On("BoundingBoxAsync", Task.FromResult<LocatorBoundingBoxResult?>(
new LocatorBoundingBoxResult { X = 100, Y = 200, Width = 80, Height = 30 }));
locRec.On("ScrollIntoViewIfNeededAsync", Task.CompletedTask);
// IsFocusedAsync / IsInputAsync both go through EvaluateAsync<bool>.
locRec.On("EvaluateAsync", Task.FromResult(focused));
return locator;
}
// --- PressAsync ---------------------------------------------------------
[Fact]
public async Task PressAsync_skips_click_when_focused()
{
var (page, mouseRec, kbRec) = BuildPage();
var human = new HumanizedLocator(BuildLocator(focused: true), new HumanCursor(page), FastConfig());
await human.PressAsync("Enter");
Assert.Equal(0, mouseRec.CountOf("MoveAsync")); // cursor did not move
Assert.Equal(0, mouseRec.CountOf("DownAsync")); // no click
Assert.True(kbRec.CountOf("PressAsync") >= 1, "the key was still pressed");
}
[Fact]
public async Task PressAsync_clicks_when_not_focused()
{
var (page, mouseRec, _) = BuildPage();
var human = new HumanizedLocator(BuildLocator(focused: false), new HumanCursor(page), FastConfig());
await human.PressAsync("Enter");
Assert.True(mouseRec.CountOf("MoveAsync") >= 1, "cursor moved to focus the element");
Assert.Equal(1, mouseRec.CountOf("DownAsync")); // humanized click happened
}
// --- ClearAsync ---------------------------------------------------------
[Fact]
public async Task ClearAsync_skips_click_when_focused()
{
var (page, mouseRec, kbRec) = BuildPage();
var human = new HumanizedLocator(BuildLocator(focused: true), new HumanCursor(page), FastConfig());
await human.ClearAsync();
Assert.Equal(0, mouseRec.CountOf("MoveAsync")); // no focus click
Assert.Equal(0, mouseRec.CountOf("DownAsync"));
// Still selects-all + Backspace through the keyboard.
Assert.True(kbRec.CountOf("PressAsync") >= 2, "select-all + Backspace");
}
[Fact]
public async Task ClearAsync_clicks_when_not_focused()
{
var (page, mouseRec, _) = BuildPage();
var human = new HumanizedLocator(BuildLocator(focused: false), new HumanCursor(page), FastConfig());
await human.ClearAsync();
Assert.True(mouseRec.CountOf("MoveAsync") >= 1, "cursor moved to focus the element");
Assert.Equal(1, mouseRec.CountOf("DownAsync"));
}
}
@@ -0,0 +1,228 @@
using CloakBrowser.Human;
using CloakBrowser.Wrappers;
using Microsoft.Playwright;
using Xunit;
namespace CloakBrowser.Tests.Wrappers;
/// <summary>
/// Tests that the Roslyn source generator's auto-delegation is correct: every
/// non-intercepted interface member forwards to the inner object verbatim, with
/// arguments and return values passed through, exceptions propagated, and overloads
/// distinguished.
/// </summary>
public class GeneratorDelegationTests
{
private static (HumanizedLocator human, FakeProxy rec) Locator()
{
var (mouse, _) = Fake.Of<IMouse>();
var (keyboard, _) = Fake.Of<IKeyboard>();
var (page, pageRec) = Fake.Of<IPage>();
pageRec.On("Mouse", mouse);
pageRec.On("Keyboard", keyboard);
var (inner, rec) = Fake.Of<ILocator>();
return (new HumanizedLocator(inner, new HumanCursor(page), new HumanConfig()), rec);
}
// -----------------------------------------------------------------------
// Methods delegate with arguments + return values intact.
// -----------------------------------------------------------------------
[Fact]
public async Task Method_with_return_value_delegates()
{
var (human, rec) = Locator();
rec.On("CountAsync", Task.FromResult(42));
Assert.Equal(42, await human.CountAsync());
Assert.True(rec.WasCalled("CountAsync"));
}
[Fact]
public async Task Method_arguments_are_forwarded_unchanged()
{
var (human, rec) = Locator();
rec.On("GetAttributeAsync", Task.FromResult<string?>("yes"));
await human.GetAttributeAsync("data-id");
Assert.Equal("data-id", rec.Last("GetAttributeAsync")!.Args[0]);
}
[Fact]
public async Task Method_with_optional_options_delegates_without_throwing()
{
var (human, rec) = Locator();
rec.On("InnerTextAsync", Task.FromResult("text"));
// Call without supplying the optional options argument.
Assert.Equal("text", await human.InnerTextAsync());
Assert.True(rec.WasCalled("InnerTextAsync"));
}
[Fact]
public async Task Generic_returning_method_delegates_with_type_intact()
{
var (human, rec) = Locator();
rec.On("EvaluateAsync", Task.FromResult(123));
// ILocator has a generic EvaluateAsync<T>; ensure delegation preserves T=int.
int result = await human.EvaluateAsync<int>("el => 123");
Assert.Equal(123, result);
}
// -----------------------------------------------------------------------
// Properties delegate.
// -----------------------------------------------------------------------
[Fact]
public void Property_getter_delegates()
{
var (human, rec) = Locator();
// No handler registered: the getter still delegates and is recorded; the
// generator returns the inner value (default null for IPage here).
_ = human.Page;
Assert.True(rec.WasCalled("Page"));
}
// -----------------------------------------------------------------------
// Exceptions from delegated members propagate unchanged (not swallowed).
// -----------------------------------------------------------------------
[Fact]
public async Task Delegated_exception_propagates_with_same_type_and_message()
{
var (human, rec) = Locator();
rec.On("InnerHTMLAsync", _ => throw new TimeoutException("timed out"));
var ex = await Assert.ThrowsAsync<TimeoutException>(() => human.InnerHTMLAsync());
Assert.Equal("timed out", ex.Message);
}
// -----------------------------------------------------------------------
// The marker attribute was generated into the consuming assembly.
// -----------------------------------------------------------------------
[Fact]
public void Generated_marker_attribute_exists()
{
var attrType = typeof(HumanizedPage).Assembly
.GetType("CloakBrowser.Wrappers.GenerateInterfaceDelegationAttribute");
Assert.NotNull(attrType);
}
// -----------------------------------------------------------------------
// Every wrapper actually implements its full Playwright interface - proof
// that the generator filled in all non-hand-written members (a missing
// implementation would not even compile, but this asserts the contract too).
// -----------------------------------------------------------------------
[Theory]
[InlineData(typeof(HumanizedPage), typeof(IPage))]
[InlineData(typeof(HumanizedLocator), typeof(ILocator))]
[InlineData(typeof(HumanizedMouse), typeof(IMouse))]
[InlineData(typeof(HumanizedKeyboard), typeof(IKeyboard))]
[InlineData(typeof(HumanizedFrame), typeof(IFrame))]
[InlineData(typeof(HumanizedElementHandle), typeof(IElementHandle))]
[InlineData(typeof(HumanizedBrowser), typeof(IBrowser))]
[InlineData(typeof(HumanizedBrowserContext), typeof(IBrowserContext))]
public void Wrapper_implements_full_interface(System.Type wrapper, System.Type iface)
{
Assert.True(iface.IsAssignableFrom(wrapper),
$"{wrapper.Name} must implement {iface.Name}");
}
[Theory]
[InlineData(typeof(HumanizedPage))]
[InlineData(typeof(HumanizedLocator))]
[InlineData(typeof(HumanizedMouse))]
[InlineData(typeof(HumanizedKeyboard))]
[InlineData(typeof(HumanizedFrame))]
[InlineData(typeof(HumanizedElementHandle))]
[InlineData(typeof(HumanizedBrowser))]
[InlineData(typeof(HumanizedBrowserContext))]
public void Wrapper_exposes_Original_and_Inner_escape_hatch(System.Type wrapper)
{
Assert.NotNull(wrapper.GetProperty("Original"));
Assert.NotNull(wrapper.GetProperty("Inner"));
}
// -----------------------------------------------------------------------
// Guard: NO interaction method may be left to the source generator to
// delegate straight to raw Playwright (that would silently bypass
// humanization). Previously enforced for Locator only; now covers every
// interactive wrapper. A future overload that falls through to the
// generator fails the build here.
//
// This is what caught the Frame.SelectOptionAsync / DragAndDropAsync and
// ElementHandle.SelectOptionAsync / SetCheckedAsync gaps: the generator had
// been forwarding those raw because no override was hand-written.
// -----------------------------------------------------------------------
private static bool IsGenerated(System.Reflection.MethodInfo m) =>
m.GetCustomAttributes(typeof(System.CodeDom.Compiler.GeneratedCodeAttribute), false).Length > 0;
public static IEnumerable<object[]> InteractionMethodsByWrapper() => new[]
{
// HumanizedPage
new object[] { typeof(HumanizedPage), "ClickAsync" },
new object[] { typeof(HumanizedPage), "DblClickAsync" },
new object[] { typeof(HumanizedPage), "HoverAsync" },
new object[] { typeof(HumanizedPage), "TapAsync" },
new object[] { typeof(HumanizedPage), "FillAsync" },
new object[] { typeof(HumanizedPage), "TypeAsync" },
new object[] { typeof(HumanizedPage), "PressAsync" },
new object[] { typeof(HumanizedPage), "CheckAsync" },
new object[] { typeof(HumanizedPage), "UncheckAsync" },
new object[] { typeof(HumanizedPage), "SetCheckedAsync" },
new object[] { typeof(HumanizedPage), "SelectOptionAsync" },
new object[] { typeof(HumanizedPage), "DragAndDropAsync" },
// HumanizedFrame
new object[] { typeof(HumanizedFrame), "ClickAsync" },
new object[] { typeof(HumanizedFrame), "DblClickAsync" },
new object[] { typeof(HumanizedFrame), "HoverAsync" },
new object[] { typeof(HumanizedFrame), "TapAsync" },
new object[] { typeof(HumanizedFrame), "FillAsync" },
new object[] { typeof(HumanizedFrame), "TypeAsync" },
new object[] { typeof(HumanizedFrame), "PressAsync" },
new object[] { typeof(HumanizedFrame), "CheckAsync" },
new object[] { typeof(HumanizedFrame), "UncheckAsync" },
new object[] { typeof(HumanizedFrame), "SetCheckedAsync" },
new object[] { typeof(HumanizedFrame), "SelectOptionAsync" },
new object[] { typeof(HumanizedFrame), "DragAndDropAsync" },
// HumanizedElementHandle (no DragAndDropAsync on IElementHandle)
new object[] { typeof(HumanizedElementHandle), "ClickAsync" },
new object[] { typeof(HumanizedElementHandle), "DblClickAsync" },
new object[] { typeof(HumanizedElementHandle), "HoverAsync" },
new object[] { typeof(HumanizedElementHandle), "TapAsync" },
new object[] { typeof(HumanizedElementHandle), "FillAsync" },
new object[] { typeof(HumanizedElementHandle), "TypeAsync" },
new object[] { typeof(HumanizedElementHandle), "PressAsync" },
new object[] { typeof(HumanizedElementHandle), "CheckAsync" },
new object[] { typeof(HumanizedElementHandle), "UncheckAsync" },
new object[] { typeof(HumanizedElementHandle), "SetCheckedAsync" },
new object[] { typeof(HumanizedElementHandle), "SelectOptionAsync" },
// HumanizedMouse
new object[] { typeof(HumanizedMouse), "MoveAsync" },
new object[] { typeof(HumanizedMouse), "ClickAsync" },
new object[] { typeof(HumanizedMouse), "DblClickAsync" },
new object[] { typeof(HumanizedMouse), "DownAsync" },
new object[] { typeof(HumanizedMouse), "UpAsync" },
new object[] { typeof(HumanizedMouse), "WheelAsync" },
// HumanizedKeyboard
new object[] { typeof(HumanizedKeyboard), "TypeAsync" },
new object[] { typeof(HumanizedKeyboard), "PressAsync" },
new object[] { typeof(HumanizedKeyboard), "InsertTextAsync" },
};
[Theory]
[MemberData(nameof(InteractionMethodsByWrapper))]
public void Interaction_method_is_humanized_not_generator_delegated(System.Type wrapper, string methodName)
{
var methods = wrapper
.GetMethods(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance)
.Where(m => m.Name == methodName)
.ToList();
Assert.NotEmpty(methods); // the method exists on the wrapper
// EVERY overload of an interaction method must be hand-written, not
// emitted by the generator (which would delegate straight to Playwright).
Assert.All(methods, m =>
Assert.False(IsGenerated(m),
$"{wrapper.Name}.{methodName} must be humanized (hand-written), not generator-delegated"));
}
}
@@ -0,0 +1,354 @@
using CloakBrowser.Human;
using CloakBrowser.Wrappers;
using Microsoft.Playwright;
using Xunit;
namespace CloakBrowser.Tests.Wrappers;
/// <summary>
/// Tests for the transparent <see cref="HumanizedLocator"/> decorator: humanized
/// actions, nested locator re-wrapping (so chains stay humanized), correct delegation
/// of non-interaction members, and exception/cancellation propagation.
/// </summary>
public class LocatorWrapperTests
{
private static HumanConfig FastConfig() => new()
{
IdleBetweenActions = false,
MouseMinSteps = 2,
MouseMaxSteps = 3,
MouseBurstPause = (0, 0),
MouseOvershootChance = 0,
ClickAimDelayButton = (0, 0),
ClickHoldButton = (0, 0),
ClickAimDelayInput = (0, 0),
ClickHoldInput = (0, 0),
TypingDelay = 0,
TypingDelaySpread = 0,
TypingPauseChance = 0,
MistypeChance = 0,
ShiftDownDelay = (0, 0),
ShiftUpDelay = (0, 0),
KeyHold = (0, 0),
InitialCursorX = (100, 100),
InitialCursorY = (100, 100),
};
private static (IPage page, FakeProxy mouseRec, FakeProxy kbRec) BuildPage()
{
var (mouse, mouseRec) = Fake.Of<IMouse>();
var (keyboard, kbRec) = Fake.Of<IKeyboard>();
var (page, pageRec) = Fake.Of<IPage>();
pageRec.On("Mouse", mouse);
pageRec.On("Keyboard", keyboard);
pageRec.On("ViewportSize", new PageViewportSizeResult { Width = 1280, Height = 720 });
return (page, mouseRec, kbRec);
}
private static (ILocator locator, FakeProxy locRec) BuildLocator(
LocatorBoundingBoxResult? box = null, bool evaluateResult = false)
{
var (locator, locRec) = Fake.Of<ILocator>();
// .First returns itself so motion code resolving First works.
locRec.On("First", locator);
locRec.On("BoundingBoxAsync", Task.FromResult<LocatorBoundingBoxResult?>(
box ?? new LocatorBoundingBoxResult { X = 100, Y = 200, Width = 80, Height = 30 }));
locRec.On("ScrollIntoViewIfNeededAsync", Task.CompletedTask);
// EvaluateAsync<bool> backs both IsInput and IsFocused checks. The wrapper
// awaits a Task<bool>, so the handler must return a real Task<bool>.
locRec.On("EvaluateAsync", Task.FromResult(evaluateResult));
return (locator, locRec);
}
// -----------------------------------------------------------------------
// Interception
// -----------------------------------------------------------------------
[Fact]
public async Task ClickAsync_runs_humanized_motion_and_press()
{
var (page, mouseRec, _) = BuildPage();
var (locator, _) = BuildLocator();
var cursor = new HumanCursor(page);
var human = new HumanizedLocator(locator, cursor, FastConfig());
await human.ClickAsync();
Assert.True(mouseRec.CountOf("MoveAsync") >= 1, "should move along a curve");
Assert.Equal(1, mouseRec.CountOf("DownAsync"));
Assert.Equal(1, mouseRec.CountOf("UpAsync"));
}
[Fact]
public async Task FillAsync_clears_then_types_via_keyboard()
{
var (page, _, kbRec) = BuildPage();
var (locator, _) = BuildLocator();
var human = new HumanizedLocator(locator, new HumanCursor(page), FastConfig());
await human.FillAsync("hi");
// Select-all + Backspace + per-char typing all go through the page keyboard.
Assert.True(kbRec.CountOf("PressAsync") >= 2, "select-all + backspace");
Assert.True(kbRec.CountOf("DownAsync") >= 2, "typed characters");
}
[Fact]
public async Task SelectOptionAsync_runs_humanized_hover_then_delegates()
{
var (page, mouseRec, _) = BuildPage();
var (locator, locRec) = BuildLocator();
IReadOnlyList<string> selected = new[] { "opt1" };
locRec.On("SelectOptionAsync", Task.FromResult(selected));
var human = new HumanizedLocator(locator, new HumanCursor(page), FastConfig());
var result = await human.SelectOptionAsync("opt1");
// The cursor must travel to the <select> along a curve before the native select.
Assert.True(mouseRec.CountOf("MoveAsync") >= 1, "should move the cursor to the element");
// The real Playwright select is still performed (and its result flows back).
Assert.True(locRec.WasCalled("SelectOptionAsync"));
Assert.Equal(new[] { "opt1" }, result);
}
[Fact]
public async Task SelectOptionAsync_overloads_all_humanize_and_delegate()
{
IReadOnlyList<string> selected = new[] { "v" };
// Every ILocator SelectOptionAsync overload should hover (move) then delegate.
async Task AssertOverload(System.Func<HumanizedLocator, Task> call)
{
var (page, mouseRec, _) = BuildPage();
var (locator, locRec) = BuildLocator();
locRec.On("SelectOptionAsync", Task.FromResult(selected));
var human = new HumanizedLocator(locator, new HumanCursor(page), FastConfig());
await call(human);
Assert.True(mouseRec.CountOf("MoveAsync") >= 1, "should move the cursor");
Assert.True(locRec.WasCalled("SelectOptionAsync"), "should delegate to inner");
}
var (handle, _) = Fake.Of<IElementHandle>();
await AssertOverload(h => h.SelectOptionAsync("v"));
await AssertOverload(h => h.SelectOptionAsync(new[] { "v" }));
await AssertOverload(h => h.SelectOptionAsync(new SelectOptionValue { Value = "v" }));
await AssertOverload(h => h.SelectOptionAsync(new[] { new SelectOptionValue { Value = "v" } }));
await AssertOverload(h => h.SelectOptionAsync(handle));
await AssertOverload(h => h.SelectOptionAsync(new[] { handle }));
}
[Fact]
public async Task ClearAsync_focuses_selects_all_and_backspaces()
{
var (page, mouseRec, kbRec) = BuildPage();
var (locator, _) = BuildLocator(); // EvaluateAsync(IsFocused) -> false
var human = new HumanizedLocator(locator, new HumanCursor(page), FastConfig());
await human.ClearAsync();
// Not focused -> humanized click to focus the field (curve + press).
Assert.True(mouseRec.CountOf("MoveAsync") >= 1, "should move the cursor to focus");
Assert.Equal(1, mouseRec.CountOf("DownAsync"));
// Then select-all + Backspace via the keyboard (NOT an instant value reset).
Assert.True(kbRec.CountOf("PressAsync") >= 2, "select-all + backspace");
}
[Fact]
public async Task ClearAsync_when_focused_skips_click_but_still_selects_and_deletes()
{
var (page, mouseRec, kbRec) = BuildPage();
// Field is already focused -> no humanized click, just select-all + backspace.
var (locator, _) = BuildLocator(evaluateResult: true);
var human = new HumanizedLocator(locator, new HumanCursor(page), FastConfig());
await human.ClearAsync();
Assert.Equal(0, mouseRec.CountOf("DownAsync")); // no focus click needed
Assert.True(kbRec.CountOf("PressAsync") >= 2, "select-all + backspace");
}
[Fact]
public async Task CheckAsync_clicks_only_when_not_already_checked()
{
var (page, mouseRec, _) = BuildPage();
var (locator, locRec) = BuildLocator();
locRec.On("IsCheckedAsync", Task.FromResult(true)); // already checked
var human = new HumanizedLocator(locator, new HumanCursor(page), FastConfig());
await human.CheckAsync();
Assert.Equal(0, mouseRec.CountOf("DownAsync")); // no click performed
}
[Fact]
public async Task CheckAsync_clicks_when_unchecked()
{
var (page, mouseRec, _) = BuildPage();
var (locator, locRec) = BuildLocator();
locRec.On("IsCheckedAsync", Task.FromResult(false));
var human = new HumanizedLocator(locator, new HumanCursor(page), FastConfig());
await human.CheckAsync();
Assert.Equal(1, mouseRec.CountOf("DownAsync")); // click performed
}
// -----------------------------------------------------------------------
// Nested re-wrapping: locator-returning members return humanized locators.
// -----------------------------------------------------------------------
[Fact]
public void Nested_locator_members_return_wrapped_locators()
{
var (page, _, _) = BuildPage();
var (inner, innerRec) = Fake.Of<ILocator>();
var (child, _) = Fake.Of<ILocator>();
innerRec.On("First", child);
innerRec.On("Last", child);
innerRec.On("Nth", child);
innerRec.On("Locator", child);
innerRec.On("GetByTestId", child);
innerRec.On("GetByText", child);
var human = new HumanizedLocator(inner, new HumanCursor(page), FastConfig());
Assert.IsType<HumanizedLocator>(human.First);
Assert.IsType<HumanizedLocator>(human.Last);
Assert.IsType<HumanizedLocator>(human.Nth(0));
Assert.IsType<HumanizedLocator>(human.Locator("a"));
Assert.IsType<HumanizedLocator>(human.GetByTestId("t"));
Assert.IsType<HumanizedLocator>(human.GetByText("x"));
}
// -----------------------------------------------------------------------
// Delegation: non-interaction members forward to the inner locator.
// -----------------------------------------------------------------------
[Fact]
public async Task Query_members_delegate_to_inner()
{
var (page, _, _) = BuildPage();
var (inner, innerRec) = Fake.Of<ILocator>();
innerRec.On("CountAsync", Task.FromResult(7));
innerRec.On("TextContentAsync", Task.FromResult<string?>("hello"));
innerRec.On("IsVisibleAsync", Task.FromResult(true));
var human = new HumanizedLocator(inner, new HumanCursor(page), FastConfig());
Assert.Equal(7, await human.CountAsync());
Assert.Equal("hello", await human.TextContentAsync());
Assert.True(await human.IsVisibleAsync());
Assert.True(innerRec.WasCalled("CountAsync"));
Assert.True(innerRec.WasCalled("TextContentAsync"));
}
// -----------------------------------------------------------------------
// Completeness - port of Python test_locator_methods_patched.
// Every interaction method must be hand-written (humanized/intercepted),
// NOT left to the source generator to delegate straight to Playwright.
// The generator marks the members it emits with [GeneratedCode]; an
// intercepted method carries no such marker.
// -----------------------------------------------------------------------
public static IEnumerable<object[]> InteractionMethodNames() => new[]
{
new object[] { "ClickAsync" },
new object[] { "DblClickAsync" },
new object[] { "HoverAsync" },
new object[] { "TapAsync" },
new object[] { "FillAsync" },
new object[] { "TypeAsync" },
new object[] { "PressSequentiallyAsync" },
new object[] { "PressAsync" },
new object[] { "CheckAsync" },
new object[] { "UncheckAsync" },
new object[] { "SetCheckedAsync" },
new object[] { "DragToAsync" },
new object[] { "SelectOptionAsync" },
new object[] { "ClearAsync" },
};
private static bool IsGenerated(System.Reflection.MethodInfo m) =>
m.GetCustomAttributes(typeof(System.CodeDom.Compiler.GeneratedCodeAttribute), false).Length > 0;
[Theory]
[MemberData(nameof(InteractionMethodNames))]
public void Interaction_method_is_humanized_not_generator_delegated(string methodName)
{
var methods = typeof(HumanizedLocator)
.GetMethods(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance)
.Where(m => m.Name == methodName)
.ToList();
Assert.NotEmpty(methods); // the method exists on the wrapper
// EVERY overload of an interaction method must be hand-written.
Assert.All(methods, m =>
Assert.False(IsGenerated(m),
$"{methodName} must be humanized (hand-written), not generator-delegated"));
}
[Fact]
public void All_fourteen_interaction_methods_are_present_and_humanized()
{
var humanizedNames = typeof(HumanizedLocator)
.GetMethods(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance)
.Where(m => !IsGenerated(m))
.Select(m => m.Name)
.ToHashSet();
foreach (var row in InteractionMethodNames())
Assert.Contains((string)row[0], humanizedNames);
}
[Fact]
public void A_non_interaction_member_is_generator_delegated()
{
// Sanity check that the [GeneratedCode] discriminator actually works:
// a pure query like CountAsync is delegated by the generator.
var count = typeof(HumanizedLocator).GetMethod("CountAsync");
Assert.NotNull(count);
Assert.True(IsGenerated(count!), "CountAsync should be generator-delegated");
}
// -----------------------------------------------------------------------
// Escape hatch
// -----------------------------------------------------------------------
[Fact]
public void Original_and_Inner_expose_unwrapped_locator()
{
var (page, _, _) = BuildPage();
var (inner, _) = Fake.Of<ILocator>();
var human = new HumanizedLocator(inner, new HumanCursor(page), FastConfig());
Assert.Same(inner, human.Original);
Assert.Same(inner, human.Inner);
}
// -----------------------------------------------------------------------
// Exception & cancellation propagation
// -----------------------------------------------------------------------
[Fact]
public async Task Inner_exception_during_action_propagates()
{
var (page, _, _) = BuildPage();
var (locator, locRec) = BuildLocator();
locRec.On("ScrollIntoViewIfNeededAsync", _ => throw new PlaywrightException("detached"));
var human = new HumanizedLocator(locator, new HumanCursor(page), FastConfig());
await Assert.ThrowsAsync<PlaywrightException>(() => human.ClickAsync());
}
[Fact]
public async Task Cancellation_propagates_as_OperationCanceled()
{
var (page, _, _) = BuildPage();
var (locator, locRec) = BuildLocator();
locRec.On("ScrollIntoViewIfNeededAsync", _ => throw new OperationCanceledException());
var human = new HumanizedLocator(locator, new HumanCursor(page), FastConfig());
await Assert.ThrowsAsync<OperationCanceledException>(() => human.ClickAsync());
}
}
@@ -0,0 +1,183 @@
using CloakBrowser.Human;
using CloakBrowser.Wrappers;
using Microsoft.Playwright;
using Xunit;
namespace CloakBrowser.Tests.Wrappers;
/// <summary>
/// Tests for the transparent <see cref="HumanizedMouse"/> / <see cref="HumanizedKeyboard"/>
/// decorators: intercepted methods run humanize logic (multiple inner calls), and the
/// escape-hatch exposes the original object.
/// </summary>
public class MouseKeyboardWrapperTests
{
// A fast config: no idle, minimal motion steps, tiny ranges, so tests run quickly.
private static HumanConfig FastConfig() => new()
{
IdleBetweenActions = false,
MouseMinSteps = 2,
MouseMaxSteps = 3,
MouseBurstSize = (5, 5),
MouseBurstPause = (0, 0),
MouseOvershootChance = 0, // deterministic: no overshoot
ClickAimDelayButton = (0, 0),
ClickHoldButton = (0, 0),
ClickAimDelayInput = (0, 0),
ClickHoldInput = (0, 0),
TypingDelay = 0,
TypingDelaySpread = 0,
TypingPauseChance = 0,
MistypeChance = 0,
ShiftDownDelay = (0, 0),
ShiftUpDelay = (0, 0),
KeyHold = (0, 0),
InitialCursorX = (100, 100),
InitialCursorY = (100, 100),
};
private static (IPage page, FakeProxy pageRec, FakeProxy mouseRec, FakeProxy kbRec) BuildPage()
{
var (mouse, mouseRec) = Fake.Of<IMouse>();
var (keyboard, kbRec) = Fake.Of<IKeyboard>();
var (page, pageRec) = Fake.Of<IPage>();
pageRec.On("Mouse", mouse);
pageRec.On("Keyboard", keyboard);
pageRec.On("ViewportSize", new PageViewportSizeResult { Width = 1280, Height = 720 });
return (page, pageRec, mouseRec, kbRec);
}
private static HumanCursor MakeCursor(IPage page)
{
var cursor = new HumanCursor(page);
// Don't call InitStealthAsync (no CDP in a fake) - typing falls back to evaluate.
return cursor;
}
// -----------------------------------------------------------------------
// Interception: humanized methods produce multiple low-level inner calls.
// -----------------------------------------------------------------------
[Fact]
public async Task Mouse_MoveAsync_is_humanized_into_multiple_inner_moves()
{
var (page, _, mouseRec, _) = BuildPage();
var cfg = FastConfig();
var cursor = MakeCursor(page);
var mouse = new HumanizedMouse(page.Mouse, cursor, cfg);
await mouse.MoveAsync(500, 400);
// Bezier motion => more than one inner MoveAsync (the single-call API became many).
Assert.True(mouseRec.CountOf("MoveAsync") > 1,
$"expected humanized multi-step move, got {mouseRec.CountOf("MoveAsync")}");
}
[Fact]
public async Task Mouse_ClickAsync_moves_then_presses()
{
var (page, _, mouseRec, _) = BuildPage();
var mouse = new HumanizedMouse(page.Mouse, MakeCursor(page), FastConfig());
await mouse.ClickAsync(300, 300);
Assert.True(mouseRec.CountOf("MoveAsync") >= 1);
Assert.Equal(1, mouseRec.CountOf("DownAsync"));
Assert.Equal(1, mouseRec.CountOf("UpAsync"));
// Down must come before Up (correct ordering preserved).
int down = mouseRec.CallNames.ToList().IndexOf("DownAsync");
int up = mouseRec.CallNames.ToList().IndexOf("UpAsync");
Assert.True(down < up);
}
[Fact]
public async Task Mouse_WheelAsync_is_chunked_into_multiple_inner_wheels()
{
var (page, _, mouseRec, _) = BuildPage();
var mouse = new HumanizedMouse(page.Mouse, MakeCursor(page), FastConfig());
await mouse.WheelAsync(0, 300);
Assert.True(mouseRec.CountOf("WheelAsync") > 1,
"wheel should be broken into small inertia bursts");
}
[Fact]
public Task Mouse_Original_and_Inner_expose_the_unwrapped_object()
{
var (page, _, _, _) = BuildPage();
var inner = page.Mouse;
var mouse = new HumanizedMouse(inner, MakeCursor(page), FastConfig());
Assert.Same(inner, mouse.Original);
Assert.Same(inner, mouse.Inner);
return Task.CompletedTask;
}
// -----------------------------------------------------------------------
// Delegation: non-intercepted members forward verbatim to the inner object.
// -----------------------------------------------------------------------
[Fact]
public async Task Keyboard_DownAsync_UpAsync_delegate_to_inner()
{
var (page, _, _, kbRec) = BuildPage();
var kb = new HumanizedKeyboard(page.Keyboard, MakeCursor(page), FastConfig());
await kb.DownAsync("Shift");
await kb.UpAsync("Shift");
Assert.Equal(1, kbRec.CountOf("DownAsync"));
Assert.Equal(1, kbRec.CountOf("UpAsync"));
Assert.Equal("Shift", kbRec.Last("DownAsync")!.Args[0]);
}
[Fact]
public async Task Keyboard_TypeAsync_produces_per_character_inner_key_events()
{
var (page, _, _, kbRec) = BuildPage();
var kb = new HumanizedKeyboard(page.Keyboard, MakeCursor(page), FastConfig());
await kb.TypeAsync("abc");
// Human typing presses each key down+up individually (not one inner TypeAsync).
Assert.Equal(0, kbRec.CountOf("TypeAsync"));
Assert.True(kbRec.CountOf("DownAsync") >= 3, "expected per-char key downs");
Assert.True(kbRec.CountOf("UpAsync") >= 3, "expected per-char key ups");
}
[Fact]
public Task Keyboard_Original_and_Inner_expose_the_unwrapped_object()
{
var (page, _, _, _) = BuildPage();
var inner = page.Keyboard;
var kb = new HumanizedKeyboard(inner, MakeCursor(page), FastConfig());
Assert.Same(inner, kb.Original);
Assert.Same(inner, kb.Inner);
return Task.CompletedTask;
}
// -----------------------------------------------------------------------
// Exception propagation: inner failures bubble up unchanged (not swallowed).
// -----------------------------------------------------------------------
[Fact]
public async Task Mouse_inner_exception_propagates()
{
var (page, _, mouseRec, _) = BuildPage();
mouseRec.On("MoveAsync", _ => throw new InvalidOperationException("boom"));
var mouse = new HumanizedMouse(page.Mouse, MakeCursor(page), FastConfig());
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => mouse.ClickAsync(200, 200));
Assert.Equal("boom", ex.Message);
}
[Fact]
public async Task Keyboard_inner_exception_propagates()
{
var (page, _, _, kbRec) = BuildPage();
kbRec.On("DownAsync", _ => throw new PlaywrightException("kb fail"));
var kb = new HumanizedKeyboard(page.Keyboard, MakeCursor(page), FastConfig());
await Assert.ThrowsAsync<PlaywrightException>(() => kb.TypeAsync("a"));
}
}
@@ -0,0 +1,198 @@
using CloakBrowser.Human;
using CloakBrowser.Wrappers;
using Microsoft.Playwright;
using Xunit;
namespace CloakBrowser.Tests.Wrappers;
/// <summary>
/// Tests for the transparent <see cref="HumanizedPage"/> decorator: nested objects
/// (Mouse/Keyboard/Locator/Frame) are returned wrapped, selector actions run through
/// the humanize engine, non-interaction members delegate, and the escape hatch works.
/// </summary>
public class PageWrapperTests
{
private static HumanConfig FastConfig() => new()
{
IdleBetweenActions = false,
MouseMinSteps = 2,
MouseMaxSteps = 3,
MouseBurstPause = (0, 0),
MouseOvershootChance = 0,
ClickAimDelayButton = (0, 0),
ClickHoldButton = (0, 0),
ClickAimDelayInput = (0, 0),
ClickHoldInput = (0, 0),
TypingDelay = 0,
TypingDelaySpread = 0,
TypingPauseChance = 0,
MistypeChance = 0,
ShiftDownDelay = (0, 0),
ShiftUpDelay = (0, 0),
KeyHold = (0, 0),
InitialCursorX = (100, 100),
InitialCursorY = (100, 100),
};
/// <summary>Build a fake page whose Locator(...) returns an actionable fake locator.</summary>
private static (HumanizedPage human, FakeProxy pageRec, FakeProxy mouseRec) BuildHumanizedPage()
{
var (mouse, mouseRec) = Fake.Of<IMouse>();
var (keyboard, _) = Fake.Of<IKeyboard>();
var (page, pageRec) = Fake.Of<IPage>();
var (locator, locRec) = Fake.Of<ILocator>();
locRec.On("First", locator);
locRec.On("BoundingBoxAsync", Task.FromResult<LocatorBoundingBoxResult?>(
new LocatorBoundingBoxResult { X = 100, Y = 200, Width = 80, Height = 30 }));
locRec.On("IsVisibleAsync", Task.FromResult(true));
locRec.On("IsEnabledAsync", Task.FromResult(true));
locRec.On("IsEditableAsync", Task.FromResult(true));
locRec.On("WaitForAsync", Task.CompletedTask);
locRec.On("EvaluateAsync", Task.FromResult(
System.Text.Json.JsonSerializer.SerializeToElement(new { hit = true })));
pageRec.On("Mouse", mouse);
pageRec.On("Keyboard", keyboard);
pageRec.On("ViewportSize", new PageViewportSizeResult { Width = 1280, Height = 720 });
pageRec.On("Locator", locator);
pageRec.On("EvaluateAsync", Task.FromResult(
System.Text.Json.JsonSerializer.SerializeToElement(false)));
var cursor = new HumanCursor(page);
var human = new HumanizedPage(page, cursor, FastConfig());
return (human, pageRec, mouseRec);
}
// -----------------------------------------------------------------------
// Nested objects are wrapped
// -----------------------------------------------------------------------
[Fact]
public void Mouse_and_Keyboard_are_humanized_wrappers()
{
var (human, _, _) = BuildHumanizedPage();
Assert.IsType<HumanizedMouse>(human.Mouse);
Assert.IsType<HumanizedKeyboard>(human.Keyboard);
}
[Fact]
public void Locator_and_GetBy_return_humanized_locators()
{
var (human, _, _) = BuildHumanizedPage();
Assert.IsType<HumanizedLocator>(human.Locator("#a"));
Assert.IsType<HumanizedLocator>(human.GetByTestId("t"));
Assert.IsType<HumanizedLocator>(human.GetByText("x"));
Assert.IsType<HumanizedLocator>(human.GetByRole(AriaRole.Button));
}
[Fact]
public void MainFrame_and_Frames_return_humanized_frames()
{
var (mouse, _) = Fake.Of<IMouse>();
var (keyboard, _) = Fake.Of<IKeyboard>();
var (frame, _) = Fake.Of<IFrame>();
var (page, pageRec) = Fake.Of<IPage>();
pageRec.On("Mouse", mouse);
pageRec.On("Keyboard", keyboard);
pageRec.On("MainFrame", frame);
pageRec.On("Frames", new List<IFrame> { frame });
var human = new HumanizedPage(page, new HumanCursor(page), FastConfig());
Assert.IsType<HumanizedFrame>(human.MainFrame);
Assert.All(human.Frames, f => Assert.IsType<HumanizedFrame>(f));
}
// -----------------------------------------------------------------------
// Selector action interception drives the humanize engine.
// -----------------------------------------------------------------------
[Fact]
public async Task ClickAsync_selector_runs_humanized_motion()
{
var (human, _, mouseRec) = BuildHumanizedPage();
await human.ClickAsync("#submit");
Assert.True(mouseRec.CountOf("MoveAsync") >= 1);
Assert.Equal(1, mouseRec.CountOf("DownAsync"));
Assert.Equal(1, mouseRec.CountOf("UpAsync"));
}
// -----------------------------------------------------------------------
// Delegation: non-interaction members forward to the inner page.
// -----------------------------------------------------------------------
[Fact]
public async Task TitleAsync_and_Url_delegate_to_inner()
{
var (mouse, _) = Fake.Of<IMouse>();
var (keyboard, _) = Fake.Of<IKeyboard>();
var (page, pageRec) = Fake.Of<IPage>();
pageRec.On("Mouse", mouse);
pageRec.On("Keyboard", keyboard);
pageRec.On("TitleAsync", Task.FromResult("Example Domain"));
pageRec.On("Url", "https://example.com/");
var human = new HumanizedPage(page, new HumanCursor(page), FastConfig());
Assert.Equal("Example Domain", await human.TitleAsync());
Assert.Equal("https://example.com/", human.Url);
Assert.True(pageRec.WasCalled("TitleAsync"));
}
[Fact]
public async Task GotoAsync_delegates_and_returns_inner_response()
{
var (mouse, _) = Fake.Of<IMouse>();
var (keyboard, _) = Fake.Of<IKeyboard>();
var (response, _) = Fake.Of<IResponse>();
var (page, pageRec) = Fake.Of<IPage>();
pageRec.On("Mouse", mouse);
pageRec.On("Keyboard", keyboard);
pageRec.On("GotoAsync", Task.FromResult<IResponse?>(response));
var human = new HumanizedPage(page, new HumanCursor(page), FastConfig());
var result = await human.GotoAsync("https://example.com");
Assert.Same(response, result);
Assert.True(pageRec.WasCalled("GotoAsync"));
}
// -----------------------------------------------------------------------
// Escape hatch
// -----------------------------------------------------------------------
[Fact]
public void Original_and_Inner_expose_unwrapped_page()
{
var (mouse, _) = Fake.Of<IMouse>();
var (keyboard, _) = Fake.Of<IKeyboard>();
var (page, pageRec) = Fake.Of<IPage>();
pageRec.On("Mouse", mouse);
pageRec.On("Keyboard", keyboard);
var human = new HumanizedPage(page, new HumanCursor(page), FastConfig());
Assert.Same(page, human.Original);
Assert.Same(page, human.Inner);
}
// -----------------------------------------------------------------------
// Exception propagation through a delegated member.
// -----------------------------------------------------------------------
[Fact]
public async Task Delegated_member_exception_propagates()
{
var (mouse, _) = Fake.Of<IMouse>();
var (keyboard, _) = Fake.Of<IKeyboard>();
var (page, pageRec) = Fake.Of<IPage>();
pageRec.On("Mouse", mouse);
pageRec.On("Keyboard", keyboard);
pageRec.On("ContentAsync", _ => throw new PlaywrightException("closed"));
var human = new HumanizedPage(page, new HumanCursor(page), FastConfig());
await Assert.ThrowsAsync<PlaywrightException>(() => human.ContentAsync());
}
}