f709dba519
Add a preview release channel system to all wrappers (Python, JavaScript, .NET), allowing users to opt into newer binary builds before they go stable. - Python: preview/stable channel resolution in download, launch, CLI (install, update, clear-cache), and license validation - JavaScript: preview/stable channel in CLI, launch, license, and config - .NET: preview/stable channel in CLI, license, diagnostics, and config - All wrappers: channel fallback indicator, auto-update gate, CLOAKBROWSER_RELEASE_CHANNEL env var support - Harden channel resolution: reuse the resolution sidecar on offline lookups, defensive marker/JSON parsing, atomic version-marker writes, a launch-time preview-to-stable fallback notice, channel-aware info download URL, and Python/JS/.NET parity for the fallback default - Binary 150.0.7871.114.4 changelog (Linux x64 + arm64) - Updated READMEs, CHANGELOG, and tests for all wrappers
695 lines
33 KiB
Markdown
695 lines
33 KiB
Markdown
# 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 (71 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** (`login` / `logout` / `install` / `info` / `update` / `clear-cache`) | ✅ | `CloakBrowser.Cli/` |
|
||
|
||
---
|
||
|
||
## Requirements
|
||
|
||
| Dependency | Version | Why |
|
||
| --- | --- | --- |
|
||
| .NET SDK | **8.0** | target framework `net8.0` |
|
||
| `Microsoft.Playwright` | **1.49.0** | underlying browser automation |
|
||
| `MaxMind.GeoIP2` | **5.2.0** | GeoIP timezone/locale resolution |
|
||
|
||
---
|
||
|
||
## Installation
|
||
|
||
```bash
|
||
dotnet add package CloakBrowser
|
||
```
|
||
|
||
The patched Chromium binary downloads automatically on first launch, cached under
|
||
`~/.cloakbrowser` (override with `CLOAKBROWSER_CACHE_DIR`).
|
||
|
||
---
|
||
|
||
## Project layout
|
||
|
||
```
|
||
dotnet/
|
||
├── CloakBrowser.sln
|
||
├── src/
|
||
│ ├── CloakBrowser/ # the library
|
||
│ │ ├── Config.cs # <- cloakbrowser/config.py
|
||
│ │ ├── Download.cs # <- cloakbrowser/download.py (+ zip-slip guard)
|
||
│ │ ├── GeoIp.cs # <- cloakbrowser/geoip.py
|
||
│ │ ├── Widevine.cs # <- cloakbrowser/widevine.py
|
||
│ │ ├── CloakLauncher.cs # <- cloakbrowser/browser.py (launch funcs, build_args)
|
||
│ │ ├── ProxyResolver.cs # <- cloakbrowser/browser.py (proxy URL helpers)
|
||
│ │ ├── ProxySettings.cs # <- ProxySettings TypedDict
|
||
│ │ ├── LaunchOptions.cs # launch option records
|
||
│ │ ├── Handles.cs # CloakBrowserHandle / CloakContextHandle
|
||
│ │ ├── CloakLog.cs # logging facade
|
||
│ │ ├── Human/ # <- cloakbrowser/human/*
|
||
│ │ │ ├── HumanConfig.cs # <- human/config.py (HumanConfig + merge)
|
||
│ │ │ ├── HumanRandom.cs # <- human/config.py (rand/sleep helpers)
|
||
│ │ │ ├── HumanMouse.cs # <- human/mouse.py (Bezier engine)
|
||
│ │ │ ├── HumanKeyboard.cs # <- human/keyboard.py (typing + CDP stealth)
|
||
│ │ │ ├── HumanScroll.cs # <- human/scroll.py
|
||
│ │ │ ├── Actionability.cs # <- human/actionability.py
|
||
│ │ │ ├── IsolatedWorld.cs # <- _AsyncIsolatedWorld
|
||
│ │ │ ├── PlaywrightAdapters.cs # IMouse/IKeyboard/ICDPSession -> raw protocols
|
||
│ │ │ └── HumanPage.cs # <- patch_page flows (explicit engine)
|
||
│ │ └── Wrappers/ # transparent humanize decorators (Humanize=true)
|
||
│ │ ├── Humanize.cs # wrap entry points + helpers (idempotent)
|
||
│ │ ├── HumanCursor.cs # shared per-page cursor / CDP stealth state
|
||
│ │ ├── LocatorHumanizer.cs # locator-based humanize engine
|
||
│ │ ├── HumanizedBrowser.cs # IBrowser decorator
|
||
│ │ ├── HumanizedBrowserContext.cs # IBrowserContext decorator
|
||
│ │ ├── HumanizedPage.cs # IPage decorator
|
||
│ │ ├── HumanizedFrame.cs # IFrame decorator
|
||
│ │ ├── HumanizedLocator.cs # ILocator decorator
|
||
│ │ ├── HumanizedElementHandle.cs # IElementHandle decorator
|
||
│ │ ├── HumanizedMouse.cs # IMouse decorator
|
||
│ │ └── HumanizedKeyboard.cs # IKeyboard decorator
|
||
│ ├── CloakBrowser.Generators/ # Roslyn source generator
|
||
│ │ └── InterfaceDelegationGenerator.cs # emits delegating members
|
||
│ └── CloakBrowser.Cli/ # <- cloakbrowser/__main__.py
|
||
├── examples/CloakBrowser.Examples/ # runnable examples (see below)
|
||
└── tests/CloakBrowser.Tests/ # xUnit tests
|
||
```
|
||
|
||
---
|
||
|
||
## Quick start
|
||
|
||
```csharp
|
||
using CloakBrowser;
|
||
|
||
await using var browser = await CloakLauncher.LaunchAsync(new LaunchOptions
|
||
{
|
||
Headless = true,
|
||
});
|
||
|
||
var page = await browser.NewPageAsync();
|
||
await page.GotoAsync("https://bot.incolumitas.com/");
|
||
Console.WriteLine(await page.TitleAsync());
|
||
```
|
||
|
||
---
|
||
|
||
## Humanized interactions (transparent)
|
||
|
||
Pass `Humanize = true` and **write ordinary Playwright code** - mouse moves,
|
||
clicks, typing, and scrolling are humanized automatically. Nothing else in your
|
||
code changes:
|
||
|
||
```csharp
|
||
using CloakBrowser;
|
||
using CloakBrowser.Human;
|
||
|
||
await using var browser = await CloakLauncher.LaunchAsync(new LaunchOptions
|
||
{
|
||
Headless = false,
|
||
Humanize = true, // <- the only change
|
||
HumanPreset = HumanPreset.Careful, // optional: Default | Careful
|
||
HumanConfig = new Dictionary<string, object> { ["typing_delay"] = 90.0 }, // optional overrides
|
||
});
|
||
|
||
var page = await browser.NewPageAsync(); // returns a transparently-wrapped IPage
|
||
await page.GotoAsync("https://example.com/login");
|
||
|
||
await page.FillAsync("#username", "alice"); // per-character human typing
|
||
await page.FillAsync("#password", "s3cr3t!"); // (variable delays, thinking pauses, typos+fixes)
|
||
await page.ClickAsync("button[type=submit]"); // Bezier mouse curve to a realistic aim point
|
||
await page.Mouse.WheelAsync(0, 600); // accelerate -> cruise -> decelerate scroll
|
||
```
|
||
|
||
The wrapping is **complete and transitive** - anything you reach through the page
|
||
is wrapped too:
|
||
|
||
```csharp
|
||
await page.Mouse.MoveAsync(400, 300); // humanized
|
||
await page.Keyboard.TypeAsync("hello"); // humanized
|
||
var btn = page.Locator("#submit"); // wrapped ILocator
|
||
await btn.ClickAsync(); // humanized
|
||
foreach (var frame in page.Frames) { /* wrapped IFrame */ }
|
||
```
|
||
|
||
### What changes vs. plain Playwright
|
||
|
||
| Member | `Humanize = false` (default) | `Humanize = true` |
|
||
| --- | --- | --- |
|
||
| `IPage` / `ILocator` `ClickAsync`, `DblClickAsync`, `HoverAsync`, `TapAsync` | direct Playwright dispatch | Bezier-curve mouse move to a randomized in-element aim point, then click |
|
||
| `IPage` / `ILocator` `FillAsync`, `TypeAsync` / `PressSequentiallyAsync` | instant value set / fast type | per-character typing with variable delays, thinking pauses, occasional typos that self-correct |
|
||
| `IPage` / `ILocator` `PressAsync` | direct | human key timing; clicks to focus first only if not already focused |
|
||
| `ILocator` `CheckAsync`, `UncheckAsync`, `SetCheckedAsync`, `DragToAsync` | direct | humanized click / curved drag |
|
||
| `ILocator` `SelectOptionAsync` (all overloads) | instant native select | curved hover to the `<select>` + pause, then native select |
|
||
| `ILocator` `ClearAsync` | instant value reset | focus (humanized click if needed) + select-all + Backspace |
|
||
| `IMouse` `MoveAsync`, `ClickAsync`, `DblClickAsync`, `DownAsync`, `UpAsync` | direct | curved, eased motion with overshoot |
|
||
| `IMouse` `WheelAsync` | single event | accelerate-cruise-decelerate microsteps |
|
||
| `IKeyboard` `TypeAsync`, `PressAsync`, `InsertTextAsync` | direct | human-timed CDP key events |
|
||
| `page.Mouse`, `page.Keyboard`, `page.Locator(...)`, `page.GetBy*`, `page.Frames`, `QuerySelectorAsync`, `IBrowser.NewPageAsync/NewContextAsync`, `IBrowserContext.NewPageAsync` | raw Playwright objects | the same call returns a **wrapped** object so humanization stays transitive |
|
||
| every **other** member (navigation, waits, evaluation, screenshots, network, etc.) | - | **delegated verbatim** to Playwright (identical signatures, return types, exceptions, and `CancellationToken` support) |
|
||
| escape hatch | n/a | `((HumanizedPage)page).Original` (also `.Inner`) returns the raw, un-wrapped object; `browser.RawBrowser` on the handle |
|
||
|
||
Per-call overrides are honored - e.g.
|
||
`await page.ClickAsync("#x", new() { Force = true })` still works and is
|
||
humanized. `ElementHandle` interactions are humanized too, but prefer `ILocator`
|
||
(Playwright's recommendation) for the most reliable aiming.
|
||
|
||
### Calling the original Playwright methods (escape hatch)
|
||
|
||
When you want a specific call to skip humanization (for raw speed, or to bypass a
|
||
behavior you don't want for one action) every wrapper exposes the underlying,
|
||
un-humanized Playwright object via **`Original`** (with **`Inner`** as an alias).
|
||
This is the .NET equivalent of the `page._original` escape hatch in the Python/JS
|
||
clients - the object you get back is the genuine Playwright interface, so the
|
||
whole API is available on it:
|
||
|
||
```csharp
|
||
await using var browser = await CloakLauncher.LaunchAsync(new() { Humanize = true });
|
||
var page = await browser.NewPageAsync();
|
||
|
||
// Humanized (default): Bezier mouse curve, realistic aim point, etc.
|
||
await page.ClickAsync("#submit");
|
||
|
||
// Raw Playwright, no humanization - instant, via the escape hatch:
|
||
await ((HumanizedPage)page).Original.ClickAsync("#submit");
|
||
await ((HumanizedPage)page).Original.FillAsync("#token", value);
|
||
|
||
// Works on every wrapped object:
|
||
await ((HumanizedMouse)page.Mouse).Original.MoveAsync(10, 10);
|
||
var rawLocator = ((HumanizedLocator)page.Locator("#row")).Original;
|
||
```
|
||
|
||
The handle also exposes the un-wrapped browser/context directly:
|
||
`browser.RawBrowser` (and `RawContext` on a context handle).
|
||
|
||
> Note: when `Humanize = false`, `NewPageAsync()` already returns the raw
|
||
> Playwright `IPage`, so no escape hatch is needed.
|
||
|
||
### Explicit `HumanPage` engine (advanced)
|
||
|
||
If you want the explicit, non-transparent engine object instead of the
|
||
transparent layer, `NewHumanPageAsync` returns a `HumanPage`. Its raw Playwright
|
||
page is always available via `human.Page`.
|
||
|
||
```csharp
|
||
HumanPage human = await browser.NewHumanPageAsync();
|
||
await human.GotoAsync("https://example.com/login");
|
||
await human.FillAsync("#username", "alice");
|
||
await human.ClickAsync("button[type=submit]");
|
||
```
|
||
|
||
`HumanPage` methods (all take an optional `HumanActionOptions { Timeout, Force }`):
|
||
|
||
| Method | Description |
|
||
| --- | --- |
|
||
| `GotoAsync(url, options?)` | navigate (delegates to the raw page) |
|
||
| `ClickAsync(selector)` | Bezier move + human click |
|
||
| `DblClickAsync(selector)` | move + double click |
|
||
| `HoverAsync(selector)` | curved move, no click |
|
||
| `TapAsync(selector)` | same motion as click |
|
||
| `TypeAsync(selector, text)` | focus + per-character typing |
|
||
| `FillAsync(selector, value)` | click + select-all + clear + type |
|
||
| `PressAsync(selector, key)` | focus-aware single key press |
|
||
| `PressSequentiallyAsync(selector, text)` | focus + per-character typing |
|
||
| `ClearAsync(selector)` | focus + select-all + Backspace |
|
||
| `CheckAsync` / `UncheckAsync` / `SetCheckedAsync(selector, state)` | humanized toggle via click |
|
||
| `SelectOptionAsync(selector, values)` | curved hover + native select |
|
||
| `FocusAsync(selector)` | curved move + **programmatic** focus (no click side-effects) |
|
||
| `ScrollIntoViewIfNeededAsync(selector)` | humanized accelerate->cruise->decelerate->overshoot scroll |
|
||
| `DragAndDropAsync(src, dst)` | curved press-drag-release |
|
||
| `MouseMoveAsync(x, y)` / `MouseClickAsync(x, y)` | low-level curved motion |
|
||
| `KeyboardTypeAsync(text)` | low-level human typing at the focused element |
|
||
|
||
---
|
||
|
||
## Launch API reference
|
||
|
||
All entry points live on the static `CloakLauncher` class and return an
|
||
`await using`-friendly handle.
|
||
|
||
| Method | Returns | Notes |
|
||
| --- | --- | --- |
|
||
| `LaunchAsync(LaunchOptions)` | `CloakBrowserHandle` | launches a browser; `NewPageAsync`, `NewContextAsync`, `NewHumanPageAsync`, `RawBrowser` |
|
||
| `LaunchContextAsync(LaunchContextOptions)` | `CloakContextHandle` | browser-owned context with emulation |
|
||
| `LaunchPersistentContextAsync(userDataDir, LaunchContextOptions)` | `CloakContextHandle` | reuse a profile directory (cookies/localStorage persist) |
|
||
|
||
```csharp
|
||
// emulated context
|
||
await using var ctx = await CloakLauncher.LaunchContextAsync(new LaunchContextOptions
|
||
{
|
||
Locale = "en-US",
|
||
Timezone = "America/New_York",
|
||
Viewport = (1280, 800),
|
||
ColorScheme = "dark",
|
||
});
|
||
var page = await ctx.NewPageAsync();
|
||
```
|
||
|
||
> Locale and timezone are applied via **binary flags** (`--lang`,
|
||
> `--fingerprint-locale`, `--fingerprint-timezone`) - *not* detectable CDP
|
||
> emulation - matching the Python wrapper.
|
||
|
||
```csharp
|
||
// persistent profile - cookies / localStorage survive across runs, so the
|
||
// browser looks like a returning user instead of a fresh incognito session
|
||
await using var ctx = await CloakLauncher.LaunchPersistentContextAsync("./profile",
|
||
new LaunchContextOptions { Headless = false });
|
||
var page = await ctx.NewPageAsync();
|
||
```
|
||
|
||
---
|
||
|
||
## Options reference
|
||
|
||
`LaunchOptions` / `LaunchContextOptions` (commonly used fields):
|
||
|
||
| Option | Type | Default | Purpose |
|
||
| --- | --- | --- | --- |
|
||
| `Headless` | `bool` | `true` | run headless (detectors often flag this) |
|
||
| `Humanize` | `bool` | `false` | enable the transparent humanize layer |
|
||
| `HumanPreset` | `HumanPreset` | `Default` | `Default` or `Careful` (slower, more cautious) |
|
||
| `HumanConfig` | `Dictionary<string,object>` | `null` | per-field overrides (snake_case **or** PascalCase keys) |
|
||
| `Proxy` | `string` / `ProxySettings` | `null` | HTTP/HTTPS or SOCKS5 proxy |
|
||
| `GeoIp` | `bool` | `false` | resolve timezone/locale from the proxy exit IP |
|
||
| `Args` | `List<string>` | `[]` | extra Chromium flags (e.g. `--fingerprint-webrtc-ip=auto`) |
|
||
| `Locale` | `string` | `null` | BCP 47 locale -> `--lang`, `--fingerprint-locale` |
|
||
| `Timezone` | `string` | `null` | IANA timezone -> `--fingerprint-timezone` |
|
||
| `Viewport` | `(int,int)` | - | window size |
|
||
| `NoViewport` | `bool` | `false` | disable viewport emulation (track the real window) |
|
||
| `ColorScheme` | `string` | - | `light` / `dark` |
|
||
| `LicenseKey` | `string` | `null` | CloakBrowser Pro key (or `CLOAKBROWSER_LICENSE_KEY` env / `~/.cloakbrowser/license.key`) |
|
||
| `BrowserVersion` | `string` | `null` | Pin an exact Chromium version (e.g. `"148.0.7778.215.2"`). Also reads from `CLOAKBROWSER_VERSION` env var. Works with Free and Pro. |
|
||
| `ReleaseChannel` | `string` | `null` (`stable`) | Set to `"preview"` to opt into the Pro Preview binary channel. Also reads from `CLOAKBROWSER_RELEASE_CHANNEL`. |
|
||
|
||
### CloakBrowser Pro
|
||
|
||
The wrappers are MIT, free forever. The latest binary is **free to try**:
|
||
|
||
- **Free, latest build (Chromium 150)** — the newest binary. Free with a GitHub sign-in, one concurrent session. Run `cloakbrowser login` or [grab your key](https://cloakbrowser.dev/free).
|
||
- **Pro** — scale to **5, 20, 200, 2,000, or more concurrent sessions**, always first on the newest patches, with hands-on support. Linux, Windows, macOS. **[See plans and pricing →](https://cloakbrowser.dev)**
|
||
- **v146** — the older build stays free on [GitHub Releases](https://github.com/CloakHQ/cloakbrowser/releases). A quick first look, but it ages fast as detection evolves.
|
||
|
||
Try the latest free → **[cloakbrowser.dev/free](https://cloakbrowser.dev/free)** · Scale up on Pro → **[cloakbrowser.dev](https://cloakbrowser.dev)**
|
||
|
||
Get a key: run `cloakbrowser login` (GitHub sign-in for a free key, or paste a paid key), or set it directly via the `LicenseKey` option, the `CLOAKBROWSER_LICENSE_KEY` env var, or `~/.cloakbrowser/license.key`:
|
||
|
||
```csharp
|
||
await using var browser = await CloakLauncher.LaunchAsync(new LaunchOptions
|
||
{
|
||
LicenseKey = "cb_xxxxxxxx", // or via CLOAKBROWSER_LICENSE_KEY / ~/.cloakbrowser/license.key
|
||
});
|
||
```
|
||
|
||
A valid key downloads the latest Pro binary from cloakbrowser.dev; without one, the
|
||
free binary downloads from GitHub Releases. Validation is cached locally for 24h, and
|
||
the Pro binary is authenticated with the **same pinned Ed25519 signature** as the free
|
||
binary — a key whose Pro download or signature check fails surfaces a clear error
|
||
rather than silently downgrading. `Download.BinaryInfo()` exposes a `Tier` field
|
||
(`"pro"` / `"free"`); `License.ValidateLicense` / `License.LicenseInfo` mirror the
|
||
Python `validate_license` / `LicenseInfo` exports.
|
||
|
||
### Preview release channel
|
||
|
||
Stable is the default. Opt into Preview per launch:
|
||
|
||
```csharp
|
||
await using var browser = await CloakLauncher.LaunchAsync(new LaunchOptions
|
||
{
|
||
LicenseKey = "cb_xxxxxxxx",
|
||
ReleaseChannel = "preview",
|
||
});
|
||
```
|
||
|
||
Or set `CLOAKBROWSER_RELEASE_CHANNEL=preview` for all launches and CLI commands. Preview selects the newest build available for this platform: a newer Preview when present, otherwise Stable. The `info` command reports the resolved channel and exact version. An exact `BrowserVersion` pin overrides the channel.
|
||
|
||
### Rolling back to a previous binary version
|
||
|
||
Pin an exact Chromium version without downgrading the wrapper — works for Free and Pro:
|
||
|
||
```csharp
|
||
// Free — pin a public release
|
||
await using var browser = await CloakLauncher.LaunchAsync(new LaunchOptions
|
||
{
|
||
BrowserVersion = "146.0.7680.177.5",
|
||
});
|
||
|
||
// Pro — pin a previous Pro version
|
||
await using var browser = await CloakLauncher.LaunchAsync(new LaunchOptions
|
||
{
|
||
LicenseKey = "cb_xxxxxxxx",
|
||
BrowserVersion = "148.0.7778.215.2",
|
||
});
|
||
```
|
||
|
||
The pin is **never sticky** — unpinned launches always use the latest available version.
|
||
You can also set it globally via the `CLOAKBROWSER_VERSION` env var (see [Environment variables](#environment-variables)).
|
||
|
||
---
|
||
|
||
## `HumanConfig` reference
|
||
|
||
Every behavior is tunable. Names match the Python `HumanConfig` dataclass; the
|
||
override dictionary accepts both `snake_case` (Python parity) and `PascalCase`
|
||
keys. Ranges are `(min, max)` and may be passed as a `(double,double)` tuple or a
|
||
2-element array.
|
||
|
||
| Group | Field (PascalCase) | Default | Meaning |
|
||
| --- | --- | --- | --- |
|
||
| Keyboard | `TypingDelay` | `70` | base inter-key delay (ms) |
|
||
| | `TypingDelaySpread` | `40` | ± jitter around `TypingDelay` |
|
||
| | `TypingPauseChance` | `0.1` | chance of a longer "thinking" pause |
|
||
| | `TypingPauseRange` | `(400, 1000)` | thinking-pause duration (ms) |
|
||
| | `ShiftDownDelay` / `ShiftUpDelay` | `(30,70)` / `(20,50)` | shift key timing |
|
||
| | `KeyHold` | `(15, 35)` | key-down hold time (ms) |
|
||
| Mistype | `MistypeChance` | `0.02` | chance of a fat-finger typo (ASCII alnum only) |
|
||
| | `MistypeDelayNotice` | `(100, 300)` | pause before noticing the typo |
|
||
| | `MistypeDelayCorrect` | `(50, 150)` | pause after correcting |
|
||
| | `FieldSwitchDelay` | `(800, 1500)` | delay when moving between fields |
|
||
| Mouse - move | `MouseStepsDivisor` | `8` | distance ÷ divisor = step count |
|
||
| | `MouseMinSteps` / `MouseMaxSteps` | `25` / `80` | clamp on step count |
|
||
| | `MouseWobbleMax` | `1.5` | perpendicular wobble amplitude (px) |
|
||
| | `MouseOvershootChance` | `0.15` | chance of overshoot + correction |
|
||
| | `MouseOvershootPx` | `(3, 6)` | overshoot distance |
|
||
| | `MouseBurstSize` / `MouseBurstPause` | `(3,5)` / `(8,18)` | move-burst grouping + pause |
|
||
| Mouse - click | `ClickAimDelayInput` / `...Button` | `(60,140)` / `(80,200)` | aim delay before pressing |
|
||
| | `ClickHoldInput` / `...Button` | `(40,100)` / `(60,150)` | mouse-down hold time |
|
||
| | `ClickInputXRange` | `(0.05, 0.30)` | left-biased X target inside inputs |
|
||
| Mouse - idle | `IdleDriftPx` | `3` | idle drift amplitude |
|
||
| | `IdlePauseRange` | `(300, 1000)` | idle pause between drifts |
|
||
| Scroll | `ScrollDeltaBase` | `(80, 130)` | wheel delta per microstep |
|
||
| | `ScrollOvershootChance` | `0.1` | chance to overshoot then settle |
|
||
| | `ScrollSettleDelay` | `(300, 600)` | pause after reaching the target |
|
||
| | `ScrollTargetZone` | `(0.20, 0.80)` | viewport band the element lands in |
|
||
| Cursor | `InitialCursorX` / `...Y` | `(400,700)` / `(45,60)` | starting cursor position (address-bar area) |
|
||
| Idle between actions | `IdleBetweenActions` | `false` | opt-in micro-movements between actions (adds latency) |
|
||
|
||
Resolve / merge helpers:
|
||
|
||
```csharp
|
||
// preset + overrides
|
||
var cfg = HumanConfigFactory.Resolve(
|
||
HumanPreset.Careful,
|
||
new Dictionary<string, object> { ["typing_delay"] = 120.0, ["key_hold"] = (40.0, 90.0) });
|
||
|
||
// merge onto an existing config (never mutates the base; returns a new instance)
|
||
var faster = cfg.With(new Dictionary<string, object> { ["TypingDelay"] = 30.0 });
|
||
```
|
||
|
||
`With(...)` is **forgiving**: unknown keys are ignored silently, `null`/empty
|
||
overrides return a clone, and non-overridden fields are preserved.
|
||
|
||
---
|
||
|
||
## How the humanize layer works
|
||
|
||
### Mouse motion (Bezier)
|
||
|
||
`HumanMouse.HumanMoveAsync` traces a **cubic Bezier curve** between the current
|
||
cursor position and a randomized aim point inside the target's bounding box:
|
||
|
||
1. Step count scales with distance (`dist / MouseStepsDivisor`, clamped to
|
||
`[MouseMinSteps, MouseMaxSteps]`).
|
||
2. Two control points are biased **perpendicular** to the straight path, so the
|
||
curve is never a straight line.
|
||
3. Each step adds **sinusoidal wobble** (max amplitude `MouseWobbleMax`).
|
||
4. Motion is **eased** (cubic ease-in-out) and grouped into bursts with short
|
||
pauses (`MouseBurstSize` / `MouseBurstPause`).
|
||
5. With probability `MouseOvershootChance`, the cursor overshoots the target and
|
||
corrects back - exactly like a real hand.
|
||
|
||
Click points are computed by `HumanMouse.ClickTarget`: inputs get a left-biased X
|
||
and wide Y band; buttons get a centered cluster.
|
||
|
||
### Human typing & the CDP stealth path
|
||
|
||
`HumanKeyboard.HumanTypeAsync` types one character at a time with variable delays
|
||
and occasional "thinking" pauses. ASCII alphanumeric characters can trigger a
|
||
**fat-finger typo** (a nearby QWERTY key) that is then noticed and corrected with
|
||
Backspace.
|
||
|
||
Shift symbols (`@ # ! $ ...`) are the tricky case for stealth. When a **CDP
|
||
session** is available, they are dispatched through
|
||
`Input.dispatchKeyEvent` - producing `isTrusted = true` events with **no
|
||
`evaluate` stack trace** for detectors to find. Without CDP, it falls back to a
|
||
(detectable) `page.evaluate` path.
|
||
|
||
### Non-ASCII input
|
||
|
||
Cyrillic, CJK, emoji and other non-ASCII characters cannot be produced with
|
||
physical key codes, so they are inserted via **`InsertText`**, one character at a
|
||
time, while ASCII characters keep going through `Down`/`Up` key presses. For a
|
||
mixed string like `"Hi Мир"`:
|
||
|
||
| Char | Path |
|
||
| --- | --- |
|
||
| `H`, `i`, space | `DownAsync` / `UpAsync` (with Shift for `H`) |
|
||
| `М`, `и`, `р` | `InsertTextAsync` (per character) |
|
||
|
||
### Scrolling
|
||
|
||
`HumanScroll` performs an **accelerate -> cruise -> decelerate** wheel sequence in
|
||
microsteps, with an optional overshoot-and-settle, landing the element in a
|
||
natural viewport band (`ScrollTargetZone`).
|
||
|
||
### Actionability checks
|
||
|
||
Before interacting, the humanize layer runs Playwright-style **actionability
|
||
checks** (`Actionability.cs`), with a retry/backoff loop `[100, 250, 500, 1000]`
|
||
ms:
|
||
|
||
| Check | Meaning | Error on failure |
|
||
| --- | --- | --- |
|
||
| `attached` | element exists in the DOM | `ElementNotAttachedError` |
|
||
| `visible` | element is visible | `ElementNotVisibleError` |
|
||
| `stable` | bounding box stopped moving (post-scroll) | `ElementNotStableError` |
|
||
| `enabled` | element is enabled | `ElementNotEnabledError` |
|
||
| `editable` | element is editable | `ElementNotEditableError` |
|
||
| `pointer_events` | the click point actually hits the element | `ElementNotReceivingEventsError` |
|
||
|
||
Action presets: `ChecksClick`, `ChecksHover`, `ChecksInput`, `ChecksFocus`,
|
||
`ChecksCheck`.
|
||
|
||
**Fail-open pointer check.** The `pointer_events` probe uses
|
||
`document.elementFromPoint`. If it *cannot run* (stale handle, execution context
|
||
destroyed - `EvaluateAsync` throws), the check **fails open** and returns
|
||
promptly rather than blocking until timeout - failing closed would wrongly block
|
||
legitimate clicks. But an explicit `{ hit: false }` (the element is genuinely
|
||
*covered*) still raises `ElementNotReceivingEventsError`.
|
||
|
||
### Shared timeout budget (#307)
|
||
|
||
All sequential steps of one action share a **single deadline** rather than each
|
||
restarting the full timeout. The helper
|
||
`Actionability.RemainingMs(deadline)` returns the milliseconds left (clamped at
|
||
zero, never negative). Because every step subtracts from the *same* budget, the
|
||
total wall-clock time can never multiply across steps - the bug fixed in
|
||
upstream issue **#307**.
|
||
|
||
### Focus-aware press / clear
|
||
|
||
`PressAsync` and `ClearAsync` first probe focus with
|
||
`EvaluateAsync<bool>("el => el === document.activeElement")`. If the element is
|
||
**already focused**, they **skip the humanized click** entirely (the cursor does
|
||
not move) and go straight to the keystrokes; otherwise they perform a humanized
|
||
focus-click first. This avoids pointless mouse motion on an already-focused
|
||
field.
|
||
|
||
---
|
||
|
||
## Proxy, GeoIP & WebRTC
|
||
|
||
```csharp
|
||
await using var browser = await CloakLauncher.LaunchAsync(new LaunchOptions
|
||
{
|
||
Proxy = "http://user:pass@proxy.example.com:8080", // or a ProxySettings
|
||
GeoIp = true, // tz/locale from exit IP
|
||
Args = new List<string> { "--fingerprint-webrtc-ip=auto" },
|
||
});
|
||
```
|
||
|
||
- **SOCKS5** and **credentialed HTTP** proxies are routed through Chrome's
|
||
`--proxy-server` with inline, URL-encoded credentials (matching the Python
|
||
logic, including the `linux-x64` / `windows-x64` + binary-version gate for HTTP
|
||
inline auth).
|
||
- **GeoIP** looks up the proxy exit IP against MaxMind GeoLite2 and applies the
|
||
resolved timezone/locale via binary flags.
|
||
- **WebRTC** spoofing reuses that exit IP so `RTCPeerConnection` cannot leak the
|
||
real address.
|
||
|
||
---
|
||
|
||
## CLI
|
||
|
||
Installed via NuGet you don't need a CLI — the binary auto-downloads on first launch
|
||
and updates in the background, and `Download.BinaryInfo()` reports the installed
|
||
version, path, and tier (`Download.EnsureBinary()` pre-fetches it on demand).
|
||
|
||
When running from a clone of the repo, a small CLI does the same:
|
||
|
||
```bash
|
||
dotnet run --project src/CloakBrowser.Cli -- install # download the binary
|
||
dotnet run --project src/CloakBrowser.Cli -- info # version / path / platform
|
||
dotnet run --project src/CloakBrowser.Cli -- update # check + download newer
|
||
dotnet run --project src/CloakBrowser.Cli -- clear-cache # remove cached binaries
|
||
```
|
||
|
||
---
|
||
|
||
## Environment variables
|
||
|
||
Same set as the Python wrapper:
|
||
|
||
| Variable | Purpose |
|
||
| --- | --- |
|
||
| `CLOAKBROWSER_BINARY_PATH` | Use a local binary, skip download |
|
||
| `CLOAKBROWSER_CACHE_DIR` | Override the cache directory |
|
||
| `CLOAKBROWSER_DOWNLOAD_URL` | Override the download URL |
|
||
| `CLOAKBROWSER_AUTO_UPDATE` | Enable/disable background auto-update |
|
||
| `CLOAKBROWSER_SKIP_CHECKSUM` | Skip SHA-256 verification |
|
||
| `CLOAKBROWSER_VERSION` | Pin to an exact Chromium version for rollback (e.g. `148.0.7778.215.2`). Works with Free and Pro binaries |
|
||
| `CLOAKBROWSER_RELEASE_CHANNEL` | Set to `preview` to opt into the Pro Preview binary channel (default: `stable`) |
|
||
| `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.
|