feat: add preview release channel across all wrappers

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
This commit is contained in:
CloakHQ committed 2026-07-24 11:47:28 +02:00
1 parent c6673e3eda
commit f709dba519
41 files changed
+2355 -304

No files matched your search

+12 -1
View File
@@ -6,6 +6,17 @@ Changes are tagged: **[wrapper]** for Python/JS wrapper, **[binary]** for Chromi
---
## [Unreleased]
- **[wrapper]** Preview now means the newest build available for the current platform. A newer Preview is selected when present; otherwise it resolves to Stable, including when Stable has moved ahead. Python, JavaScript, and .NET CLI diagnostics report the requested and resolved channel plus the exact version that will launch.
- **[binary]** Chromium **150.0.7871.114.4** (Pro, Linux x64 + arm64) — 73 source-level patches (up from 71). A coherence release. Pro license required; v146 stays free.
- **Consistent identity under CDP user-agent overrides** — subframes no longer disagree with the top frame about the browser identity when a custom user agent is set over CDP. Mainly affects Puppeteer, which sets one on every page session.
- **Closer Windows persona text rendering** — font availability and text measurements track a real Windows install more closely.
- **Complete locale coverage** — every supported locale now resolves to a coherent profile, with Greek (`el-GR`, `el-CY`) added.
- **Headed window geometry coherence** on the Windows and macOS personas; headless is unchanged.
- **More stable results across seeds** when running natively on macOS and Linux.
- No unsupported-flag warning bar when the sandbox is disabled, as required when running as root (e.g. in Docker).
## [0.5.1] — 2026-07-23
- **[wrapper]** Fix GeoIP exit-IP resolution through authenticated HTTP proxies in the JavaScript wrapper. The CONNECT request sent the proxy's address in the `Host` header instead of the tunnel target, which strict proxies (e.g. Oxylabs backconnect) reject; the wrapper then silently fell back to the proxy gateway's location, producing a wrong timezone/locale for the session's real exit IP. The `Host` header now names the tunnel target per RFC 9110. JavaScript only; Python and .NET were unaffected.
@@ -102,7 +113,7 @@ Changes are tagged: **[wrapper]** for Python/JS wrapper, **[binary]** for Chromi
## [0.4.0] — 2026-06-22
- **[wrapper]** **CloakBrowser Pro**: all launch functions now accept a `license_key` parameter (`licenseKey` in JS); a key can also be supplied via the `CLOAKBROWSER_LICENSE_KEY` environment variable or a `~/.cloakbrowser/license.key` file. With a valid key the latest binary is downloaded from cloakbrowser.dev; without one, the free binary continues to download from GitHub Releases exactly as before. License validation is cached locally for 24h, and the Pro binary is authenticated with the same pinned Ed25519 signature as the free binary. A valid key whose Pro download or signature check fails surfaces a clear error rather than silently downgrading to the free binary. Adds `validate_license`/`LicenseInfo` exports and a `tier` field on `binary_info()`. Details: https://cloakbrowser.dev
- **[wrapper]** **CloakBrowser Pro**: all launch functions now accept a `license_key` parameter (`licenseKey` in JS); a key can also be supplied via the `CLOAKBROWSER_LICENSE_KEY` environment variable or a `~/.cloakbrowser/license.key` file. With a valid key the latest binary is downloaded from cloakbrowser.dev; without one, the free binary continues to download from GitHub Releases exactly as before. License validation is cached locally for 24h, and the Pro binary is authenticated with the same pinned Ed25519 signature as the free binary. A valid key whose Pro download or signature check fails surfaces a clear error rather than silently downgrading to the free binary. Adds `validate_license`/`LicenseInfo` exports and a `tier` field on `binary_info()`. Details: <https://cloakbrowser.dev>
- **[wrapper]** **Security**: downloaded binaries are now verified against a pinned Ed25519 signature on the published `SHA256SUMS` (a detached `SHA256SUMS.sig`), so a compromised download mirror can no longer certify a tampered binary — the previous same-origin checksum proved integrity but not authenticity (#308). The signed manifest also binds the release version, rejecting a forced downgrade to an older signed build. Verification is mandatory on the official download path; silent auto-update is preserved for everyone because only a constant public key is pinned, not per-version hashes. Older installed wrappers are unaffected.
- **[wrapper]** Headed launches no longer apply a fixed emulated viewport on top of the real browser window — the page now tracks the actual window so window-geometry stays self-consistent. Headless keeps a deterministic viewport (unchanged). Applies across `launch`, `launch_context`, `launch_persistent_context` (+ async) and the JS Playwright/Puppeteer wrappers. Passing an explicit `viewport=`/`no_viewport` (Python) or `viewport`/`defaultViewport` (JS) still works exactly as before.
- **[wrapper]** **Breaking**: removed the optional `patchright` backend. The `backend` parameter and `CLOAKBROWSER_BACKEND` environment variable no longer exist, and the `cloakbrowser[patchright]` extra is gone. Stock Playwright is now the only backend. The stealth binary handles automation-signal suppression at the C++ level — patchright added no measurable benefit on top of it (identical reCAPTCHA v3 score to plain Playwright) while breaking proxy auth and `add_init_script` (#27). Callers passing `backend=...` will get a `TypeError`; remove the argument.
+37 -2
View File
@@ -482,7 +482,13 @@ python -m cloakbrowser update # Check for and download newer binary
python -m cloakbrowser clear-cache # Remove cached binaries
```
`login` with no argument prompts you to paste a license key or press Enter to get a free key via a GitHub sign-in; `login <key>` saves a key directly. Both validate the key, then store it at `~/.cloakbrowser/license.key` so every launch picks it up. `info` reports the binary that will actually launch given your license, runs a quick launch test (and flags missing system libraries on Linux), shows your license tier, and checks fonts, GeoIP, and optional dependencies. Add `--quick` to skip the launch test or `--json` for machine-readable output. The same commands are available via `npx cloakbrowser <command>` (JS) and the `cloakbrowser` CLI (.NET).
`login` with no argument prompts you to paste a license key or press Enter to get a free key via a GitHub sign-in; `login <key>` saves a key directly. Both validate the key, then store it at `~/.cloakbrowser/license.key` so every launch picks it up.
`info` reports the binary that will actually launch given your license, runs a quick launch test (and flags missing system libraries on Linux), shows your license tier, and checks fonts, GeoIP, and optional dependencies. Add `--quick` to skip the launch test or `--json` for machine-readable output.
`CLOAKBROWSER_RELEASE_CHANNEL=preview` also applies to `install`, `info`, and `update`. `info` shows the exact version that will launch and whether Preview resolved to Stable for the current platform.
The same commands are available via `npx cloakbrowser <command>` (JS) and the `cloakbrowser` CLI (.NET).
### Utility Functions
@@ -663,6 +669,7 @@ Access the original un-patched Playwright page at `page._original` if you need r
| `CLOAKBROWSER_WIDEVINE` | `1` | Set to `0` to disable automatic Widevine hint-file seeding for persistent contexts |
| `CLOAKBROWSER_FETCH_WIDEVINE` | `0` | Docker only: set to `1` to auto-fetch the Widevine CDM on container start (Linux x86-64 only). See [Widevine / DRM](#widevine--drm) |
| `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` | `stable` | Set to `preview` to opt into the Preview binary channel |
## Fingerprint Management
@@ -728,7 +735,7 @@ Supported by the binary but **not set by default** — pass via `args` to custom
| `--fingerprint=off` | **Chromium 148+ binary only.** Pass-through debug mode — turns spoofing off and presents the machine's **real native fingerprint** (keeps only the baseline any Chrome needs). The binary strips the injected seed *and* `--fingerprint-platform`, so there's no mixed OS profile. Most useful on a genuine Windows machine to check whether an issue is our spoofing or the environment. Accepts `off`/`false`/`0`/`disable`/`disabled`. For a *pure* pass-through don't combine it with `geoip=True` / explicit timezone / locale — those stay applied. |
| `--fingerprint-allow-3p-cookies` | **Chromium 148+ binary only.** Re-enable third-party cookies for embedded flows that need them (reCAPTCHA v3, SSO, some payment challenges). Off by default; turn on only where a login/payment/embedded challenge loads but never finishes. |
| `--fingerprint-sapi-voices=false` | **Chromium 150+ binary only.** Opt out of the Windows speech-voice tables when spoofing Windows. On by default (the voice set matches a real Chrome install); turn off only if a target reacts badly to the Windows voice list. |
| `--license-through-proxy` | **Chromium 148+ binary only, Linux only for now.** Route the Pro license/session calls through your `--proxy-server` instead of direct to cloakbrowser.dev. Off by default (these calls go direct, so they never spend proxy bandwidth or touch your scraping session). |
| `--license-through-proxy` | **Chromium 148+ binary only (all platforms).** Route the Pro license/session calls through your `--proxy-server` instead of direct to cloakbrowser.dev. Off by default (these calls go direct, so they never spend proxy bandwidth or touch your scraping session). |
| `--enable-blink-features=FakeShadowRoot` | Access closed shadow DOM elements |
> **Note:** All stealth tests were verified with the default fingerprint config above. Changing these flags may affect detection results — test your configuration before using in production.
@@ -1205,6 +1212,34 @@ npm install cloakbrowser@latest # JavaScript
docker pull cloakhq/cloakbrowser:latest # Docker
```
### Preview release channel
Stable is the default. Opt into Preview per launch:
```python
browser = launch(license_key="cb_xxxxxxxx", release_channel="preview")
```
```javascript
const browser = await launch({ licenseKey: 'cb_xxxxxxxx', releaseChannel: 'preview' });
```
```csharp
await using var browser = await CloakLauncher.LaunchAsync(new LaunchOptions
{
LicenseKey = "cb_xxxxxxxx",
ReleaseChannel = "preview",
});
```
Or enable Preview for all wrappers and CLI commands:
```bash
export CLOAKBROWSER_RELEASE_CHANNEL=preview
```
Preview availability is platform-specific. Preview always selects the newest build available for your platform: a newer Preview when one exists, otherwise Stable (including when Stable is newer). `cloakbrowser info` shows `Preview → Stable fallback` when that happens. An exact version pin overrides the release channel.
### New update broke something? Roll back
Two ways to go back to a working version:
+52 -10
View File
@@ -120,10 +120,12 @@ def _effective_binary(entitled_pro: bool, quick: bool = False) -> dict:
"""
from .config import (
CHROMIUM_VERSION,
_version_newer,
get_binary_dir,
get_binary_path,
get_effective_version,
get_local_binary_override,
normalize_release_channel,
normalize_requested_version,
)
@@ -142,31 +144,55 @@ def _effective_binary(entitled_pro: bool, quick: bool = False) -> dict:
}
requested = normalize_requested_version(None)
requested_channel = normalize_release_channel(None)
# For a Pro license, surface the server's latest separately from the version
# that will actually launch, so `info` can never silently diverge from launch
# (the divergence a customer hit: info showed latest, launch ran a stale cache).
# --quick keeps `info` fully network-free (skip the server latest lookup).
latest_version = None
resolved_channel = None
channel_fallback = False
if entitled_pro and not quick:
from .license import get_pro_latest_version
from .license import get_pro_latest_release
latest_version = get_pro_latest_version()
latest_release = get_pro_latest_release(requested_channel)
if latest_release:
latest_version = latest_release.version
resolved_channel = latest_release.resolved_channel
channel_fallback = latest_release.fallback
installed_version = None
if requested:
version = requested
elif entitled_pro:
# "Will launch now" is the cached Pro build; if none is cached, the next
# launch downloads latest_version. get_effective_version(pro=True) returns
# None (never the free base) when nothing is cached.
version = get_effective_version(pro=True) or latest_version
# Mirror ensure_binary: Preview/Stable resolution picks what the next
# launch will run, while AUTO_UPDATE=false keeps an installed channel build.
installed_version = get_effective_version(
pro=True, release_channel=requested_channel
)
auto_update = os.environ.get("CLOAKBROWSER_AUTO_UPDATE", "true").lower()
updates_enabled = auto_update != "false"
if installed_version and not updates_enabled:
version = installed_version
elif latest_version and (
not installed_version or _version_newer(latest_version, installed_version)
):
version = latest_version
else:
version = installed_version or latest_version
else:
version = get_effective_version()
installed_version = version
path = get_binary_path(version, pro=entitled_pro) if version else None
return {
"version": version,
"latest_version": latest_version,
"requested_channel": requested_channel,
"resolved_channel": resolved_channel,
"channel_fallback": channel_fallback,
"installed_version": installed_version,
"pinned": bool(requested),
"tier": "pro" if entitled_pro else "free",
"bundled_version": CHROMIUM_VERSION,
@@ -282,20 +308,33 @@ def _print_diagnostics(diag: dict) -> None:
if binary["tier"] == "override":
print("Version: set via CLOAKBROWSER_BINARY_PATH (see Launch line)")
else:
requested_channel = binary.get("requested_channel", "stable")
resolved_channel = binary.get("resolved_channel")
if binary.get("channel_fallback"):
print("Channel: Preview → Stable fallback")
elif resolved_channel:
print(f"Channel: {resolved_channel.capitalize()}")
else:
print(f"Channel: {requested_channel.capitalize()}")
latest = binary.get("latest_version")
if latest:
# Pro: show what launches now AND the server's latest, so the two
# can never silently diverge.
print(f"Version: {binary['version']} ({binary['tier']}) — will launch")
if latest == binary["version"]:
print(f"Version: {binary['version']} ({binary['tier']}) — next launch")
if latest == binary["version"] and binary.get("installed"):
print(f"Latest: {latest} (up to date)")
elif latest == binary["version"]:
print(f"Latest: {latest} (downloads on next launch)")
elif binary.get("pinned"):
print(
f"Latest: {latest} (available — pinned; unset "
"CLOAKBROWSER_VERSION to upgrade)"
)
else:
print(f"Latest: {latest} (available — next launch upgrades)")
print(f"Latest: {latest} (server-resolved; installed build retained)")
installed_version = binary.get("installed_version")
if installed_version and installed_version != binary["version"]:
print(f"Installed: {installed_version}")
elif binary["version"] is None:
# Pro with no cached build and no server answer (e.g. offline).
print(
@@ -394,7 +433,10 @@ def cmd_update(args: argparse.Namespace) -> None:
if entitled_pro:
from .license import resolve_license_key
new_version = check_for_pro_update(resolve_license_key(None))
key = resolve_license_key(None)
if not key:
raise RuntimeError("Pro license key disappeared during update")
new_version = check_for_pro_update(key)
label = "Pro Chromium"
else:
new_version = check_for_update()
+59 -38
View File
@@ -178,6 +178,7 @@ def launch(
extension_paths: list[str] | None = None,
license_key: str | None = None,
browser_version: str | None = None,
release_channel: str | None = None,
_suppress_maximize: bool = False,
**kwargs: Any,
) -> Any:
@@ -219,13 +220,13 @@ def launch(
from playwright.sync_api import sync_playwright
binary_path = ensure_binary(license_key=license_key, browser_version=browser_version)
binary_path = ensure_binary(license_key=license_key, browser_version=browser_version, release_channel=release_channel)
timezone, locale, exit_ip = maybe_resolve_geoip(geoip, proxy, timezone, locale, args)
proxy_kwargs, proxy_extra_args = _resolve_proxy_config(proxy, browser_version, license_key)
proxy_kwargs, proxy_extra_args = _resolve_proxy_config(proxy, browser_version, license_key, release_channel)
args = _resolve_webrtc_args(args, proxy)
args = _append_webrtc_exit_ip(args, exit_ip)
chrome_args = build_args(stealth_args, (args or []) + proxy_extra_args, timezone=timezone, locale=locale, headless=headless, extension_paths=extension_paths, start_maximized=binary_supports_maximized_window(license_key, browser_version) and not _suppress_maximize)
chrome_args = build_args(stealth_args, (args or []) + proxy_extra_args, timezone=timezone, locale=locale, headless=headless, extension_paths=extension_paths, start_maximized=binary_supports_maximized_window(license_key, browser_version, release_channel) and not _suppress_maximize)
_maybe_warn_windows_fonts(chrome_args)
logger.debug("Launching stealth Chromium (headless=%s, args=%d)", headless, len(chrome_args))
@@ -257,9 +258,12 @@ def launch(
# Patch close() to also stop the Playwright instance
_original_close = browser.close
def _close_with_cleanup() -> None:
def _close_with_cleanup(*, reason: str | None = None) -> None:
try:
_original_close()
if reason is None:
_original_close()
else:
_original_close(reason=reason)
finally:
pw.stop()
@@ -269,7 +273,7 @@ def launch(
# real window) and for headless on binaries that report coherent dimensions
# natively; older headless binaries keep Playwright's default viewport. Apply
# before humanize so the wraps compose.
if not headless or binary_supports_headless_no_viewport(license_key, browser_version):
if not headless or binary_supports_headless_no_viewport(license_key, browser_version, release_channel):
_default_no_viewport(browser)
# Human-like behavioral patching
@@ -296,6 +300,7 @@ async def launch_async( # noqa: C901
extension_paths: list[str] | None = None,
license_key: str | None = None,
browser_version: str | None = None,
release_channel: str | None = None,
_suppress_maximize: bool = False,
**kwargs: Any,
) -> Any:
@@ -335,12 +340,12 @@ async def launch_async( # noqa: C901
from playwright.async_api import async_playwright
binary_path = ensure_binary(license_key=license_key, browser_version=browser_version)
binary_path = ensure_binary(license_key=license_key, browser_version=browser_version, release_channel=release_channel)
timezone, locale, exit_ip = maybe_resolve_geoip(geoip, proxy, timezone, locale, args)
proxy_kwargs, proxy_extra_args = _resolve_proxy_config(proxy, browser_version, license_key)
proxy_kwargs, proxy_extra_args = _resolve_proxy_config(proxy, browser_version, license_key, release_channel)
args = _resolve_webrtc_args(args, proxy)
args = _append_webrtc_exit_ip(args, exit_ip)
chrome_args = build_args(stealth_args, (args or []) + proxy_extra_args, timezone=timezone, locale=locale, headless=headless, extension_paths=extension_paths, start_maximized=binary_supports_maximized_window(license_key, browser_version) and not _suppress_maximize)
chrome_args = build_args(stealth_args, (args or []) + proxy_extra_args, timezone=timezone, locale=locale, headless=headless, extension_paths=extension_paths, start_maximized=binary_supports_maximized_window(license_key, browser_version, release_channel) and not _suppress_maximize)
_maybe_warn_windows_fonts(chrome_args)
logger.debug("Launching stealth Chromium async (headless=%s, args=%d)", headless, len(chrome_args))
@@ -372,9 +377,12 @@ async def launch_async( # noqa: C901
# Patch close() to also stop the Playwright instance
_original_close = browser.close
async def _close_with_cleanup() -> None:
async def _close_with_cleanup(*, reason: str | None = None) -> None:
try:
await _original_close()
if reason is None:
await _original_close()
else:
await _original_close(reason=reason)
finally:
await pw.stop()
@@ -382,7 +390,7 @@ async def launch_async( # noqa: C901
# Default new_page()/new_context() to no_viewport for headed and qualifying
# headless binaries (see launch()).
if not headless or binary_supports_headless_no_viewport(license_key, browser_version):
if not headless or binary_supports_headless_no_viewport(license_key, browser_version, release_channel):
_default_no_viewport_async(browser)
# Human-like behavioral patching (async variant)
@@ -402,7 +410,7 @@ def launch_persistent_context(
args: list[str] | None = None,
stealth_args: bool = True,
user_agent: str | None = None,
viewport: dict | None = _VIEWPORT_UNSET,
viewport: Any = _VIEWPORT_UNSET,
locale: str | None = None,
timezone: str | None = None,
color_scheme: Literal["light", "dark", "no-preference"] | None = None,
@@ -413,6 +421,7 @@ def launch_persistent_context(
extension_paths: list[str] | None = None,
license_key: str | None = None,
browser_version: str | None = None,
release_channel: str | None = None,
**kwargs: Any,
) -> Any:
"""Launch stealth browser with a persistent profile and return a BrowserContext.
@@ -461,12 +470,12 @@ def launch_persistent_context(
timezone = _resolve_timezone(timezone, kwargs)
binary_path = ensure_binary(license_key=license_key, browser_version=browser_version)
binary_path = ensure_binary(license_key=license_key, browser_version=browser_version, release_channel=release_channel)
timezone, locale, exit_ip = maybe_resolve_geoip(geoip, proxy, timezone, locale, args)
proxy_kwargs, proxy_extra_args = _resolve_proxy_config(proxy, browser_version, license_key)
proxy_kwargs, proxy_extra_args = _resolve_proxy_config(proxy, browser_version, license_key, release_channel)
args = _resolve_webrtc_args(args, proxy)
args = _append_webrtc_exit_ip(args, exit_ip)
chrome_args = build_args(stealth_args, (args or []) + proxy_extra_args, timezone=timezone, locale=locale, headless=headless, extension_paths=extension_paths, start_maximized=binary_supports_maximized_window(license_key, browser_version) and viewport is _VIEWPORT_UNSET and "viewport" not in kwargs and "no_viewport" not in kwargs)
chrome_args = build_args(stealth_args, (args or []) + proxy_extra_args, timezone=timezone, locale=locale, headless=headless, extension_paths=extension_paths, start_maximized=binary_supports_maximized_window(license_key, browser_version, release_channel) and viewport is _VIEWPORT_UNSET and "viewport" not in kwargs and "no_viewport" not in kwargs)
_maybe_warn_windows_fonts(chrome_args)
logger.debug(
@@ -482,7 +491,7 @@ def launch_persistent_context(
context_kwargs["user_agent"] = user_agent
context_kwargs.update(
_resolve_context_viewport(
viewport, headless, binary_supports_headless_no_viewport(license_key, browser_version)
viewport, headless, binary_supports_headless_no_viewport(license_key, browser_version, release_channel)
)
)
if color_scheme:
@@ -522,9 +531,12 @@ def launch_persistent_context(
# Patch close() to also stop the Playwright instance
_original_close = context.close
def _close_with_cleanup() -> None:
def _close_with_cleanup(*, reason: str | None = None) -> None:
try:
_original_close()
if reason is None:
_original_close()
else:
_original_close(reason=reason)
finally:
pw.stop()
@@ -547,7 +559,7 @@ async def launch_persistent_context_async(
args: list[str] | None = None,
stealth_args: bool = True,
user_agent: str | None = None,
viewport: dict | None = _VIEWPORT_UNSET,
viewport: Any = _VIEWPORT_UNSET,
locale: str | None = None,
timezone: str | None = None,
color_scheme: Literal["light", "dark", "no-preference"] | None = None,
@@ -558,6 +570,7 @@ async def launch_persistent_context_async(
extension_paths: list[str] | None = None,
license_key: str | None = None,
browser_version: str | None = None,
release_channel: str | None = None,
**kwargs: Any,
) -> Any:
"""Async version of launch_persistent_context().
@@ -608,12 +621,12 @@ async def launch_persistent_context_async(
timezone = _resolve_timezone(timezone, kwargs)
binary_path = ensure_binary(license_key=license_key, browser_version=browser_version)
binary_path = ensure_binary(license_key=license_key, browser_version=browser_version, release_channel=release_channel)
timezone, locale, exit_ip = maybe_resolve_geoip(geoip, proxy, timezone, locale, args)
proxy_kwargs, proxy_extra_args = _resolve_proxy_config(proxy, browser_version, license_key)
proxy_kwargs, proxy_extra_args = _resolve_proxy_config(proxy, browser_version, license_key, release_channel)
args = _resolve_webrtc_args(args, proxy)
args = _append_webrtc_exit_ip(args, exit_ip)
chrome_args = build_args(stealth_args, (args or []) + proxy_extra_args, timezone=timezone, locale=locale, headless=headless, extension_paths=extension_paths, start_maximized=binary_supports_maximized_window(license_key, browser_version) and viewport is _VIEWPORT_UNSET and "viewport" not in kwargs and "no_viewport" not in kwargs)
chrome_args = build_args(stealth_args, (args or []) + proxy_extra_args, timezone=timezone, locale=locale, headless=headless, extension_paths=extension_paths, start_maximized=binary_supports_maximized_window(license_key, browser_version, release_channel) and viewport is _VIEWPORT_UNSET and "viewport" not in kwargs and "no_viewport" not in kwargs)
_maybe_warn_windows_fonts(chrome_args)
logger.debug(
@@ -629,7 +642,7 @@ async def launch_persistent_context_async(
context_kwargs["user_agent"] = user_agent
context_kwargs.update(
_resolve_context_viewport(
viewport, headless, binary_supports_headless_no_viewport(license_key, browser_version)
viewport, headless, binary_supports_headless_no_viewport(license_key, browser_version, release_channel)
)
)
if color_scheme:
@@ -669,9 +682,12 @@ async def launch_persistent_context_async(
# Patch close() to also stop the Playwright instance
_original_close = context.close
async def _close_with_cleanup() -> None:
async def _close_with_cleanup(*, reason: str | None = None) -> None:
try:
await _original_close()
if reason is None:
await _original_close()
else:
await _original_close(reason=reason)
finally:
await pw.stop()
@@ -693,7 +709,7 @@ def launch_context(
args: list[str] | None = None,
stealth_args: bool = True,
user_agent: str | None = None,
viewport: dict | None = _VIEWPORT_UNSET,
viewport: Any = _VIEWPORT_UNSET,
locale: str | None = None,
timezone: str | None = None,
color_scheme: Literal["light", "dark", "no-preference"] | None = None,
@@ -704,6 +720,7 @@ def launch_context(
extension_paths: list[str] | None = None,
license_key: str | None = None,
browser_version: str | None = None,
release_channel: str | None = None,
**kwargs: Any,
) -> Any:
"""Launch stealth browser and return a BrowserContext with common options pre-set.
@@ -748,6 +765,7 @@ def launch_context(
browser = launch(headless=headless, proxy=proxy, args=args, stealth_args=stealth_args,
timezone=timezone, locale=locale, extension_paths=extension_paths,
license_key=license_key, browser_version=browser_version,
release_channel=release_channel,
# Caller chose a viewport geometry → don't also auto-maximize
# the window (mirrors the persistent-context path + JS).
_suppress_maximize=(viewport is not _VIEWPORT_UNSET or "no_viewport" in kwargs))
@@ -757,7 +775,7 @@ def launch_context(
context_kwargs["user_agent"] = user_agent
context_kwargs.update(
_resolve_context_viewport(
viewport, headless, binary_supports_headless_no_viewport(license_key, browser_version)
viewport, headless, binary_supports_headless_no_viewport(license_key, browser_version, release_channel)
)
)
if color_scheme:
@@ -798,7 +816,7 @@ async def launch_context_async(
args: list[str] | None = None,
stealth_args: bool = True,
user_agent: str | None = None,
viewport: dict | None = _VIEWPORT_UNSET,
viewport: Any = _VIEWPORT_UNSET,
locale: str | None = None,
timezone: str | None = None,
color_scheme: Literal["light", "dark", "no-preference"] | None = None,
@@ -809,6 +827,7 @@ async def launch_context_async(
extension_paths: list[str] | None = None,
license_key: str | None = None,
browser_version: str | None = None,
release_channel: str | None = None,
**kwargs: Any,
) -> Any:
"""Async version of launch_context().
@@ -872,6 +891,7 @@ async def launch_context_async(
browser = await launch_async(headless=headless, proxy=proxy, args=args, stealth_args=stealth_args,
timezone=timezone, locale=locale, extension_paths=extension_paths,
license_key=license_key, browser_version=browser_version,
release_channel=release_channel,
# Caller chose a viewport geometry → don't also auto-maximize
# the window (mirrors the persistent-context path + JS).
_suppress_maximize=(viewport is not _VIEWPORT_UNSET or "no_viewport" in kwargs))
@@ -881,7 +901,7 @@ async def launch_context_async(
context_kwargs["user_agent"] = user_agent
context_kwargs.update(
_resolve_context_viewport(
viewport, headless, binary_supports_headless_no_viewport(license_key, browser_version)
viewport, headless, binary_supports_headless_no_viewport(license_key, browser_version, release_channel)
)
)
if color_scheme:
@@ -1342,7 +1362,7 @@ def _maybe_warn_windows_fonts(chrome_args: list[str]) -> None:
if marker.exists():
return
present = _windows_fonts_present()
if present is None or present is True:
if present is None or present:
return # full set present, or can't determine — don't warn
# Write straight to stderr (like the welcome banner and the JS/.NET
# wrappers) so an app's logging config can't silence it.
@@ -1467,6 +1487,7 @@ def _resolve_proxy_config(
proxy: str | ProxySettings | None,
browser_version: str | None = None,
license_key: str | None = None,
release_channel: str | None = None,
) -> tuple[dict[str, Any], list[str]]:
"""Resolve proxy into Playwright kwargs and Chrome args.
@@ -1488,8 +1509,9 @@ def _resolve_proxy_config(
if isinstance(proxy, dict):
url = _reconstruct_socks_url(proxy)
extra_args = [f"--proxy-server={url}"]
if proxy.get("bypass"):
extra_args.append(f"--proxy-bypass-list={proxy['bypass']}")
bypass = proxy.get("bypass")
if bypass:
extra_args.append(f"--proxy-bypass-list={bypass}")
return {}, extra_args
# String URL — re-encode creds to work around Chromium parser truncating
# passwords at '=' and other special chars (#157).
@@ -1499,14 +1521,13 @@ def _resolve_proxy_config(
# use Chrome's native proxy authentication path instead of Playwright's CDP
# auth interceptor (#182). Older binaries (free macOS/linux-arm64) can't parse
# inline credentials, so they fall through to the Playwright proxy dict below.
if _has_credentials(proxy) and binary_supports_http_proxy_inline_auth(
license_key, browser_version
):
if _has_credentials(proxy) and binary_supports_http_proxy_inline_auth(license_key, browser_version, release_channel):
if isinstance(proxy, dict):
url = _reconstruct_http_url(proxy)
extra_args = [f"--proxy-server={url}"]
if proxy.get("bypass"):
extra_args.append(f"--proxy-bypass-list={proxy['bypass']}")
bypass = proxy.get("bypass")
if bypass:
extra_args.append(f"--proxy-bypass-list={bypass}")
return {}, extra_args
return {}, [f"--proxy-server={_normalize_http_string_url(proxy)}"]
+36 -8
View File
@@ -104,6 +104,16 @@ AVAILABLE_PLATFORMS: set[str] = set(PLATFORM_CHROMIUM_VERSIONS.keys())
_VERSION_PIN_RE = re.compile(r"^[0-9]+(?:\.[0-9]+){3,4}$")
def normalize_release_channel(release_channel: str | None = None) -> str:
"""Return the requested release channel, defaulting to Stable."""
raw = (
release_channel
if release_channel is not None
else os.environ.get("CLOAKBROWSER_RELEASE_CHANNEL", "stable")
)
return "preview" if raw.strip().lower() == "preview" else "stable"
def normalize_requested_version(version: str | None = None) -> str | None:
"""Return an explicit Chromium version pin from arg/env, or None.
@@ -199,7 +209,9 @@ def check_platform_available() -> None:
)
def get_effective_version(pro: bool = False) -> str | None:
def get_effective_version(
pro: bool = False, release_channel: str | None = None
) -> str | None:
"""Return the best available version: auto-updated if available, else platform default.
Reads a platform-scoped marker file from the cache directory.
@@ -214,7 +226,13 @@ def get_effective_version(pro: bool = False) -> str | None:
cache = get_cache_dir()
if pro:
marker = cache / f"latest_pro_version_{get_platform_tag()}"
channel = normalize_release_channel(release_channel)
marker_prefix = (
"latest_pro_version_preview"
if channel == "preview"
else "latest_pro_version"
)
marker = cache / f"{marker_prefix}_{get_platform_tag()}"
if marker.exists():
try:
version = marker.read_text().strip()
@@ -312,7 +330,9 @@ HEADLESS_NO_VIEWPORT_MIN_VERSION: str | None = "148.0.7778.215.4"
def binary_supports_headless_no_viewport(
license_key: str | None = None, browser_version: str | None = None
license_key: str | None = None,
browser_version: str | None = None,
release_channel: str | None = None,
) -> bool:
"""Whether headless can launch with ``no_viewport`` on the resolved binary.
@@ -337,7 +357,9 @@ def binary_supports_headless_no_viewport(
from .license import resolve_license_key
pro = bool(resolve_license_key(license_key))
version = get_effective_version(pro=pro)
version = get_effective_version(pro=pro, release_channel=release_channel)
if version is None:
return False
try:
return not _version_newer(HEADLESS_NO_VIEWPORT_MIN_VERSION, version)
except (ValueError, AttributeError):
@@ -369,7 +391,9 @@ HTTP_PROXY_INLINE_AUTH_MIN_VERSION: dict[str, str] = {
def binary_supports_http_proxy_inline_auth(
license_key: str | None = None, browser_version: str | None = None
license_key: str | None = None,
browser_version: str | None = None,
release_channel: str | None = None,
) -> bool:
"""Whether the resolved binary accepts inline HTTP proxy credentials.
@@ -397,7 +421,7 @@ def binary_supports_http_proxy_inline_auth(
from .license import resolve_license_key
pro = bool(resolve_license_key(license_key))
version = get_effective_version(pro=pro)
version = get_effective_version(pro=pro, release_channel=release_channel)
if version is None:
# Pro with no cached marker => resolve latest Pro from the server, which
# is >= the floor by construction and always ships the patch.
@@ -409,7 +433,9 @@ def binary_supports_http_proxy_inline_auth(
def binary_supports_maximized_window(
license_key: str | None = None, browser_version: str | None = None
license_key: str | None = None,
browser_version: str | None = None,
release_channel: str | None = None,
) -> bool:
"""Whether the wrapper may auto-add ``--start-maximized``.
@@ -421,4 +447,6 @@ def binary_supports_maximized_window(
``HEADLESS_NO_VIEWPORT_MIN_VERSION``; kept as its own name so the two can
diverge later. Python, JS and .NET mirror this gate.
"""
return binary_supports_headless_no_viewport(license_key, browser_version)
return binary_supports_headless_no_viewport(
license_key, browser_version, release_channel
)
+129 -49
View File
@@ -42,6 +42,7 @@ from .config import (
get_fallback_download_url,
get_local_binary_override,
get_platform_tag,
normalize_release_channel,
normalize_requested_version,
)
@@ -94,6 +95,24 @@ def _welcome_due(marker: Path, pro: bool) -> bool:
return (time.time() - last) >= WELCOME_FREE_INTERVAL
_preview_fallback_warned = False
def _warn_preview_fallback() -> None:
"""Tell the user, once per process, that a requested Preview build does not
exist for this platform and the Stable build is being used instead."""
global _preview_fallback_warned
if _preview_fallback_warned:
return
_preview_fallback_warned = True
# Write straight to stderr (like the welcome banner) so an app's logging
# config can't silence it.
sys.stderr.write(
"[cloakbrowser] Preview channel requested, but no preview build is "
f"available for {get_platform_tag()}; using the stable build.\n"
)
def _show_welcome(tier: str = "keyless") -> None:
"""Show welcome message on launch. A marker file gates the cadence: a paid
(Pro) key shows once ever; free (keyless or a free GitHub key) re-shows every
@@ -147,6 +166,7 @@ def _show_welcome(tier: str = "keyless") -> None:
def ensure_binary(
license_key: str | None = None,
browser_version: str | None = None,
release_channel: str | None = None,
) -> str:
"""Ensure the stealth Chromium binary is available. Download if needed.
@@ -155,6 +175,7 @@ def ensure_binary(
Args:
license_key: Pro license key. Also reads from CLOAKBROWSER_LICENSE_KEY env var.
browser_version: Exact Chromium version pin. Also reads from CLOAKBROWSER_VERSION.
release_channel: Stable (default) or Preview binary channel.
Set CLOAKBROWSER_BINARY_PATH to skip download and use a local build.
"""
@@ -192,7 +213,12 @@ def ensure_binary(
# during a routine update never reaches here: _ensure_pro_binary
# returns the cached Pro binary and updates in the background.)
try:
return _ensure_pro_binary(key, requested_version=requested_version, plan=info.plan)
return _ensure_pro_binary(
key,
requested_version=requested_version,
plan=info.plan,
release_channel=release_channel,
)
except BinaryVerificationError:
# Authenticity could not be confirmed — surface verbatim.
raise
@@ -338,6 +364,7 @@ def _ensure_pro_binary(
license_key: str,
requested_version: str | None = None,
plan: str | None = None,
release_channel: str | None = None,
) -> str:
"""Ensure the latest (keyed) binary is downloaded and cached. Returns its path.
@@ -346,7 +373,7 @@ def _ensure_pro_binary(
NEVER falls back to the older free binary: if the latest build cannot be
resolved or downloaded and none is cached, the error is raised.
"""
from .license import get_pro_latest_version
from .license import get_pro_latest_release, get_pro_latest_version
# Banner tier: a free GitHub key gets the free banner, paid keys the Pro one.
welcome_tier = "free" if plan == "free" else "pro"
@@ -375,8 +402,8 @@ def _ensure_pro_binary(
_show_welcome(welcome_tier)
return str(binary_path)
# --- Unpinned: track the server's latest stable. ---
effective = get_effective_version(pro=True)
# --- Unpinned: track the server's latest selected channel. ---
effective = get_effective_version(pro=True, release_channel=release_channel)
# Honor CLOAKBROWSER_AUTO_UPDATE=false the way the free path does: if the user
# froze updates AND a Pro build is already cached, keep it and skip the server
@@ -390,13 +417,23 @@ def _ensure_pro_binary(
# get_pro_latest_version() is rate-limited to one network call per hour and
# returns a cached string in between, so this foreground check stays cheap on
# steady-state launches while still landing new stable after a version gap.
latest = get_pro_latest_version()
# steady-state launches while still landing the selected release after a version gap.
latest = get_pro_latest_version(release_channel)
# Tell the user when they asked for Preview but the server has no preview
# build for this platform, so the Stable build is used instead. The lookup is
# a cache hit (get_pro_latest_version above already populated it), so this
# only touches the network on the once-an-hour refresh.
if normalize_release_channel(release_channel) == "preview":
release = get_pro_latest_release(release_channel)
if release is not None and release.fallback:
_warn_preview_fallback()
# Prefer the server's latest when it is newer than — or replaces a missing —
# the cached build. Otherwise stay on the cached Pro binary (fast, offline-ok).
if latest and (
not _pro_binary_ready(effective) # also covers effective is None
effective is None
or not _pro_binary_ready(effective)
or _version_newer(latest, effective)
):
version: str | None = latest
@@ -415,7 +452,7 @@ def _ensure_pro_binary(
# launch — never a stale marker.
if version != effective:
try:
_write_pro_version_marker(version)
_write_pro_version_marker(version, release_channel)
except OSError:
pass
logger.debug("Pro binary found in cache: %s (version %s)", binary_path, version)
@@ -452,7 +489,7 @@ def _ensure_pro_binary(
# Advance the marker so future unpinned launches use this build.
try:
_write_pro_version_marker(version)
_write_pro_version_marker(version, release_channel)
except OSError:
pass
@@ -763,9 +800,13 @@ def _parse_checksums(text: str) -> dict[str, str]:
def _verify_checksum(file_path: Path, expected_hash: str) -> None:
"""Verify SHA-256 of a file. Raises RuntimeError on mismatch."""
sha256 = hashlib.sha256()
with open(file_path, "rb") as f:
for chunk in iter(lambda: f.read(8192), b""):
sha256.update(chunk)
try:
with open(file_path, "rb") as f:
for chunk in iter(lambda: f.read(8192), b""):
sha256.update(chunk)
except OSError:
logger.error("Failed to read downloaded file for checksum: %s", file_path)
raise
actual = sha256.hexdigest().lower()
if actual != expected_hash:
raise RuntimeError(
@@ -791,26 +832,33 @@ def _download_file(url: str, dest: Path, headers: dict[str, str] | None = None)
) as response:
response.raise_for_status()
total = int(response.headers.get("content-length", 0))
try:
total = int(response.headers.get("content-length", 0))
except (TypeError, ValueError):
total = 0
downloaded = 0
last_logged_pct = -1
with open(dest, "wb") as f:
for chunk in response.iter_bytes(chunk_size=8192):
f.write(chunk)
downloaded += len(chunk)
try:
with open(dest, "wb") as f:
for chunk in response.iter_bytes(chunk_size=8192):
f.write(chunk)
downloaded += len(chunk)
if total > 0:
pct = int(downloaded / total * 100)
# Log every 10%
if pct >= last_logged_pct + 10:
last_logged_pct = pct
logger.info(
"Download progress: %d%% (%d/%d MB)",
pct,
downloaded // (1024 * 1024),
total // (1024 * 1024),
)
if total > 0:
pct = downloaded * 100 // total
# Log every 10%
if pct >= last_logged_pct + 10:
last_logged_pct = pct
logger.info(
"Download progress: %d%% (%d/%d MB)",
pct,
downloaded // (1024 * 1024),
total // (1024 * 1024),
)
except OSError:
logger.error("Failed to write downloaded file: %s", dest)
raise
logger.info("Download complete: %d MB", dest.stat().st_size // (1024 * 1024))
@@ -823,7 +871,11 @@ def _extract_archive(
# Clean existing dir if partial download existed
if dest_dir.exists():
shutil.rmtree(dest_dir)
try:
shutil.rmtree(dest_dir)
except OSError:
logger.error("Failed to remove partial extraction directory: %s", dest_dir)
raise
dest_dir.mkdir(parents=True, exist_ok=True)
@@ -902,14 +954,22 @@ def _flatten_single_subdir(dest_dir: Path) -> None:
logger.debug("Keeping .app bundle intact: %s", subdir.name)
return
logger.debug("Flattening single subdirectory: %s", subdir.name)
for item in subdir.iterdir():
shutil.move(str(item), str(dest_dir / item.name))
subdir.rmdir()
try:
for item in subdir.iterdir():
shutil.move(str(item), str(dest_dir / item.name))
subdir.rmdir()
except OSError:
logger.error("Failed to flatten extracted directory: %s", subdir)
raise
def _is_executable(path: Path) -> bool:
"""Check if a file is executable."""
return os.access(path, os.X_OK)
try:
return os.access(path, os.X_OK)
except OSError:
logger.error("Failed to check executable permissions: %s", path)
return False
def _make_executable(path: Path) -> None:
@@ -939,11 +999,18 @@ def clear_cache() -> None:
cache_dir = get_cache_dir()
if cache_dir.exists():
shutil.rmtree(cache_dir)
try:
shutil.rmtree(cache_dir)
except OSError:
logger.error("Failed to clear cache: %s", cache_dir)
raise
logger.info("Cache cleared: %s", cache_dir)
def binary_info(browser_version: str | None = None) -> dict:
def binary_info(
browser_version: str | None = None,
release_channel: str | None = None,
) -> dict:
"""Return info about the current binary installation.
tier reflects what is actually installed on disk, not merely whether a
@@ -957,7 +1024,9 @@ def binary_info(browser_version: str | None = None) -> dict:
requested = normalize_requested_version(browser_version)
# Prefer Pro only if a Pro binary actually exists on disk. get_effective_version
# returns None for Pro when nothing is cached (it never falls back to free).
pro_version = requested or get_effective_version(pro=True)
pro_version = requested or get_effective_version(
pro=True, release_channel=release_channel
)
pro = _pro_binary_ready(pro_version) # already false for a None version
if pro:
@@ -966,11 +1035,12 @@ def binary_info(browser_version: str | None = None) -> dict:
else:
effective = requested or get_effective_version()
binary_path = get_binary_path(effective)
download_url = (
f"{DOWNLOAD_BASE_URL}/api/download/latest"
if pro
else get_download_url(effective)
)
if pro:
download_url = f"{DOWNLOAD_BASE_URL}/api/download/latest"
if normalize_release_channel(release_channel) == "preview":
download_url += "?channel=preview"
else:
download_url = get_download_url(effective)
return {
"version": effective,
"tier": "pro" if pro else "free",
@@ -1012,8 +1082,10 @@ def check_for_update() -> str | None:
return latest
def check_for_pro_update(license_key: str) -> str | None:
"""Move a Pro install to the server's latest stable. Blocks until complete.
def check_for_pro_update(
license_key: str, release_channel: str | None = None
) -> str | None:
"""Move a Pro install to the selected channel's latest. Blocks until complete.
Returns the new version when a newer Pro build is downloaded or an
already-cached newer build is activated, else None (already up to date or the
@@ -1021,11 +1093,11 @@ def check_for_pro_update(license_key: str) -> str | None:
"""
from .license import get_pro_latest_version
latest = get_pro_latest_version()
latest = get_pro_latest_version(release_channel)
if not latest:
return None
effective = get_effective_version(pro=True)
effective = get_effective_version(pro=True, release_channel=release_channel)
if effective and not _version_newer(latest, effective) and _pro_binary_ready(
effective
):
@@ -1041,7 +1113,7 @@ def check_for_pro_update(license_key: str) -> str | None:
f"Pro download completed but binary not found at: {binary_path}"
)
_write_pro_version_marker(latest)
_write_pro_version_marker(latest, release_channel)
return latest
@@ -1098,11 +1170,19 @@ def _write_version_marker(version: str) -> None:
tmp.rename(marker)
def _write_pro_version_marker(version: str) -> None:
"""Atomically write the latest Pro version marker for this platform."""
def _write_pro_version_marker(
version: str, release_channel: str | None = None
) -> None:
"""Atomically write the selected channel's Pro version marker."""
cache_dir = get_cache_dir()
cache_dir.mkdir(parents=True, exist_ok=True)
marker = cache_dir / f"latest_pro_version_{get_platform_tag()}"
channel = normalize_release_channel(release_channel)
marker_prefix = (
"latest_pro_version_preview"
if channel == "preview"
else "latest_pro_version"
)
marker = cache_dir / f"{marker_prefix}_{get_platform_tag()}"
tmp = marker.with_suffix(".tmp")
tmp.write_text(version)
os.replace(str(tmp), str(marker))
+97 -17
View File
@@ -12,12 +12,13 @@ import logging
import os
import re
import time
from collections.abc import Mapping
from dataclasses import dataclass
from pathlib import Path
import httpx
from .config import get_cache_dir, get_platform_tag
from .config import get_cache_dir, get_platform_tag, normalize_release_channel
logger = logging.getLogger("cloakbrowser")
@@ -36,6 +37,31 @@ class LicenseInfo:
expires: str | None
@dataclass(frozen=True)
class ProReleaseInfo:
version: str
requested_channel: str
resolved_channel: str
fallback: bool
def _release_from_sidecar(
data: Mapping[str, object], version: str, channel: str
) -> ProReleaseInfo:
"""Build a ProReleaseInfo from a resolution-sidecar dict (snake_case, with a
camelCase fallback for sidecars written by an older JS wrapper). A missing
``fallback`` defaults to ``requested != resolved`` — mirrors JS and .NET."""
requested = str(data.get("requested_channel", data.get("requestedChannel", channel)))
resolved = str(data.get("resolved_channel", data.get("resolvedChannel", "stable")))
fallback = data.get("fallback")
return ProReleaseInfo(
version=version,
requested_channel=requested,
resolved_channel=resolved,
fallback=bool(fallback) if fallback is not None else requested != resolved,
)
class CloakBrowserLicenseError(RuntimeError):
"""The Pro binary refused to run for a license reason.
@@ -83,7 +109,11 @@ def license_error_message(error_text: str) -> str | None:
match = _EXIT_CODE_RE.search(error_text or "")
if not match:
return None
return _LICENSE_EXIT_MESSAGES.get(int(match.group(1)))
try:
exit_code = int(match.group(1))
except ValueError:
return None
return _LICENSE_EXIT_MESSAGES.get(exit_code)
_LICENSE_KEY_SOURCE_PARAM = "param"
@@ -138,7 +168,7 @@ def resolve_license_key(license_key: str | None = None) -> str | None:
def build_launch_env(
license_key: str | None = None,
user_env: dict[str, str] | None = None,
user_env: Mapping[str, str | None] | None = None,
) -> dict[str, str] | None:
"""Build child process env dict with any needed license key injection.
@@ -247,44 +277,94 @@ def validate_license(license_key: str) -> LicenseInfo | None:
return None
def get_pro_latest_version() -> str | None:
"""Get the latest Pro binary version from the server.
Rate-limited to 1 call per hour via a marker file.
"""
marker = get_cache_dir() / f".last_pro_version_check_{get_platform_tag()}"
def get_pro_latest_release(
release_channel: str | None = None,
) -> ProReleaseInfo | None:
"""Get the server-resolved Pro release and channel for this platform."""
channel = normalize_release_channel(release_channel)
marker_suffix = (
f"preview_{get_platform_tag()}"
if channel == "preview"
else get_platform_tag()
)
marker = get_cache_dir() / f".last_pro_version_check_{marker_suffix}"
resolution_marker = get_cache_dir() / f".last_pro_version_resolution_{marker_suffix}"
if marker.exists():
try:
age = time.time() - marker.stat().st_mtime
if age < PRO_VERSION_CHECK_INTERVAL:
content = marker.read_text().strip()
return content if content else None
except OSError:
if age < PRO_VERSION_CHECK_INTERVAL and resolution_marker.exists():
data = json.loads(resolution_marker.read_text())
version = marker.read_text().strip()
if version and data.get("version") == version:
return _release_from_sidecar(data, version, channel)
except (OSError, ValueError, TypeError):
pass
try:
version_url = (
f"{PRO_VERSION_URL}?channel=preview"
if channel == "preview"
else PRO_VERSION_URL
)
resp = httpx.get(
PRO_VERSION_URL,
version_url,
headers={"X-Platform": get_platform_tag()},
timeout=10.0,
)
resp.raise_for_status()
version = resp.json().get("version")
data = resp.json()
version = data.get("version")
if not version:
return None
resolved_channel = str(data.get("resolved_channel", "stable"))
release = ProReleaseInfo(
version=str(version),
requested_channel=str(data.get("requested_channel", channel)),
resolved_channel=resolved_channel,
fallback=bool(
data.get("fallback", channel != resolved_channel)
),
)
marker.parent.mkdir(parents=True, exist_ok=True)
resolution_tmp = resolution_marker.with_suffix(".tmp")
resolution_tmp.write_text(json.dumps(release.__dict__))
os.replace(str(resolution_tmp), str(resolution_marker))
tmp = marker.with_suffix(".tmp")
tmp.write_text(version)
tmp.write_text(release.version)
os.replace(str(tmp), str(marker))
return version
return release
except Exception as e:
logger.debug("Pro version check failed: %s", e)
try:
version = marker.read_text().strip()
if not version:
return None
# Prefer the last successful fetch's channel metadata (the resolution
# sidecar) so an offline preview build is not mislabeled as a stable
# fallback. Older wrappers left only the version marker, so its channel
# is unknowable — hardcode a conservative stable fallback then.
if resolution_marker.exists():
try:
data = json.loads(resolution_marker.read_text())
if data.get("version") == version:
return _release_from_sidecar(data, version, channel)
except (OSError, ValueError, TypeError):
pass
return ProReleaseInfo(version, channel, "stable", channel == "preview")
except OSError:
pass
return None
def get_pro_latest_version(release_channel: str | None = None) -> str | None:
"""Get only the version from the server-resolved Pro release."""
release = get_pro_latest_release(release_channel)
return release.version if release else None
def get_active_session_count(license_key: str) -> int | None:
"""How many concurrent sessions (seats) this license is holding right now.
+16
View File
@@ -355,6 +355,7 @@ var page = await ctx.NewPageAsync();
| `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
@@ -383,6 +384,20 @@ 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:
@@ -608,6 +623,7 @@ Same set as the Python wrapper:
| `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 |
+32 -15
View File
@@ -110,22 +110,39 @@ static void PrintDiagnostics(Dictionary<string, object?> diag)
{
if ((string)binary["tier"]! == "override")
Console.WriteLine("Version: set via CLOAKBROWSER_BINARY_PATH (see Launch line)");
else if (binary.TryGetValue("latest_version", out var lv) && lv is string latest && !string.IsNullOrEmpty(latest))
{
// Pro: show what launches now AND the server's latest, so the two can't diverge.
Console.WriteLine($"Version: {binary["version"]} ({binary["tier"]}) — will launch");
if (latest == binary["version"] as string)
Console.WriteLine($"Latest: {latest} (up to date)");
else if (binary.TryGetValue("pinned", out var p) && p is true)
Console.WriteLine($"Latest: {latest} (available — pinned; unset CLOAKBROWSER_VERSION to upgrade)");
else
Console.WriteLine($"Latest: {latest} (available — next launch upgrades)");
}
else if (binary["version"] is null)
// Pro with no cached build and no server answer (e.g. offline).
Console.WriteLine($"Version: not downloaded yet ({binary["tier"]}) — next launch downloads the latest");
else
Console.WriteLine($"Version: {binary["version"]} ({binary["tier"]})");
{
if (binary.TryGetValue("channel_fallback", out var fallback) && fallback is true)
Console.WriteLine("Channel: Preview → Stable fallback");
else
{
var channel = binary.GetValueOrDefault("resolved_channel") as string
?? binary.GetValueOrDefault("requested_channel") as string
?? "stable";
Console.WriteLine($"Channel: {TitleCase(channel)}");
}
if (binary.TryGetValue("latest_version", out var lv) && lv is string latest && !string.IsNullOrEmpty(latest))
{
// Pro: show what launches now AND the server's latest, so the two can't diverge.
Console.WriteLine($"Version: {binary["version"]} ({binary["tier"]}) — next launch");
if (latest == binary["version"] as string && binary["installed"] is true)
Console.WriteLine($"Latest: {latest} (up to date)");
else if (latest == binary["version"] as string)
Console.WriteLine($"Latest: {latest} (downloads on next launch)");
else if (binary.TryGetValue("pinned", out var p) && p is true)
Console.WriteLine($"Latest: {latest} (available — pinned; unset CLOAKBROWSER_VERSION to upgrade)");
else
Console.WriteLine($"Latest: {latest} (server-resolved; installed build retained)");
if (binary.GetValueOrDefault("installed_version") is string installedVersion
&& installedVersion != binary["version"] as string)
Console.WriteLine($"Installed: {installedVersion}");
}
else if (binary["version"] is null)
// Pro with no cached build and no server answer (e.g. offline).
Console.WriteLine($"Version: not downloaded yet ({binary["tier"]}) — next launch downloads the latest");
else
Console.WriteLine($"Version: {binary["version"]} ({binary["tier"]})");
}
Console.WriteLine($"Binary: {binary["path"]}");
Console.WriteLine($"Installed: {binary["installed"]}");
if (binary["cache_dir"] is string cd && !string.IsNullOrEmpty(cd))
+19 -9
View File
@@ -23,17 +23,21 @@ public static class CloakLauncher
{
options ??= new LaunchOptions();
string binaryPath = await Download.EnsureBinaryAsync(options.LicenseKey, options.BrowserVersion).ConfigureAwait(false);
string binaryPath = await Download.EnsureBinaryAsync(
options.LicenseKey, options.BrowserVersion,
releaseChannel: options.ReleaseChannel).ConfigureAwait(false);
var (timezone, locale, exitIp) = await MaybeResolveGeoIpAsync(
options.GeoIp, options.Proxy, options.Timezone, options.Locale, options.Args).ConfigureAwait(false);
var proxyResolution = ProxyResolver.Resolve(options.Proxy, options.BrowserVersion, options.LicenseKey);
var proxyResolution = ProxyResolver.Resolve(
options.Proxy, options.BrowserVersion, options.LicenseKey, options.ReleaseChannel);
var args = await ResolveWebRtcArgsAsync(options.Args, options.Proxy).ConfigureAwait(false);
args = MaybeAppendWebRtcExitIp(args, exitIp);
var combined = new List<string>(args ?? new List<string>());
combined.AddRange(proxyResolution.ExtraArgs);
var chromeArgs = BuildArgs(options.StealthArgs, combined, timezone, locale, options.Headless, options.ExtensionPaths,
startMaximized: Config.BinarySupportsMaximizedWindow(options.LicenseKey, options.BrowserVersion)
startMaximized: Config.BinarySupportsMaximizedWindow(
options.LicenseKey, options.BrowserVersion, options.ReleaseChannel)
&& !options.SuppressMaximize);
MaybeWarnWindowsFonts(chromeArgs);
@@ -72,8 +76,8 @@ public static class CloakLauncher
// Pass headless so headed handles default new pages/contexts to NoViewport
// (track the real window - see CloakBrowserHandle.ApplyDefaultNoViewport).
// headlessNoViewport extends that default to headless on newer binaries.
bool headlessNoViewport =
Config.BinarySupportsHeadlessNoViewport(options.LicenseKey, options.BrowserVersion);
bool headlessNoViewport = Config.BinarySupportsHeadlessNoViewport(
options.LicenseKey, options.BrowserVersion, options.ReleaseChannel);
return new CloakBrowserHandle(
playwright, browser, options.Humanize, humanCfg, options.Headless, headlessNoViewport);
}
@@ -112,6 +116,7 @@ public static class CloakLauncher
ExtensionPaths = options.ExtensionPaths,
LicenseKey = options.LicenseKey,
BrowserVersion = options.BrowserVersion,
ReleaseChannel = options.ReleaseChannel,
// geoip already resolved above; don't re-resolve.
GeoIp = false,
// Caller chose a viewport geometry → don't also auto-maximize the
@@ -149,17 +154,21 @@ public static class CloakLauncher
{
options ??= new LaunchContextOptions();
string binaryPath = await Download.EnsureBinaryAsync(options.LicenseKey, options.BrowserVersion).ConfigureAwait(false);
string binaryPath = await Download.EnsureBinaryAsync(
options.LicenseKey, options.BrowserVersion,
releaseChannel: options.ReleaseChannel).ConfigureAwait(false);
var (timezone, locale, exitIp) = await MaybeResolveGeoIpAsync(
options.GeoIp, options.Proxy, options.Timezone, options.Locale, options.Args).ConfigureAwait(false);
var proxyResolution = ProxyResolver.Resolve(options.Proxy, options.BrowserVersion, options.LicenseKey);
var proxyResolution = ProxyResolver.Resolve(
options.Proxy, options.BrowserVersion, options.LicenseKey, options.ReleaseChannel);
var args = await ResolveWebRtcArgsAsync(options.Args, options.Proxy).ConfigureAwait(false);
args = MaybeAppendWebRtcExitIp(args, exitIp);
var combined = new List<string>(args ?? new List<string>());
combined.AddRange(proxyResolution.ExtraArgs);
var chromeArgs = BuildArgs(options.StealthArgs, combined, timezone, locale, options.Headless, options.ExtensionPaths,
startMaximized: Config.BinarySupportsMaximizedWindow(options.LicenseKey, options.BrowserVersion)
startMaximized: Config.BinarySupportsMaximizedWindow(
options.LicenseKey, options.BrowserVersion, options.ReleaseChannel)
&& !options.NoViewport && options.Viewport == null);
MaybeWarnWindowsFonts(chromeArgs);
@@ -555,7 +564,8 @@ public static class CloakLauncher
// Viewport unset: headed tracks the real window; headless on a newer binary also
// tracks it (coherent dimensions natively), older headless gets the fixed default.
bool headlessNoViewport = options.Headless
&& Config.BinarySupportsHeadlessNoViewport(options.LicenseKey, options.BrowserVersion);
&& Config.BinarySupportsHeadlessNoViewport(
options.LicenseKey, options.BrowserVersion, options.ReleaseChannel);
return options.Headless && !headlessNoViewport
? new ViewportSize { Width = Config.DefaultViewportWidth, Height = Config.DefaultViewportHeight }
: ViewportSize.NoViewport;
+29 -10
View File
@@ -116,6 +116,17 @@ public static class Config
@"^[0-9]+(?:\.[0-9]+){3,4}$",
RegexOptions.Compiled | RegexOptions.CultureInvariant);
/// <summary>Return the requested release channel, defaulting to Stable.</summary>
public static string NormalizeReleaseChannel(string? releaseChannel = null)
{
var raw = releaseChannel
?? Environment.GetEnvironmentVariable("CLOAKBROWSER_RELEASE_CHANNEL")
?? "stable";
return string.Equals(raw.Trim(), "preview", StringComparison.OrdinalIgnoreCase)
? "preview"
: "stable";
}
/// <summary>
/// Return an explicit Chromium version pin from arg/env, or null.
///
@@ -248,14 +259,17 @@ public static class Config
/// back to the free binary, so there is deliberately no free-version fallback for Pro —
/// callers treat <c>null</c> as "resolve the latest Pro version from the server".
/// </summary>
public static string? GetEffectiveVersion(bool pro = false)
public static string? GetEffectiveVersion(bool pro = false, string? releaseChannel = null)
{
var baseVersion = GetChromiumVersion();
var cache = GetCacheDir();
if (pro)
{
var proMarker = Path.Combine(cache, $"latest_pro_version_{GetPlatformTag()}");
var markerPrefix = NormalizeReleaseChannel(releaseChannel) == "preview"
? "latest_pro_version_preview"
: "latest_pro_version";
var proMarker = Path.Combine(cache, $"{markerPrefix}_{GetPlatformTag()}");
if (File.Exists(proMarker))
{
try
@@ -373,8 +387,10 @@ public static class Config
$"{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";
public static string GetProLatestDownloadUrl(string? releaseChannel = null) =>
NormalizeReleaseChannel(releaseChannel) == "preview"
? $"{DownloadBaseUrl}/api/download/latest?channel=preview"
: $"{DownloadBaseUrl}/api/download/latest";
// -----------------------------------------------------------------------
// Local binary override (skip download, use your own build)
@@ -399,7 +415,8 @@ public static class Config
/// ones keep the fixed default viewport. A local override binary
/// (<c>CLOAKBROWSER_BINARY_PATH</c>) is unknown-version, so stay on the safe path.
/// </summary>
public static bool BinarySupportsHeadlessNoViewport(string? licenseKey = null, string? browserVersion = null)
public static bool BinarySupportsHeadlessNoViewport(
string? licenseKey = null, string? browserVersion = null, string? releaseChannel = null)
{
if (HeadlessNoViewportMinVersion == null)
return false;
@@ -427,7 +444,7 @@ public static class Config
else
{
bool pro = !string.IsNullOrEmpty(License.ResolveLicenseKey(licenseKey));
version = GetEffectiveVersion(pro);
version = GetEffectiveVersion(pro, releaseChannel);
}
// No cached Pro build resolvable (GetEffectiveVersion returned null) → fail safe.
if (version == null) return false;
@@ -450,8 +467,9 @@ public static class Config
/// Shares <see cref="HeadlessNoViewportMinVersion"/>; own name so the two can
/// diverge later. Python, JS and .NET mirror this gate.
/// </summary>
public static bool BinarySupportsMaximizedWindow(string? licenseKey = null, string? browserVersion = null)
=> BinarySupportsHeadlessNoViewport(licenseKey, browserVersion);
public static bool BinarySupportsMaximizedWindow(
string? licenseKey = null, string? browserVersion = null, string? releaseChannel = null)
=> BinarySupportsHeadlessNoViewport(licenseKey, browserVersion, releaseChannel);
// First Chromium build, per platform, whose binary can take inline HTTP proxy
// credentials (Chromium-native --proxy-server=http://user:pass@host). Below the
@@ -483,7 +501,8 @@ public static class Config
/// override with no declared version is unknown-version, so stay on the safe
/// fallback. Python, JS and .NET mirror this gate.
/// </summary>
public static bool BinarySupportsHttpProxyInlineAuth(string? licenseKey = null, string? browserVersion = null)
public static bool BinarySupportsHttpProxyInlineAuth(
string? licenseKey = null, string? browserVersion = null, string? releaseChannel = null)
{
string tag;
try
@@ -517,7 +536,7 @@ public static class Config
else
{
bool pro = !string.IsNullOrEmpty(License.ResolveLicenseKey(licenseKey));
version = GetEffectiveVersion(pro);
version = GetEffectiveVersion(pro, releaseChannel);
}
if (version == null) return false;
try
+26 -5
View File
@@ -159,28 +159,49 @@ internal static class Diagnostics
};
}
string? requested = Config.NormalizeRequestedVersion();
string requestedChannel = Config.NormalizeReleaseChannel();
// For a Pro license, surface the server's latest separately from the version
// that will actually launch, so `info` can never silently diverge from launch
// (the divergence a customer hit: info showed latest, launch ran a stale cache).
// --quick keeps `info` fully network-free (skip the server latest lookup).
string? latestVersion = (entitledPro && !quick) ? License.GetProLatestVersion() : null;
ProReleaseInfo? latestRelease = (entitledPro && !quick)
? License.GetProLatestRelease(requestedChannel)
: null;
string? latestVersion = latestRelease?.Version;
string? version;
string? installedVersion = null;
if (!string.IsNullOrEmpty(requested))
version = requested!;
else if (entitledPro)
// "Will launch now" is the cached Pro build; if none is cached, the next
// launch downloads latestVersion. GetEffectiveVersion(true) returns null
// (never the free base) when nothing is cached.
version = Config.GetEffectiveVersion(true) ?? latestVersion;
{
// Mirror EnsureBinary: report what the next launch resolves to, while
// CLOAKBROWSER_AUTO_UPDATE=false retains an installed channel build.
installedVersion = Config.GetEffectiveVersion(true, requestedChannel);
var autoUpdate = (Environment.GetEnvironmentVariable("CLOAKBROWSER_AUTO_UPDATE") ?? "true").ToLowerInvariant();
bool updatesEnabled = autoUpdate != "false";
if (installedVersion != null && !updatesEnabled)
version = installedVersion;
else if (latestVersion != null && (installedVersion == null || Config.VersionNewer(latestVersion, installedVersion)))
version = latestVersion;
else
version = installedVersion ?? latestVersion;
}
else
{
version = Config.GetEffectiveVersion(false);
installedVersion = version;
}
string? path = version != null ? Config.GetBinaryPath(version, entitledPro) : null;
return new Dictionary<string, object?>
{
["version"] = version,
["latest_version"] = latestVersion,
["requested_channel"] = requestedChannel,
["resolved_channel"] = latestRelease?.ResolvedChannel,
["channel_fallback"] = latestRelease?.Fallback ?? false,
["installed_version"] = installedVersion,
["pinned"] = !string.IsNullOrEmpty(requested),
["tier"] = entitledPro ? "pro" : "free",
["bundled_version"] = Config.ChromiumVersion,
+63 -25
View File
@@ -2,7 +2,6 @@ using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Formats.Tar;
using System.IO.Compression;
using System.Net.Http;
using System.Runtime.InteropServices;
using System.Security.Cryptography;
@@ -107,6 +106,24 @@ public static class Download
}
}
private static bool _previewFallbackWarned;
/// <summary>Reset the once-per-process Preview-fallback notice. Test seam.</summary>
internal static void ResetPreviewFallbackWarned() => _previewFallbackWarned = false;
/// <summary>
/// Tell the user, once per process, that a requested Preview build does not exist
/// for this platform and the Stable build is being used instead.
/// </summary>
private static void WarnPreviewFallback()
{
if (_previewFallbackWarned) return;
_previewFallbackWarned = true;
Console.Error.WriteLine(
"[cloakbrowser] Preview channel requested, but no preview build is available for "
+ $"{Config.GetPlatformTag()}; using the stable build.");
}
/// <summary>
/// Show the launch welcome banner. A marker file gates the cadence: a paid
/// (Pro) key shows once ever; free (keyless or a free GitHub key) re-shows
@@ -174,8 +191,10 @@ public static class Download
/// the platform default.
/// </param>
/// <param name="ct">Cancellation token.</param>
/// <param name="releaseChannel">Stable (default) or Preview binary channel.</param>
public static async Task<string> EnsureBinaryAsync(
string? licenseKey = null, string? browserVersion = null, CancellationToken ct = default)
string? licenseKey = null, string? browserVersion = null,
CancellationToken ct = default, string? releaseChannel = null)
{
// Check for local override first.
var localOverride = Config.GetLocalBinaryOverride();
@@ -212,7 +231,8 @@ public static class Download
// returns the cached Pro binary and updates in the background.)
try
{
return await EnsureProBinaryAsync(key, proVersion, info.Plan, ct).ConfigureAwait(false);
return await EnsureProBinaryAsync(
key, proVersion, info.Plan, ct, releaseChannel).ConfigureAwait(false);
}
catch (BinaryVerificationError)
{
@@ -308,8 +328,9 @@ public static class Download
}
/// <summary>Synchronous convenience wrapper around <see cref="EnsureBinaryAsync"/>.</summary>
public static string EnsureBinary(string? licenseKey = null, string? browserVersion = null) =>
EnsureBinaryAsync(licenseKey, browserVersion).GetAwaiter().GetResult();
public static string EnsureBinary(
string? licenseKey = null, string? browserVersion = null, string? releaseChannel = null) =>
EnsureBinaryAsync(licenseKey, browserVersion, releaseChannel: releaseChannel).GetAwaiter().GetResult();
private static async Task DownloadAndExtractAsync(string? version, CancellationToken ct)
{
@@ -359,7 +380,8 @@ public static class Download
/// <summary>Ensure the Pro binary is downloaded and cached. Returns the binary path.</summary>
private static async Task<string> EnsureProBinaryAsync(
string licenseKey, string? requestedVersion, string? plan, CancellationToken ct)
string licenseKey, string? requestedVersion, string? plan,
CancellationToken ct, string? releaseChannel)
{
// A validated free GitHub key routes here too (it downloads the latest binary),
// but its welcome banner is the "free" one, not the paid Pro banner.
@@ -389,8 +411,8 @@ public static class Download
return pinnedPath;
}
// Unpinned: track the server's latest stable.
var effective = Config.GetEffectiveVersion(pro: true);
// Unpinned: track the server's latest selected channel.
var effective = Config.GetEffectiveVersion(pro: true, releaseChannel: releaseChannel);
// Honor CLOAKBROWSER_AUTO_UPDATE=false the way the free path does: frozen AND a
// cached Pro build present → keep it, skip the server check. With no cached build
@@ -407,7 +429,18 @@ public static class Download
// GetProLatestVersion() is rate-limited to one network call per hour and returns a
// cached string in between, so this foreground check stays cheap steady-state.
var latest = License.GetProLatestVersion();
var latest = License.GetProLatestVersion(releaseChannel);
// Tell the user when they asked for Preview but the server has no preview build
// for this platform, so the Stable build is used instead. The lookup is a cache
// hit (GetProLatestVersion above already populated it), so this only touches the
// network on the once-an-hour refresh.
if (Config.NormalizeReleaseChannel(releaseChannel) == "preview")
{
var release = License.GetProLatestRelease(releaseChannel);
if (release is { Fallback: true })
WarnPreviewFallback();
}
// Prefer the server's latest when it is newer than — or replaces a missing — the
// cached build. Otherwise stay on the cached Pro binary (fast, offline-ok).
@@ -436,7 +469,7 @@ public static class Download
// so `info` (and a later server-outage launch) reflect the build we actually
// launch — never a stale marker.
if (version != effective)
WriteProVersionMarker(version);
WriteProVersionMarker(version, releaseChannel);
CloakLog.Debug("Pro binary found in cache: {0} (version {1})", readyPath, version);
ShowWelcome(welcomeTier);
return readyPath;
@@ -471,7 +504,7 @@ public static class Download
throw new InvalidOperationException(
$"Pro download completed but binary not found at: {downloadedPath}");
WriteProVersionMarker(version);
WriteProVersionMarker(version, releaseChannel);
ShowWelcome(welcomeTier);
return downloadedPath;
}
@@ -602,16 +635,18 @@ public static class Download
}
}
private static void WriteProVersionMarker(string version)
private static void WriteProVersionMarker(string version, string? releaseChannel = null)
{
var marker = Path.Combine(Config.GetCacheDir(), $"latest_pro_version_{Config.GetPlatformTag()}");
var markerPrefix = Config.NormalizeReleaseChannel(releaseChannel) == "preview"
? "latest_pro_version_preview"
: "latest_pro_version";
var marker = Path.Combine(Config.GetCacheDir(), $"{markerPrefix}_{Config.GetPlatformTag()}");
try
{
Directory.CreateDirectory(Config.GetCacheDir());
var tmp = marker + ".tmp";
File.WriteAllText(tmp, version);
if (File.Exists(marker)) File.Delete(marker);
File.Move(tmp, marker);
File.Move(tmp, marker, overwrite: true);
}
catch (IOException) { }
}
@@ -1103,14 +1138,16 @@ public static class Download
/// effectively running the free binary, and the active key may differ from the
/// cached one.
/// </summary>
public static CloakBinaryInfo BinaryInfo(string? browserVersion = null)
public static CloakBinaryInfo BinaryInfo(
string? browserVersion = null, string? releaseChannel = null)
{
// browserVersion (or CLOAKBROWSER_VERSION) pins the reported version so the
// info matches what a pinned launch actually runs, instead of latest.
var requested = Config.NormalizeRequestedVersion(browserVersion);
// Prefer Pro only if a Pro binary actually exists on disk. GetEffectiveVersion
// returns null for Pro when nothing is cached (it never falls back to free).
var proVersion = requested ?? Config.GetEffectiveVersion(pro: true);
var proVersion = requested ?? Config.GetEffectiveVersion(
pro: true, releaseChannel: releaseChannel);
var pro = ProBinaryReady(proVersion);
string effective;
@@ -1135,7 +1172,7 @@ public static class Download
BinaryPath: binaryPath,
Installed: File.Exists(binaryPath),
CacheDir: Config.GetBinaryDir(effective, pro: pro),
DownloadUrl: pro ? Config.GetProLatestDownloadUrl() : Config.GetDownloadUrl(effective));
DownloadUrl: pro ? Config.GetProLatestDownloadUrl(releaseChannel) : Config.GetDownloadUrl(effective));
}
// -----------------------------------------------------------------------
@@ -1169,17 +1206,18 @@ public static class Download
public static string? CheckForUpdate() => CheckForUpdateAsync().GetAwaiter().GetResult();
/// <summary>
/// Move a Pro install to the server's latest stable. Blocks until complete. Returns the
/// Move a Pro install to the selected channel's latest. Blocks until complete. Returns the
/// new version when a newer Pro build is downloaded or an already-cached newer build is
/// activated, else null (already up to date or the server could not be reached).
/// Requires a valid Pro license key.
/// </summary>
public static async Task<string?> CheckForProUpdateAsync(string licenseKey, CancellationToken ct = default)
public static async Task<string?> CheckForProUpdateAsync(
string licenseKey, CancellationToken ct = default, string? releaseChannel = null)
{
var latest = License.GetProLatestVersion();
var latest = License.GetProLatestVersion(releaseChannel);
if (string.IsNullOrEmpty(latest)) return null;
var effective = Config.GetEffectiveVersion(pro: true);
var effective = Config.GetEffectiveVersion(pro: true, releaseChannel: releaseChannel);
if (effective != null && !Config.VersionNewer(latest, effective) && ProBinaryReady(effective))
{
// Already on the latest cached Pro build.
@@ -1195,13 +1233,13 @@ public static class Download
throw new InvalidOperationException($"Pro download completed but binary not found at: {p}");
}
WriteProVersionMarker(latest);
WriteProVersionMarker(latest, releaseChannel);
return latest;
}
/// <summary>Synchronous convenience wrapper around <see cref="CheckForProUpdateAsync"/>.</summary>
public static string? CheckForProUpdate(string licenseKey) =>
CheckForProUpdateAsync(licenseKey).GetAwaiter().GetResult();
public static string? CheckForProUpdate(string licenseKey, string? releaseChannel = null) =>
CheckForProUpdateAsync(licenseKey, releaseChannel: releaseChannel).GetAwaiter().GetResult();
private static bool ShouldCheckForUpdate()
{
+6
View File
@@ -57,6 +57,12 @@ public class LaunchOptions
/// </summary>
public string? BrowserVersion { get; set; }
/// <summary>
/// Binary release channel (<c>stable</c> or <c>preview</c>). Also read from the
/// <c>CLOAKBROWSER_RELEASE_CHANNEL</c> environment variable.
/// </summary>
public string? ReleaseChannel { get; set; }
/// <summary>
/// Internal: suppress the auto <c>--start-maximized</c> flag. Set by the context
/// launchers when the caller chose a viewport geometry, so the window is not also
+128 -18
View File
@@ -11,6 +11,10 @@ namespace CloakBrowser;
/// </summary>
public sealed record LicenseInfo(bool Valid, string Plan, string? Expires);
/// <summary>Server-resolved Pro release for the requested channel and platform.</summary>
public sealed record ProReleaseInfo(
string Version, string RequestedChannel, string ResolvedChannel, bool Fallback);
/// <summary>
/// The Pro binary refused to run for a license reason. Thrown when a launch fails
/// and the browser process exited with one of the Pro binary's license exit codes,
@@ -125,6 +129,9 @@ public static class License
/// <summary>Overrides the Pro latest-version lookup for tests. Null -> real HTTP.</summary>
internal static Func<string?>? ProLatestVersionOverride;
/// <summary>Overrides resolved Pro release metadata for tests. Null -> normal resolution.</summary>
internal static Func<ProReleaseInfo?>? ProLatestReleaseOverride;
/// <summary>Overrides the live seat-count lookup for tests. Null -> real HTTP.</summary>
internal static Func<string, int?>? ActiveSessionCountOverride;
@@ -301,63 +308,166 @@ public static class License
}
}
/// <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()
/// <summary>Get the server-resolved Pro release and channel for this platform.</summary>
public static ProReleaseInfo? GetProLatestRelease(string? releaseChannel = null)
{
var channel = Config.NormalizeReleaseChannel(releaseChannel);
if (ProLatestReleaseOverride != null)
return ProLatestReleaseOverride();
if (ProLatestVersionOverride != null)
return ProLatestVersionOverride();
{
var overridden = ProLatestVersionOverride();
return string.IsNullOrEmpty(overridden)
? null
: new ProReleaseInfo(overridden, channel, channel, false);
}
var marker = Path.Combine(Config.GetCacheDir(), $".last_pro_version_check_{Config.GetPlatformTag()}");
var markerSuffix = channel == "preview"
? $"preview_{Config.GetPlatformTag()}"
: Config.GetPlatformTag();
var marker = Path.Combine(Config.GetCacheDir(), $".last_pro_version_check_{markerSuffix}");
var resolutionMarker = Path.Combine(
Config.GetCacheDir(), $".last_pro_version_resolution_{markerSuffix}");
if (File.Exists(marker))
if (File.Exists(marker) && File.Exists(resolutionMarker))
{
try
{
var age = (DateTime.UtcNow - File.GetLastWriteTimeUtc(marker)).TotalSeconds;
if (age < ProVersionCheckInterval)
{
var content = File.ReadAllText(marker).Trim();
return string.IsNullOrEmpty(content) ? null : content;
var version = File.ReadAllText(marker).Trim();
using var cachedDoc = JsonDocument.Parse(File.ReadAllText(resolutionMarker));
var root = cachedDoc.RootElement;
var cachedVersion = root.GetProperty("version").GetString();
if (!string.IsNullOrEmpty(version) && cachedVersion == version)
{
var requested = root.TryGetProperty("requested_channel", out var requestedSnake)
? requestedSnake.GetString()
: root.TryGetProperty("requestedChannel", out var requestedCamel)
? requestedCamel.GetString()
: channel;
var resolved = root.TryGetProperty("resolved_channel", out var resolvedSnake)
? resolvedSnake.GetString()
: root.TryGetProperty("resolvedChannel", out var resolvedCamel)
? resolvedCamel.GetString()
: "stable";
requested ??= channel;
resolved ??= "stable";
var didFallback = root.TryGetProperty("fallback", out var fallback)
? fallback.GetBoolean()
: requested != resolved;
return new ProReleaseInfo(version, requested, resolved, didFallback);
}
}
}
catch (IOException) { /* unreadable - proceed with fetch */ }
catch (JsonException) { /* invalid - proceed with fetch */ }
catch (KeyNotFoundException) { /* malformed sidecar - proceed with fetch */ }
catch (InvalidOperationException) { /* wrong JSON value kind - proceed with fetch */ }
}
try
{
using var req = new HttpRequestMessage(HttpMethod.Get, ProVersionUrl);
var versionUrl = channel == "preview"
? $"{ProVersionUrl}?channel=preview"
: ProVersionUrl;
using var req = new HttpRequestMessage(HttpMethod.Get, versionUrl);
req.Headers.Add("X-Platform", Config.GetPlatformTag());
using var resp = Http.SendAsync(req).GetAwaiter().GetResult();
resp.EnsureSuccessStatusCode();
var json = resp.Content.ReadAsStringAsync().GetAwaiter().GetResult();
using var doc = JsonDocument.Parse(json);
var version = doc.RootElement.TryGetProperty("version", out var ve) && ve.ValueKind == JsonValueKind.String
var root = doc.RootElement;
var version = root.TryGetProperty("version", out var ve) && ve.ValueKind == JsonValueKind.String
? ve.GetString() : null;
if (string.IsNullOrEmpty(version))
return null;
if (string.IsNullOrEmpty(version)) return null;
var requested = root.TryGetProperty("requested_channel", out var requestedElement)
? requestedElement.GetString() ?? channel : channel;
var resolved = root.TryGetProperty("resolved_channel", out var resolvedElement)
? resolvedElement.GetString() ?? "stable" : "stable";
var didFallback = root.TryGetProperty("fallback", out var fallbackElement)
? fallbackElement.ValueKind == JsonValueKind.True
: requested != resolved;
var release = new ProReleaseInfo(version, requested, resolved, didFallback);
try
{
Directory.CreateDirectory(Path.GetDirectoryName(marker)!);
var resolutionTmp = resolutionMarker + ".tmp";
File.WriteAllText(resolutionTmp, JsonSerializer.Serialize(new
{
version = release.Version,
requested_channel = release.RequestedChannel,
resolved_channel = release.ResolvedChannel,
fallback = release.Fallback,
}));
File.Move(resolutionTmp, resolutionMarker, true);
var tmp = marker + ".tmp";
File.WriteAllText(tmp, version);
if (File.Exists(marker)) File.Delete(marker);
File.Move(tmp, marker);
File.Move(tmp, marker, true);
}
catch (IOException) { /* non-fatal */ }
return version;
return release;
}
catch (Exception ex)
{
CloakLog.Debug("Pro version check failed: {0}", ex.Message);
return null;
try
{
var version = File.ReadAllText(marker).Trim();
if (string.IsNullOrEmpty(version))
return null;
// Prefer the last successful fetch's channel metadata (the resolution
// sidecar) so an offline preview build is not mislabeled as a stable
// fallback. Older wrappers left only the version marker, so its channel
// is unknowable — hardcode a conservative stable fallback then.
if (File.Exists(resolutionMarker))
{
try
{
using var cachedDoc = JsonDocument.Parse(File.ReadAllText(resolutionMarker));
var root = cachedDoc.RootElement;
var cachedVersion = root.TryGetProperty("version", out var ve)
? ve.GetString() : null;
if (cachedVersion == version)
{
var requested = root.TryGetProperty("requested_channel", out var rq)
? rq.GetString()
: root.TryGetProperty("requestedChannel", out var rqc)
? rqc.GetString() : channel;
var resolved = root.TryGetProperty("resolved_channel", out var rs)
? rs.GetString()
: root.TryGetProperty("resolvedChannel", out var rsc)
? rsc.GetString() : "stable";
requested ??= channel;
resolved ??= "stable";
var didFallback = root.TryGetProperty("fallback", out var fb)
&& (fb.ValueKind == JsonValueKind.True || fb.ValueKind == JsonValueKind.False)
? fb.GetBoolean()
: requested != resolved;
return new ProReleaseInfo(version, requested, resolved, didFallback);
}
}
catch (IOException) { /* unreadable - use conservative default */ }
catch (JsonException) { /* malformed - use conservative default */ }
catch (InvalidOperationException) { /* wrong value kind - use default */ }
}
return new ProReleaseInfo(version, channel, "stable", channel == "preview");
}
catch (IOException)
{
return null;
}
}
}
/// <summary>Get only the version from the server-resolved Pro release.</summary>
public static string? GetProLatestVersion(string? releaseChannel = null) =>
GetProLatestRelease(releaseChannel)?.Version;
/// <summary>
/// How many concurrent sessions (seats) this license is holding right now.
/// </summary>
+5 -2
View File
@@ -281,7 +281,9 @@ internal static class ProxyResolver
}
/// <summary>Resolve a proxy into Playwright options + extra Chrome args (one or both empty).</summary>
public static ProxyResolution Resolve(object? proxy, string? browserVersion = null, string? licenseKey = null)
public static ProxyResolution Resolve(
object? proxy, string? browserVersion = null, string? licenseKey = null,
string? releaseChannel = null)
{
if (proxy == null)
return new ProxyResolution(null, new List<string>());
@@ -316,7 +318,8 @@ internal static class ProxyResolver
string s => HasCredentials(s),
_ => false,
};
if (hasCreds && Config.BinarySupportsHttpProxyInlineAuth(licenseKey, browserVersion))
if (hasCreds && Config.BinarySupportsHttpProxyInlineAuth(
licenseKey, browserVersion, releaseChannel))
{
if (proxy is ProxySettings psd)
{
@@ -180,6 +180,49 @@ public class ConfigTests
}
}
[Collection("env-serial")]
public class ReleaseChannelConfigTests
{
[Fact]
public void NormalizeReleaseChannel_defaults_to_stable()
{
var previous = Environment.GetEnvironmentVariable("CLOAKBROWSER_RELEASE_CHANNEL");
try
{
Environment.SetEnvironmentVariable("CLOAKBROWSER_RELEASE_CHANNEL", null);
Assert.Equal("stable", Config.NormalizeReleaseChannel());
}
finally
{
Environment.SetEnvironmentVariable("CLOAKBROWSER_RELEASE_CHANNEL", previous);
}
}
[Theory]
[InlineData(" preview ", "preview")]
[InlineData("PREVIEW", "preview")]
[InlineData("stable", "stable")]
[InlineData("unknown", "stable")]
public void NormalizeReleaseChannel_normalizes_values(string input, string expected)
=> Assert.Equal(expected, Config.NormalizeReleaseChannel(input));
[Fact]
public void NormalizeReleaseChannel_explicit_stable_overrides_environment()
{
var previous = Environment.GetEnvironmentVariable("CLOAKBROWSER_RELEASE_CHANNEL");
try
{
Environment.SetEnvironmentVariable("CLOAKBROWSER_RELEASE_CHANNEL", "preview");
Assert.Equal("preview", Config.NormalizeReleaseChannel());
Assert.Equal("stable", Config.NormalizeReleaseChannel("stable"));
}
finally
{
Environment.SetEnvironmentVariable("CLOAKBROWSER_RELEASE_CHANNEL", previous);
}
}
}
/// <summary>
/// Config.BinarySupportsHeadlessNoViewport() — parity-critical: Python and JS mirror
/// this gate. Threshold is an unshipped version, so the resolved-version path is a
@@ -55,4 +55,42 @@ public class DiagnosticsTests
try { Directory.Delete(tmp, recursive: true); } catch { /* best-effort */ }
}
}
[Fact]
public void Preview_reports_stable_fallback_and_next_launch_version()
{
var tmp = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName());
Directory.CreateDirectory(tmp);
string? prevCache = Environment.GetEnvironmentVariable("CLOAKBROWSER_CACHE_DIR");
string? prevKey = Environment.GetEnvironmentVariable("CLOAKBROWSER_LICENSE_KEY");
string? prevChannel = Environment.GetEnvironmentVariable("CLOAKBROWSER_RELEASE_CHANNEL");
try
{
Environment.SetEnvironmentVariable("CLOAKBROWSER_CACHE_DIR", tmp);
Environment.SetEnvironmentVariable("CLOAKBROWSER_LICENSE_KEY", "cb_test");
Environment.SetEnvironmentVariable("CLOAKBROWSER_RELEASE_CHANNEL", "preview");
License.ValidateLicenseOverride = _ => new LicenseInfo(true, "business", null);
License.ProLatestReleaseOverride = () =>
new ProReleaseInfo("150.0.7871.114.3", "preview", "stable", true);
License.ActiveSessionCountOverride = _ => null;
var diag = Diagnostics.Collect(quick: false);
var binary = Assert.IsType<Dictionary<string, object?>>(diag["binary"]);
Assert.Equal("preview", binary["requested_channel"]);
Assert.Equal("stable", binary["resolved_channel"]);
Assert.Equal(true, binary["channel_fallback"]);
Assert.Equal("150.0.7871.114.3", binary["version"]);
}
finally
{
Environment.SetEnvironmentVariable("CLOAKBROWSER_CACHE_DIR", prevCache);
Environment.SetEnvironmentVariable("CLOAKBROWSER_LICENSE_KEY", prevKey);
Environment.SetEnvironmentVariable("CLOAKBROWSER_RELEASE_CHANNEL", prevChannel);
License.ValidateLicenseOverride = null;
License.ProLatestReleaseOverride = null;
License.ActiveSessionCountOverride = null;
try { Directory.Delete(tmp, recursive: true); } catch { /* best-effort */ }
}
}
}
+340 -1
View File
@@ -29,6 +29,7 @@ public class LicenseTests : IDisposable
private readonly string? _prevCacheDir;
private readonly string? _prevLicenseEnv;
private readonly string? _prevDownloadUrl;
private readonly string? _prevReleaseChannel;
public LicenseTests()
{
@@ -37,9 +38,11 @@ public class LicenseTests : IDisposable
_prevCacheDir = Environment.GetEnvironmentVariable("CLOAKBROWSER_CACHE_DIR");
_prevLicenseEnv = Environment.GetEnvironmentVariable("CLOAKBROWSER_LICENSE_KEY");
_prevDownloadUrl = Environment.GetEnvironmentVariable("CLOAKBROWSER_DOWNLOAD_URL");
_prevReleaseChannel = Environment.GetEnvironmentVariable("CLOAKBROWSER_RELEASE_CHANNEL");
Environment.SetEnvironmentVariable("CLOAKBROWSER_CACHE_DIR", _tmp);
Environment.SetEnvironmentVariable("CLOAKBROWSER_LICENSE_KEY", null);
Environment.SetEnvironmentVariable("CLOAKBROWSER_DOWNLOAD_URL", null);
Environment.SetEnvironmentVariable("CLOAKBROWSER_RELEASE_CHANNEL", null);
}
public void Dispose()
@@ -47,8 +50,10 @@ public class LicenseTests : IDisposable
Environment.SetEnvironmentVariable("CLOAKBROWSER_CACHE_DIR", _prevCacheDir);
Environment.SetEnvironmentVariable("CLOAKBROWSER_LICENSE_KEY", _prevLicenseEnv);
Environment.SetEnvironmentVariable("CLOAKBROWSER_DOWNLOAD_URL", _prevDownloadUrl);
Environment.SetEnvironmentVariable("CLOAKBROWSER_RELEASE_CHANNEL", _prevReleaseChannel);
License.ValidateLicenseOverride = null;
License.ProLatestVersionOverride = null;
License.ProLatestReleaseOverride = null;
License.ActiveSessionCountOverride = null;
try { if (Directory.Exists(_tmp)) Directory.Delete(_tmp, recursive: true); } catch (IOException) { }
}
@@ -210,8 +215,13 @@ public class LicenseTests : IDisposable
[Fact]
public void ProLatestVersion_rate_limited_reads_marker()
{
var marker = Path.Combine(_tmp, $".last_pro_version_check_{Config.GetPlatformTag()}");
var platform = Config.GetPlatformTag();
var marker = Path.Combine(_tmp, $".last_pro_version_check_{platform}");
File.WriteAllText(marker, "148.0.7778.215.2");
File.WriteAllText(
Path.Combine(_tmp, $".last_pro_version_resolution_{platform}"),
"{\"version\":\"148.0.7778.215.2\",\"requested_channel\":\"stable\","
+ "\"resolved_channel\":\"stable\",\"fallback\":false}");
// Fresh marker (just written) -> returns cached value without server.
Assert.Equal("148.0.7778.215.2", License.GetProLatestVersion());
}
@@ -223,6 +233,169 @@ public class LicenseTests : IDisposable
Assert.Equal("149.0.0.0", License.GetProLatestVersion());
}
[Fact]
public void ProLatestVersion_preview_uses_isolated_endpoint_and_marker()
{
var recorder = new RecordingHandler(
"{\"version\":\"150.0.7871.114.3\",\"requested_channel\":\"preview\","
+ "\"resolved_channel\":\"stable\",\"fallback\":true}");
var original = License.Http;
License.Http = new HttpClient(recorder);
try
{
var release = License.GetProLatestRelease("preview");
Assert.NotNull(release);
Assert.Equal("150.0.7871.114.3", release.Version);
Assert.Equal("stable", release.ResolvedChannel);
Assert.True(release.Fallback);
Assert.EndsWith("?channel=preview", recorder.LastUri);
Assert.Equal(
"150.0.7871.114.3",
File.ReadAllText(Path.Combine(
_tmp, $".last_pro_version_check_preview_{Config.GetPlatformTag()}")));
Assert.False(File.Exists(Path.Combine(
_tmp, $".last_pro_version_check_{Config.GetPlatformTag()}")));
}
finally
{
License.Http.Dispose();
License.Http = original;
}
}
[Fact]
public void ProLatestVersion_old_server_preview_is_stable_fallback()
{
var recorder = new RecordingHandler("{\"version\":\"150.0.7871.114.3\"}");
var original = License.Http;
License.Http = new HttpClient(recorder);
try
{
var release = License.GetProLatestRelease("preview");
Assert.NotNull(release);
Assert.Equal("stable", release.ResolvedChannel);
Assert.True(release.Fallback);
}
finally
{
License.Http.Dispose();
License.Http = original;
}
}
[Fact]
public void ProLatestVersion_reads_legacy_javascript_sidecar()
{
var platform = Config.GetPlatformTag();
File.WriteAllText(
Path.Combine(_tmp, $".last_pro_version_check_preview_{platform}"),
"150.0.7871.114.3");
File.WriteAllText(
Path.Combine(_tmp, $".last_pro_version_resolution_preview_{platform}"),
"{\"version\":\"150.0.7871.114.3\",\"requestedChannel\":\"preview\","
+ "\"resolvedChannel\":\"stable\",\"fallback\":true}");
var release = License.GetProLatestRelease("preview");
Assert.NotNull(release);
Assert.Equal("stable", release.ResolvedChannel);
Assert.True(release.Fallback);
}
[Fact]
public void ProLatestVersion_offline_preview_preserves_sidecar_channel()
{
// Stale marker (rate-limit expired) forces the network path; the server is
// unreachable, so the offline branch must reuse the resolution sidecar
// instead of mislabeling a genuine preview build as a stable fallback.
var platform = Config.GetPlatformTag();
var marker = Path.Combine(_tmp, $".last_pro_version_check_preview_{platform}");
File.WriteAllText(marker, "151.0.7900.10.1");
File.WriteAllText(
Path.Combine(_tmp, $".last_pro_version_resolution_preview_{platform}"),
"{\"version\":\"151.0.7900.10.1\",\"requested_channel\":\"preview\","
+ "\"resolved_channel\":\"preview\",\"fallback\":false}");
File.SetLastWriteTimeUtc(marker, DateTime.UtcNow.AddHours(-2));
var original = License.Http;
License.Http = new HttpClient(new ThrowingHandler());
try
{
var release = License.GetProLatestRelease("preview");
Assert.NotNull(release);
Assert.Equal("151.0.7900.10.1", release.Version);
Assert.Equal("preview", release.ResolvedChannel);
Assert.False(release.Fallback);
}
finally
{
License.Http.Dispose();
License.Http = original;
}
}
[Fact]
public void ProLatestVersion_offline_preview_without_sidecar_is_stable_fallback()
{
var platform = Config.GetPlatformTag();
var marker = Path.Combine(_tmp, $".last_pro_version_check_preview_{platform}");
File.WriteAllText(marker, "151.0.7900.10.1");
File.SetLastWriteTimeUtc(marker, DateTime.UtcNow.AddHours(-2));
var original = License.Http;
License.Http = new HttpClient(new ThrowingHandler());
try
{
var release = License.GetProLatestRelease("preview");
Assert.NotNull(release);
Assert.Equal("stable", release.ResolvedChannel);
Assert.True(release.Fallback);
}
finally
{
License.Http.Dispose();
License.Http = original;
}
}
[Fact]
public void ProLatestVersion_environment_selects_preview()
{
Environment.SetEnvironmentVariable("CLOAKBROWSER_RELEASE_CHANNEL", "preview");
var recorder = new RecordingHandler("{\"version\":\"151.0.1234.5\"}");
var original = License.Http;
License.Http = new HttpClient(recorder);
try
{
License.GetProLatestVersion();
Assert.EndsWith("?channel=preview", recorder.LastUri);
}
finally
{
License.Http.Dispose();
License.Http = original;
}
}
[Fact]
public void ProLatestVersion_explicit_stable_overrides_preview_environment()
{
Environment.SetEnvironmentVariable("CLOAKBROWSER_RELEASE_CHANNEL", "preview");
var recorder = new RecordingHandler("{\"version\":\"150.0.1234.5\"}");
var original = License.Http;
License.Http = new HttpClient(recorder);
try
{
License.GetProLatestVersion("stable");
Assert.Equal(License.ProVersionUrl, recorder.LastUri);
}
finally
{
License.Http.Dispose();
License.Http = original;
}
}
[Fact]
public void ProLatestVersion_sends_platform_header()
{
@@ -248,6 +421,7 @@ public class LicenseTests : IDisposable
{
private readonly string _body;
public string? LastPlatform { get; private set; }
public string? LastUri { get; private set; }
public RecordingHandler(string body) => _body = body;
@@ -258,6 +432,7 @@ public class LicenseTests : IDisposable
LastPlatform = request.Headers.TryGetValues("X-Platform", out var values)
? values.FirstOrDefault()
: null;
LastUri = request.RequestUri?.ToString();
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(_body),
@@ -265,6 +440,14 @@ public class LicenseTests : IDisposable
}
}
/// <summary>Simulates an unreachable server by throwing on every request.</summary>
private sealed class ThrowingHandler : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
=> throw new HttpRequestException("network");
}
// =======================================================================
// GetActiveSessionCount — live seat count
// =======================================================================
@@ -429,6 +612,162 @@ public class LicenseTests : IDisposable
UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute);
}
[Fact]
public void Preview_and_stable_effective_versions_are_isolated()
{
const string stable = "145.0.1000.1";
const string preview = "148.0.7778.215.4";
MakeProBinary(stable);
MakeProBinary(preview);
File.WriteAllText(
Path.Combine(_tmp, $"latest_pro_version_{Config.GetPlatformTag()}"), stable);
File.WriteAllText(
Path.Combine(_tmp, $"latest_pro_version_preview_{Config.GetPlatformTag()}"), preview);
Assert.Equal(stable, Config.GetEffectiveVersion(pro: true, releaseChannel: "stable"));
Assert.Equal(preview, Config.GetEffectiveVersion(pro: true, releaseChannel: "preview"));
Assert.False(Config.BinarySupportsHeadlessNoViewport("cb_key", releaseChannel: "stable"));
Assert.True(Config.BinarySupportsHeadlessNoViewport("cb_key", releaseChannel: "preview"));
Assert.True(Config.BinarySupportsMaximizedWindow("cb_key", releaseChannel: "preview"));
Assert.False(Config.BinarySupportsHttpProxyInlineAuth("cb_key", releaseChannel: "stable"));
Assert.True(Config.BinarySupportsHttpProxyInlineAuth("cb_key", releaseChannel: "preview"));
Assert.Equal(preview, Download.BinaryInfo(releaseChannel: "preview").Version);
var stableProxy = ProxyResolver.Resolve(
"http://user:pass@host:8080", licenseKey: "cb_key", releaseChannel: "stable");
var previewProxy = ProxyResolver.Resolve(
"http://user:pass@host:8080", licenseKey: "cb_key", releaseChannel: "preview");
Assert.NotNull(stableProxy.PlaywrightProxy);
Assert.Empty(stableProxy.ExtraArgs);
Assert.Null(previewProxy.PlaywrightProxy);
Assert.Single(previewProxy.ExtraArgs);
var viewport = CloakLauncher.ResolveContextViewport(new LaunchContextOptions
{
Headless = true,
LicenseKey = "cb_key",
ReleaseChannel = "preview",
});
Assert.Equal(-1, viewport!.Width);
Assert.Equal(-1, viewport.Height);
}
[Fact]
public void BinaryInfo_threads_channel_into_pro_download_url()
{
const string version = "151.0.7900.10.1";
MakeProBinary(version); // same cached binary satisfies both channels
File.WriteAllText(
Path.Combine(_tmp, $"latest_pro_version_preview_{Config.GetPlatformTag()}"), version);
File.WriteAllText(
Path.Combine(_tmp, $"latest_pro_version_{Config.GetPlatformTag()}"), version);
Assert.EndsWith(
"/api/download/latest?channel=preview",
Download.BinaryInfo(releaseChannel: "preview").DownloadUrl);
var stableUrl = Download.BinaryInfo(releaseChannel: "stable").DownloadUrl;
Assert.EndsWith("/api/download/latest", stableUrl);
Assert.DoesNotContain("channel=preview", stableUrl);
}
[Fact]
public void Pinned_preview_launch_bypasses_latest_lookup_and_markers()
{
const string pinned = "151.0.1000.1";
MakeProBinary(pinned);
License.ValidateLicenseOverride = _ => new LicenseInfo(true, "solo", null);
License.ProLatestVersionOverride = () => throw new InvalidOperationException("latest lookup must not run");
Assert.Equal(
Config.GetBinaryPath(pinned, pro: true),
Download.EnsureBinary("cb_key", pinned, "preview"));
Assert.False(File.Exists(Path.Combine(
_tmp, $"latest_pro_version_preview_{Config.GetPlatformTag()}")));
Assert.False(File.Exists(Path.Combine(
_tmp, $"latest_pro_version_{Config.GetPlatformTag()}")));
}
[Fact]
public void Preview_without_preview_build_warns_stable_fallback()
{
const string stable = "150.0.1000.1";
MakeProBinary(stable); // stable-fallback build already cached → no download
License.ValidateLicenseOverride = _ => new LicenseInfo(true, "solo", null);
License.ProLatestReleaseOverride =
() => new ProReleaseInfo(stable, "preview", "stable", true);
Download.ResetPreviewFallbackWarned();
var sw = new StringWriter();
var orig = Console.Error;
Console.SetError(sw);
try
{
Download.EnsureBinary("cb_key", null, "preview");
}
finally
{
Console.SetError(orig);
}
Assert.Contains("no preview build is available", sw.ToString());
}
[Fact]
public void Genuine_preview_build_does_not_warn()
{
const string preview = "151.0.1000.1";
MakeProBinary(preview);
License.ValidateLicenseOverride = _ => new LicenseInfo(true, "solo", null);
License.ProLatestReleaseOverride =
() => new ProReleaseInfo(preview, "preview", "preview", false);
Download.ResetPreviewFallbackWarned();
var sw = new StringWriter();
var orig = Console.Error;
Console.SetError(sw);
try
{
Download.EnsureBinary("cb_key", null, "preview");
}
finally
{
Console.SetError(orig);
}
Assert.DoesNotContain("no preview build is available", sw.ToString());
}
[Fact]
public void CheckForProUpdate_preview_advances_only_preview_marker()
{
const string stable = "150.0.1000.1";
const string preview = "151.0.1000.1";
MakeProBinary(stable);
MakeProBinary(preview);
var stableMarker = Path.Combine(_tmp, $"latest_pro_version_{Config.GetPlatformTag()}");
File.WriteAllText(stableMarker, stable);
License.ProLatestVersionOverride = () => preview;
Assert.Equal(preview, Download.CheckForProUpdate("cb_key", "preview"));
Assert.Equal(stable, File.ReadAllText(stableMarker));
Assert.Equal(
preview,
File.ReadAllText(Path.Combine(
_tmp, $"latest_pro_version_preview_{Config.GetPlatformTag()}")));
}
[Fact]
public void ReleaseChannel_api_shape_preserves_cancellation_token_positions()
{
Assert.Null(new LaunchOptions().ReleaseChannel);
var ensureParams = typeof(Download).GetMethod(nameof(Download.EnsureBinaryAsync))!.GetParameters();
Assert.Equal(typeof(CancellationToken), ensureParams[2].ParameterType);
Assert.Equal("releaseChannel", ensureParams[3].Name);
var updateParams = typeof(Download).GetMethod(nameof(Download.CheckForProUpdateAsync))!.GetParameters();
Assert.Equal(typeof(CancellationToken), updateParams[1].ParameterType);
Assert.Equal("releaseChannel", updateParams[2].Name);
}
[Fact]
public void CheckForProUpdate_already_latest_returns_null()
{
+20
View File
@@ -106,6 +106,12 @@ const browser = await launch({
// Pin an exact Chromium version (Free or Pro)
const browser = await launch({ browserVersion: '146.0.7680.177.5' });
// Opt into the Pro Preview binary channel
const browser = await launch({
licenseKey: 'cb_xxxxxxxx',
releaseChannel: 'preview',
});
// Auto-detect timezone/locale from proxy IP (requires: npm install mmdb-lib)
const browser = await launch({
proxy: 'http://proxy:8080',
@@ -212,6 +218,7 @@ if (newVersion) console.log(`Updated to ${newVersion}`);
| `CLOAKBROWSER_WIDEVINE_CDM` | — | Path to a sideloaded `WidevineCdm` directory (overrides auto-detection next to the binary) |
| `CLOAKBROWSER_WIDEVINE` | `1` | Set to `0` to disable automatic Widevine hint-file seeding for persistent contexts |
| `CLOAKBROWSER_VERSION` | — | Pin to an exact Chromium version for rollback (e.g. `146.0.7680.177.5`). Works with Free and Pro binaries |
| `CLOAKBROWSER_RELEASE_CHANNEL` | `stable` | Set to `preview` to opt into the Pro Preview binary channel |
### Widevine / DRM
@@ -321,6 +328,19 @@ Other tips for maximizing reCAPTCHA scores:
- **Minimize `page.evaluate()` calls** before the reCAPTCHA check fires — each one sends CDP traffic
**Try a Preview binary**
Stable is the default. Opt into Preview per launch:
```javascript
const browser = await launch({
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. `npx cloakbrowser info` reports the resolved channel and exact version. An exact `browserVersion` pin overrides the channel.
**New update broke something? Roll back to the previous version**
Pin to an exact binary version (keep current wrapper, use older Chromium):
+5 -1
View File
@@ -83,7 +83,11 @@ export function buildArgs(options: LaunchOptions): string[] {
if (
!explicitViewport &&
!hasWindowFlag &&
binarySupportsMaximizedWindow(options.licenseKey, options.browserVersion)
binarySupportsMaximizedWindow(
options.licenseKey,
options.browserVersion,
options.releaseChannel,
)
) {
seen.set("--start-maximized", "--start-maximized");
}
+54 -16
View File
@@ -18,11 +18,13 @@ import {
getBinaryPath,
getBinaryDir,
getEffectiveVersion,
normalizeReleaseChannel,
normalizeRequestedVersion,
versionNewer,
CHROMIUM_VERSION,
} from "./config.js";
import { countFontsPresent, WINDOWS_FONT_TELLS, OFFICE_FONT_TELLS } from "./fonts.js";
import { resolveLicenseKey, validateLicense, getProLatestVersion, getActiveSessionCount, type LicenseInfo } from "./license.js";
import { resolveLicenseKey, validateLicense, getProLatestRelease, getActiveSessionCount, type LicenseInfo } from "./license.js";
import { execFileSync, spawn } from "node:child_process";
import { createRequire } from "node:module";
import { pathToFileURL } from "node:url";
@@ -138,30 +140,53 @@ async function effectiveBinary(
};
}
const requested = normalizeRequestedVersion();
const requestedChannel = normalizeReleaseChannel();
// For a Pro license, surface the server's latest separately from the version
// that will actually launch, so `info` can never silently diverge from launch
// (the divergence a customer hit: info showed latest, launch ran a stale cache).
// --quick keeps `info` fully network-free (skip the server latest lookup).
let latestVersion: string | null = null;
let resolvedChannel: "stable" | "preview" | null = null;
let channelFallback = false;
if (entitledPro && !quick) {
latestVersion = await getProLatestVersion();
const latestRelease = await getProLatestRelease(requestedChannel);
if (latestRelease) {
latestVersion = latestRelease.version;
resolvedChannel = latestRelease.resolvedChannel;
channelFallback = latestRelease.fallback;
}
}
let version: string | null;
let installedVersion: string | null = null;
if (requested) {
version = requested;
} else if (entitledPro) {
// "Will launch now" is the cached Pro build; if none is cached, the next launch
// downloads latestVersion. getEffectiveVersion(true) returns null (never free).
version = getEffectiveVersion(true) ?? latestVersion;
// Mirror ensureBinary: report what the next launch resolves to, while
// CLOAKBROWSER_AUTO_UPDATE=false retains an installed channel build.
installedVersion = getEffectiveVersion(true, requestedChannel);
const autoUpdate = (process.env.CLOAKBROWSER_AUTO_UPDATE ?? "true").toLowerCase();
const updatesEnabled = autoUpdate !== "false";
if (installedVersion && !updatesEnabled) {
version = installedVersion;
} else if (latestVersion && (!installedVersion || versionNewer(latestVersion, installedVersion))) {
version = latestVersion;
} else {
version = installedVersion ?? latestVersion;
}
} else {
version = getEffectiveVersion(false);
installedVersion = version;
}
const binPath = version ? getBinaryPath(version, entitledPro) : null;
return {
version,
latest_version: latestVersion,
requested_channel: requestedChannel,
resolved_channel: resolvedChannel,
channel_fallback: channelFallback,
installed_version: installedVersion,
pinned: Boolean(requested),
tier: entitledPro ? "pro" : "free",
bundled_version: CHROMIUM_VERSION,
@@ -262,25 +287,38 @@ function printDiagnostics(diag: Record<string, any>): void {
} else {
if (binary.tier === "override") {
console.log("Version: set via CLOAKBROWSER_BINARY_PATH (see Launch line)");
} else if (binary.latest_version) {
} else {
if (binary.channel_fallback) {
console.log("Channel: Preview → Stable fallback");
} else {
const channel = binary.resolved_channel ?? binary.requested_channel ?? "stable";
console.log(`Channel: ${channel.charAt(0).toUpperCase()}${channel.slice(1)}`);
}
if (binary.latest_version) {
// Pro: show what launches now AND the server's latest, so the two can't diverge.
console.log(`Version: ${binary.version} (${binary.tier}) — will launch`);
if (binary.latest_version === binary.version) {
console.log(`Version: ${binary.version} (${binary.tier}) — next launch`);
if (binary.latest_version === binary.version && binary.installed) {
console.log(`Latest: ${binary.latest_version} (up to date)`);
} else if (binary.latest_version === binary.version) {
console.log(`Latest: ${binary.latest_version} (downloads on next launch)`);
} else if (binary.pinned) {
console.log(
`Latest: ${binary.latest_version} (available — pinned; unset CLOAKBROWSER_VERSION to upgrade)`
);
} else {
console.log(`Latest: ${binary.latest_version} (available — next launch upgrades)`);
console.log(`Latest: ${binary.latest_version} (server-resolved; installed build retained)`);
}
if (binary.installed_version && binary.installed_version !== binary.version) {
console.log(`Installed: ${binary.installed_version}`);
}
} else if (binary.version === null) {
// Pro with no cached build and no server answer (e.g. offline).
console.log(
`Version: not downloaded yet (${binary.tier}) — next launch downloads the latest`
);
} else {
console.log(`Version: ${binary.version} (${binary.tier})`);
}
} else if (binary.version === null) {
// Pro with no cached build and no server answer (e.g. offline).
console.log(
`Version: not downloaded yet (${binary.tier}) — next launch downloads the latest`
);
} else {
console.log(`Version: ${binary.version} (${binary.tier})`);
}
console.log(`Binary: ${binary.path}`);
console.log(`Installed: ${binary.installed}`);
+19 -7
View File
@@ -67,6 +67,11 @@ const AVAILABLE_PLATFORMS = new Set(Object.keys(PLATFORM_CHROMIUM_VERSIONS));
const VERSION_PIN_RE = /^[0-9]+(?:\.[0-9]+){3,4}$/;
export function normalizeReleaseChannel(releaseChannel?: string): "stable" | "preview" {
const raw = releaseChannel ?? process.env.CLOAKBROWSER_RELEASE_CHANNEL ?? "stable";
return raw.trim().toLowerCase() === "preview" ? "preview" : "stable";
}
export function normalizeRequestedVersion(version?: string): string | undefined {
const raw = version ?? process.env.CLOAKBROWSER_VERSION;
if (raw == null) return undefined;
@@ -176,9 +181,9 @@ export function getFallbackDownloadUrl(version?: string): string {
return `${GITHUB_DOWNLOAD_BASE_URL}/chromium-v${v}/${getArchiveName()}`;
}
export function getEffectiveVersion(pro?: false): string;
export function getEffectiveVersion(pro: boolean): string | null;
export function getEffectiveVersion(pro = false): string | null {
export function getEffectiveVersion(pro?: false, releaseChannel?: string): string;
export function getEffectiveVersion(pro: boolean, releaseChannel?: string): string | null;
export function getEffectiveVersion(pro = false, releaseChannel?: string): string | null {
const base = getChromiumVersion();
const cacheDir = getCacheDir();
@@ -186,7 +191,11 @@ export function getEffectiveVersion(pro = false): string | null {
// A valid Pro license must NEVER fall back to the free binary, so there is
// deliberately no free-version fallback here — return null when no cached Pro
// binary matches the marker; callers resolve the latest Pro version instead.
const marker = path.join(cacheDir, `latest_pro_version_${getPlatformTag()}`);
const channel = normalizeReleaseChannel(releaseChannel);
const markerPrefix = channel === "preview"
? "latest_pro_version_preview"
: "latest_pro_version";
const marker = path.join(cacheDir, `${markerPrefix}_${getPlatformTag()}`);
try {
if (fs.existsSync(marker)) {
const version = fs.readFileSync(marker, "utf-8").trim();
@@ -270,6 +279,7 @@ export const HEADLESS_NO_VIEWPORT_MIN_VERSION: string | null = "148.0.7778.215.4
export function binarySupportsHeadlessNoViewport(
licenseKey?: string,
browserVersion?: string,
releaseChannel?: string,
): boolean {
if (HEADLESS_NO_VIEWPORT_MIN_VERSION === null) return false;
// A declared version (browserVersion arg OR CLOAKBROWSER_VERSION env) wins even
@@ -290,7 +300,7 @@ export function binarySupportsHeadlessNoViewport(
// Full resolution (param > env > ~/.cloakbrowser/license.key) — mirrors Python
// and .NET; a bare env/param check would miss file-based Pro keys.
const pro = Boolean(resolveLicenseKey(licenseKey));
version = getEffectiveVersion(pro);
version = getEffectiveVersion(pro, releaseChannel);
}
// No cached Pro build resolvable (getEffectiveVersion returned null) → fail safe.
if (version === null) return false;
@@ -316,8 +326,9 @@ export function binarySupportsHeadlessNoViewport(
export function binarySupportsMaximizedWindow(
licenseKey?: string,
browserVersion?: string,
releaseChannel?: string,
): boolean {
return binarySupportsHeadlessNoViewport(licenseKey, browserVersion);
return binarySupportsHeadlessNoViewport(licenseKey, browserVersion, releaseChannel);
}
// ---------------------------------------------------------------------------
@@ -355,6 +366,7 @@ export const HTTP_PROXY_INLINE_AUTH_MIN_VERSION: Record<string, string> = {
export function binarySupportsHttpProxyInlineAuth(
licenseKey?: string,
browserVersion?: string,
releaseChannel?: string,
): boolean {
let tag: string;
try {
@@ -377,7 +389,7 @@ export function binarySupportsHttpProxyInlineAuth(
return false;
} else {
const pro = Boolean(resolveLicenseKey(licenseKey));
version = getEffectiveVersion(pro);
version = getEffectiveVersion(pro, releaseChannel);
}
if (version === null) return false;
try {
+71 -20
View File
@@ -31,10 +31,11 @@ import {
getFallbackDownloadUrl,
getLocalBinaryOverride,
getPlatformTag,
normalizeReleaseChannel,
normalizeRequestedVersion,
versionNewer,
} from "./config.js";
import { resolveLicenseKey, validateLicense, getProLatestVersion } from "./license.js";
import { resolveLicenseKey, validateLicense, getProLatestVersion, getProLatestRelease } from "./license.js";
const DOWNLOAD_TIMEOUT_MS = 600_000; // 10 minutes
// Seconds, matching the Python/.NET `.last_update_check` marker format so the
@@ -72,7 +73,11 @@ export class BinaryVerificationError extends Error {
* Ensure the stealth Chromium binary is available. Download if needed.
* Returns the path to the chrome executable.
*/
export async function ensureBinary(licenseKey?: string, browserVersion?: string): Promise<string> {
export async function ensureBinary(
licenseKey?: string,
browserVersion?: string,
releaseChannel?: string,
): Promise<string> {
// Check for local override
const localOverride = getLocalBinaryOverride();
if (localOverride) {
@@ -103,7 +108,12 @@ export async function ensureBinary(licenseKey?: string, browserVersion?: string)
// a routine update never reaches here: ensureProBinary returns the cached
// Pro binary and updates in the background.)
try {
return await ensureProBinary(effectiveKey, proVersion, info.plan);
return await ensureProBinary(
effectiveKey,
proVersion,
info.plan,
releaseChannel,
);
} catch (e) {
// Authenticity could not be confirmed — surface verbatim.
if (e instanceof BinaryVerificationError) throw e;
@@ -205,13 +215,16 @@ export function clearCache(): void {
* effectively running the free binary, and the active key may differ from the
* cached one.
*/
export function binaryInfo(browserVersion?: string): BinaryInfo {
export function binaryInfo(
browserVersion?: string,
releaseChannel?: string,
): BinaryInfo {
// browserVersion (or CLOAKBROWSER_VERSION) pins the reported version so the
// info matches what a pinned launch actually runs, instead of latest.
const requested = normalizeRequestedVersion(browserVersion);
// Prefer Pro only if a Pro binary actually exists on disk. getEffectiveVersion
// returns null for Pro when nothing is cached (it never falls back to free).
const proVersion = requested ?? getEffectiveVersion(true);
const proVersion = requested ?? getEffectiveVersion(true, releaseChannel);
const isPro = proBinaryReady(proVersion);
const effective = isPro ? proVersion : (requested ?? getEffectiveVersion(false));
@@ -224,7 +237,9 @@ export function binaryInfo(browserVersion?: string): BinaryInfo {
binaryPath,
installed: fs.existsSync(binaryPath),
cacheDir: getBinaryDir(effective, isPro),
downloadUrl: isPro ? `${DOWNLOAD_BASE_URL}/api/download/latest` : getDownloadUrl(effective),
downloadUrl: isPro
? `${DOWNLOAD_BASE_URL}/api/download/latest${normalizeReleaseChannel(releaseChannel) === "preview" ? "?channel=preview" : ""}`
: getDownloadUrl(effective),
};
}
@@ -246,16 +261,19 @@ export async function checkForUpdate(): Promise<string | null> {
}
/**
* Move a Pro install to the server's latest stable. Blocks until complete.
* Move a Pro install to the selected channel's latest. Blocks until complete.
* Returns the new version when a newer Pro build is downloaded or an
* already-cached newer build is activated, else null (already up to date or the
* server could not be reached). Requires a valid Pro license key.
*/
export async function checkForProUpdate(licenseKey: string): Promise<string | null> {
const latest = await getProLatestVersion();
export async function checkForProUpdate(
licenseKey: string,
releaseChannel?: string,
): Promise<string | null> {
const latest = await getProLatestVersion(releaseChannel);
if (!latest) return null;
const effective = getEffectiveVersion(true);
const effective = getEffectiveVersion(true, releaseChannel);
if (effective && !versionNewer(latest, effective) && proBinaryReady(effective)) {
// Already on the latest cached Pro build.
return null;
@@ -271,7 +289,7 @@ export async function checkForProUpdate(licenseKey: string): Promise<string | nu
}
}
writeProVersionMarker(latest);
writeProVersionMarker(latest, releaseChannel);
return latest;
}
@@ -309,6 +327,25 @@ export function welcomeDue(marker: string, pro: boolean): boolean {
* "free" — a free GitHub key (latest binary, one concurrent session)
* "keyless" — no key, running the older free binary (invite the free login)
*/
let previewFallbackWarned = false;
/** @internal Exported for testing only. */
export function resetPreviewFallbackWarned(): void {
previewFallbackWarned = false;
}
/**
* Tell the user, once per process, that a requested Preview build does not exist
* for this platform and the Stable build is being used instead.
*/
function warnPreviewFallback(): void {
if (previewFallbackWarned) return;
previewFallbackWarned = true;
console.error(
`[cloakbrowser] Preview channel requested, but no preview build is available for ${getPlatformTag()}; using the stable build.`,
);
}
function showWelcome(tier = "keyless"): void {
const marker = path.join(getCacheDir(), ".welcome_shown");
if (!welcomeDue(marker, tier === "pro")) return;
@@ -718,11 +755,15 @@ function proBinaryReady(version: string | null): version is string {
return fs.existsSync(p) && isExecutable(p);
}
/** Atomically write the latest Pro version marker for this platform. */
function writeProVersionMarker(version: string): void {
/** Atomically write the selected channel's Pro version marker for this platform. */
function writeProVersionMarker(version: string, releaseChannel?: string): void {
const cacheDir = getCacheDir();
fs.mkdirSync(cacheDir, { recursive: true });
const marker = path.join(cacheDir, `latest_pro_version_${getPlatformTag()}`);
const channel = normalizeReleaseChannel(releaseChannel);
const markerPrefix = channel === "preview"
? "latest_pro_version_preview"
: "latest_pro_version";
const marker = path.join(cacheDir, `${markerPrefix}_${getPlatformTag()}`);
const tmp = `${marker}.tmp`;
fs.writeFileSync(tmp, version);
fs.renameSync(tmp, marker);
@@ -734,7 +775,8 @@ function writeProVersionMarker(version: string): void {
async function ensureProBinary(
licenseKey: string,
requestedVersion?: string,
plan?: string
plan?: string,
releaseChannel?: string,
): Promise<string> {
// Banner tier: a free GitHub key gets the free banner, paid keys the Pro one.
const welcomeTier = plan === "free" ? "free" : "pro";
@@ -758,8 +800,8 @@ async function ensureProBinary(
return p;
}
// Unpinned: track the server's latest stable.
const effective = getEffectiveVersion(true);
// Unpinned: track the server's latest selected channel.
const effective = getEffectiveVersion(true, releaseChannel);
// Honor CLOAKBROWSER_AUTO_UPDATE=false the way the free path does: frozen AND a
// cached Pro build present → keep it, skip the server check. With no cached build
@@ -774,7 +816,16 @@ async function ensureProBinary(
// getProLatestVersion() is rate-limited to one network call per hour and returns
// a cached string in between, so this foreground check stays cheap steady-state.
const latest = await getProLatestVersion();
const latest = await getProLatestVersion(releaseChannel);
// Tell the user when they asked for Preview but the server has no preview build
// for this platform, so the Stable build is used instead. The lookup is a cache
// hit (getProLatestVersion above already populated it), so this only touches the
// network on the once-an-hour refresh.
if (normalizeReleaseChannel(releaseChannel) === "preview") {
const release = await getProLatestRelease(releaseChannel);
if (release?.fallback) warnPreviewFallback();
}
// Prefer the server's latest when it is newer than — or replaces a missing —
// the cached build. Otherwise stay on the cached Pro binary (fast, offline-ok).
@@ -801,7 +852,7 @@ async function ensureProBinary(
// launch — never a stale marker.
if (version !== effective) {
try {
writeProVersionMarker(version);
writeProVersionMarker(version, releaseChannel);
} catch {
// Non-fatal
}
@@ -840,7 +891,7 @@ async function ensureProBinary(
// Advance the marker so future unpinned launches use this build.
try {
writeProVersionMarker(version);
writeProVersionMarker(version, releaseChannel);
} catch {
// Non-fatal
}
+103 -19
View File
@@ -11,7 +11,7 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { getCacheDir, getPlatformTag } from "./config.js";
import { getCacheDir, getPlatformTag, normalizeReleaseChannel } from "./config.js";
const VALIDATE_URL = "https://cloakbrowser.dev/api/license/validate";
const PRO_VERSION_URL = "https://cloakbrowser.dev/api/download/version";
@@ -26,6 +26,13 @@ export interface LicenseInfo {
expires: string | null;
}
export interface ProReleaseInfo {
version: string;
requestedChannel: "stable" | "preview";
resolvedChannel: "stable" | "preview";
fallback: boolean;
}
/**
* The Pro binary refused to run for a license reason. Thrown when a launch
* fails and the browser process exited with one of the Pro binary's license
@@ -268,56 +275,133 @@ export async function validateLicense(licenseKey: string): Promise<LicenseInfo |
}
}
/**
* Get the latest Pro binary version from the server.
* Rate-limited to 1 call per hour via a marker file.
*/
export async function getProLatestVersion(): Promise<string | null> {
const marker = path.join(
/** Get the server-resolved Pro release and channel for this platform. */
export async function getProLatestRelease(releaseChannel?: string): Promise<ProReleaseInfo | null> {
const channel = normalizeReleaseChannel(releaseChannel);
const markerSuffix = channel === "preview"
? `preview_${getPlatformTag()}`
: getPlatformTag();
const marker = path.join(getCacheDir(), `.last_pro_version_check_${markerSuffix}`);
const resolutionMarker = path.join(
getCacheDir(),
`.last_pro_version_check_${getPlatformTag()}`,
`.last_pro_version_resolution_${markerSuffix}`,
);
try {
if (fs.existsSync(marker)) {
if (fs.existsSync(marker) && fs.existsSync(resolutionMarker)) {
const stats = fs.statSync(marker);
const age = Date.now() - stats.mtimeMs;
if (age < PRO_VERSION_CHECK_INTERVAL_MS) {
const content = fs.readFileSync(marker, "utf-8").trim();
return content || null;
const version = fs.readFileSync(marker, "utf-8").trim();
const cached = JSON.parse(fs.readFileSync(resolutionMarker, "utf-8")) as {
version?: string;
requested_channel?: "stable" | "preview";
resolved_channel?: "stable" | "preview";
requestedChannel?: "stable" | "preview";
resolvedChannel?: "stable" | "preview";
fallback?: boolean;
};
if (version && cached.version === version) {
const requestedChannel = cached.requested_channel ?? cached.requestedChannel ?? channel;
const resolvedChannel = cached.resolved_channel ?? cached.resolvedChannel ?? "stable";
return {
version,
requestedChannel,
resolvedChannel,
fallback: cached.fallback ?? requestedChannel !== resolvedChannel,
};
}
}
}
} catch {
// Marker unreadable — proceed with fetch
// Cache unreadable — proceed with fetch.
}
try {
const resp = await fetch(PRO_VERSION_URL, {
const versionUrl = channel === "preview"
? `${PRO_VERSION_URL}?channel=preview`
: PRO_VERSION_URL;
const resp = await fetch(versionUrl, {
headers: { "X-Platform": getPlatformTag() },
signal: AbortSignal.timeout(10_000),
});
if (!resp.ok) {
throw new Error(`HTTP ${resp.status} ${resp.statusText}`);
}
if (!resp.ok) throw new Error(`HTTP ${resp.status} ${resp.statusText}`);
const data = (await resp.json()) as Record<string, unknown>;
const version = data.version != null ? String(data.version) : null;
if (!version) return null;
const requestedChannel = data.requested_channel === "preview" ? "preview" : channel;
const resolvedChannel = data.resolved_channel === "preview" ? "preview" : "stable";
const release: ProReleaseInfo = {
version,
requestedChannel,
resolvedChannel,
fallback: typeof data.fallback === "boolean"
? data.fallback
: requestedChannel !== resolvedChannel,
};
try {
fs.mkdirSync(path.dirname(marker), { recursive: true });
fs.writeFileSync(resolutionMarker, JSON.stringify({
version: release.version,
requested_channel: release.requestedChannel,
resolved_channel: release.resolvedChannel,
fallback: release.fallback,
}));
fs.writeFileSync(marker, version);
} catch {
// Non-fatal
}
return version;
return release;
} catch {
return null;
try {
const version = fs.readFileSync(marker, "utf-8").trim();
if (!version) return null;
// Prefer the last successful fetch's channel metadata (the resolution
// sidecar) so an offline preview build is not mislabeled as a stable
// fallback. Older wrappers left only the version marker, so its channel is
// unknowable — hardcode a conservative stable fallback then.
try {
const cached = JSON.parse(fs.readFileSync(resolutionMarker, "utf-8")) as {
version?: string;
requested_channel?: "stable" | "preview";
resolved_channel?: "stable" | "preview";
requestedChannel?: "stable" | "preview";
resolvedChannel?: "stable" | "preview";
fallback?: boolean;
};
if (cached.version === version) {
const requestedChannel = cached.requested_channel ?? cached.requestedChannel ?? channel;
const resolvedChannel = cached.resolved_channel ?? cached.resolvedChannel ?? "stable";
return {
version,
requestedChannel,
resolvedChannel,
fallback: cached.fallback ?? requestedChannel !== resolvedChannel,
};
}
} catch {
// Sidecar missing or unreadable — fall through to the conservative default.
}
return {
version,
requestedChannel: channel,
resolvedChannel: "stable",
fallback: channel === "preview",
};
} catch {
return null;
}
}
}
/** Get only the version from the server-resolved Pro release. */
export async function getProLatestVersion(releaseChannel?: string): Promise<string | null> {
return (await getProLatestRelease(releaseChannel))?.version ?? null;
}
/**
* How many concurrent sessions (seats) this license is holding right now.
*
+32 -6
View File
@@ -84,7 +84,11 @@ export function buildContextOptions(
// deterministic. Explicit viewport (incl. null) is always honored.
const headless = effectiveHeadless(options);
const headlessNoViewport =
headless && binarySupportsHeadlessNoViewport(options.licenseKey, options.browserVersion);
headless && binarySupportsHeadlessNoViewport(
options.licenseKey,
options.browserVersion,
options.releaseChannel,
);
const viewport =
options.viewport !== undefined
? options.viewport
@@ -112,9 +116,18 @@ export async function buildLaunchOptions(
): Promise<PlaywrightLaunchOptions> {
const binaryPath =
process.env.CLOAKBROWSER_BINARY_PATH ||
(await ensureBinary(options.licenseKey, options.browserVersion));
(await ensureBinary(
options.licenseKey,
options.browserVersion,
options.releaseChannel,
));
const { exitIp, ...resolved } = await maybeResolveGeoip(options);
const { proxyOption, proxyArgs } = resolveProxyConfig(options.proxy, options.browserVersion, options.licenseKey);
const { proxyOption, proxyArgs } = resolveProxyConfig(
options.proxy,
options.browserVersion,
options.licenseKey,
options.releaseChannel,
);
let resolvedArgs = await resolveWebrtcArgs(options);
resolvedArgs = appendWebrtcExitIp(resolvedArgs, exitIp);
const args = buildArgs({ ...options, ...resolved, args: [...(resolvedArgs ?? []), ...proxyArgs] });
@@ -187,7 +200,11 @@ export async function launch(options: LaunchOptions = {}): Promise<Browser> {
// older headless keeps Playwright's default viewport. Mirrors Python launch().
if (
!effectiveHeadless(options) ||
binarySupportsHeadlessNoViewport(options.licenseKey, options.browserVersion)
binarySupportsHeadlessNoViewport(
options.licenseKey,
options.browserVersion,
options.releaseChannel,
)
) {
applyDefaultNoViewport(browser);
}
@@ -301,9 +318,18 @@ export async function launchPersistentContext(
const binaryPath =
process.env.CLOAKBROWSER_BINARY_PATH ||
(await ensureBinary(options.licenseKey, options.browserVersion));
(await ensureBinary(
options.licenseKey,
options.browserVersion,
options.releaseChannel,
));
const { exitIp, ...resolved } = await maybeResolveGeoip(options);
const { proxyOption, proxyArgs } = resolveProxyConfig(options.proxy, options.browserVersion, options.licenseKey);
const { proxyOption, proxyArgs } = resolveProxyConfig(
options.proxy,
options.browserVersion,
options.licenseKey,
options.releaseChannel,
);
let resolvedArgs = await resolveWebrtcArgs(options);
resolvedArgs = appendWebrtcExitIp(resolvedArgs, exitIp);
const args = buildArgs({ ...options, ...resolved, args: [...(resolvedArgs ?? []), ...proxyArgs] });
+6 -2
View File
@@ -242,7 +242,8 @@ export function normalizeHttpStringUrl(urlStr: string): string {
export function resolveProxyConfig(
proxy: string | ProxyDict | undefined,
browserVersion?: string,
licenseKey?: string
licenseKey?: string,
releaseChannel?: string,
): ProxyConfig {
if (!proxy) return { proxyArgs: [] };
@@ -263,7 +264,10 @@ export function resolveProxyConfig(
// use Chrome's native proxy authentication path instead of Playwright's CDP
// auth interceptor (#182). Older binaries (free macOS/linux-arm64) can't parse
// inline credentials, so they fall through to the Playwright proxy dict below.
if (hasCredentials(proxy) && binarySupportsHttpProxyInlineAuth(licenseKey, browserVersion)) {
if (
hasCredentials(proxy) &&
binarySupportsHttpProxyInlineAuth(licenseKey, browserVersion, releaseChannel)
) {
if (typeof proxy === "string") {
return { proxyArgs: [`--proxy-server=${normalizeHttpStringUrl(proxy)}`] };
}
+26 -4
View File
@@ -40,7 +40,14 @@ function resolveDefaultViewport(options: LaunchOptions): { width: number; height
const headless = options.headless ?? true;
// Headed and newer headless binaries: null (no emulation, coherent dimensions).
// Older headless binaries: a fixed viewport keeps dimensions coherent.
if (!headless || binarySupportsHeadlessNoViewport(options.licenseKey, options.browserVersion)) {
if (
!headless ||
binarySupportsHeadlessNoViewport(
options.licenseKey,
options.browserVersion,
options.releaseChannel,
)
) {
return null;
}
return DEFAULT_VIEWPORT;
@@ -50,7 +57,11 @@ function resolveDefaultViewport(options: LaunchOptions): { width: number; height
async function resolveArgs(options: LaunchOptions): Promise<{ binaryPath: string; args: string[] }> {
const binaryPath =
process.env.CLOAKBROWSER_BINARY_PATH ||
(await ensureBinary(options.licenseKey, options.browserVersion));
(await ensureBinary(
options.licenseKey,
options.browserVersion,
options.releaseChannel,
));
const { exitIp, ...resolved } = (await maybeResolveGeoip(options)) ?? {};
let resolvedArgs = (await resolveWebrtcArgs(options)) ?? options.args;
@@ -71,13 +82,24 @@ function resolveProxy(options: LaunchOptions, args: string[]): { username: strin
if (!options.proxy) return undefined;
if (isSocksProxy(options.proxy)) {
const { proxyArgs } = resolveProxyConfig(options.proxy, options.browserVersion);
const { proxyArgs } = resolveProxyConfig(
options.proxy,
options.browserVersion,
options.licenseKey,
options.releaseChannel,
);
args.push(...proxyArgs);
return undefined;
}
// On binaries that ship inline proxy auth: pass full URL with inline creds.
if (binarySupportsHttpProxyInlineAuth(options.licenseKey, options.browserVersion)) {
if (
binarySupportsHttpProxyInlineAuth(
options.licenseKey,
options.browserVersion,
options.releaseChannel,
)
) {
if (typeof options.proxy === "string") {
args.push(`--proxy-server=${normalizeHttpStringUrl(options.proxy)}`);
return undefined;
+2
View File
@@ -31,6 +31,8 @@ export interface LaunchOptions {
licenseKey?: string;
/** Exact Chromium version pin. Also reads from CLOAKBROWSER_VERSION env var. */
browserVersion?: string;
/** Binary release channel. Also reads from CLOAKBROWSER_RELEASE_CHANNEL env var. */
releaseChannel?: "stable" | "preview";
/** Raw options passed directly to playwright/puppeteer launch(). */
launchOptions?: Record<string, unknown>;
/** Enable human-like mouse, keyboard, and scroll behavior. */
+30 -1
View File
@@ -1,7 +1,8 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import * as license from "../src/license.js";
// collectDiagnostics reads the cache dir (license key file, binary path) and,
// in --quick mode, never spawns the binary — so an isolated temp cache dir is
@@ -15,11 +16,14 @@ beforeEach(() => {
process.env.CLOAKBROWSER_CACHE_DIR = tmpDir;
delete process.env.CLOAKBROWSER_LICENSE_KEY;
delete process.env.CLOAKBROWSER_BINARY_PATH;
delete process.env.CLOAKBROWSER_RELEASE_CHANNEL;
});
afterEach(() => {
if (prevCache === undefined) delete process.env.CLOAKBROWSER_CACHE_DIR;
else process.env.CLOAKBROWSER_CACHE_DIR = prevCache;
delete process.env.CLOAKBROWSER_RELEASE_CHANNEL;
vi.restoreAllMocks();
fs.rmSync(tmpDir, { recursive: true, force: true });
});
@@ -35,6 +39,31 @@ describe("collectDiagnostics", () => {
expect(diag.modules).toBeDefined();
});
it("reports Preview to Stable fallback and the next launch version", async () => {
process.env.CLOAKBROWSER_LICENSE_KEY = "cb_test";
process.env.CLOAKBROWSER_RELEASE_CHANNEL = "preview";
vi.spyOn(license, "validateLicense").mockResolvedValue({
valid: true,
plan: "business",
expires: null,
});
vi.spyOn(license, "getProLatestRelease").mockResolvedValue({
version: "150.0.7871.114.3",
requestedChannel: "preview",
resolvedChannel: "stable",
fallback: true,
});
vi.spyOn(license, "getActiveSessionCount").mockResolvedValue(null);
const { collectDiagnostics } = await import("../src/cli.js");
const diag = (await collectDiagnostics(false)) as Record<string, any>;
expect(diag.binary.requested_channel).toBe("preview");
expect(diag.binary.resolved_channel).toBe("stable");
expect(diag.binary.channel_fallback).toBe(true);
expect(diag.binary.version).toBe("150.0.7871.114.3");
});
it("includes binary, fonts, geoip and module sections", async () => {
const { collectDiagnostics } = await import("../src/cli.js");
const diag = (await collectDiagnostics(true)) as Record<string, any>;
+31
View File
@@ -8,6 +8,7 @@ import {
getBinaryDir,
getDownloadUrl,
getFallbackDownloadUrl,
normalizeReleaseChannel,
normalizeRequestedVersion,
binarySupportsHeadlessNoViewport,
binarySupportsMaximizedWindow,
@@ -73,6 +74,36 @@ describe("config", () => {
});
});
describe("release channel", () => {
it("defaults to stable", () => {
delete process.env.CLOAKBROWSER_RELEASE_CHANNEL;
expect(normalizeReleaseChannel()).toBe("stable");
});
it("normalizes preview case and whitespace", () => {
expect(normalizeReleaseChannel(" PREVIEW ")).toBe("preview");
});
it("reads the environment at call time", () => {
process.env.CLOAKBROWSER_RELEASE_CHANNEL = "preview";
try {
expect(normalizeReleaseChannel()).toBe("preview");
} finally {
delete process.env.CLOAKBROWSER_RELEASE_CHANNEL;
}
});
it("explicit stable overrides a preview environment", () => {
process.env.CLOAKBROWSER_RELEASE_CHANNEL = "preview";
try {
expect(normalizeReleaseChannel("stable")).toBe("stable");
expect(normalizeReleaseChannel("unknown")).toBe("stable");
} finally {
delete process.env.CLOAKBROWSER_RELEASE_CHANNEL;
}
});
});
describe("version pin", () => {
it("explicit value wins over env", () => {
process.env.CLOAKBROWSER_VERSION = "146.0.0.0";
+41 -2
View File
@@ -3,7 +3,6 @@ import fs from "node:fs";
import path from "node:path";
import { binaryInfo } from "../src/download.js";
import { DEFAULT_VIEWPORT, getBinaryPath, getChromiumVersion, getPlatformTag } from "../src/config.js";
import * as config from "../src/config.js";
describe("binaryInfo", () => {
it("returns correct structure", () => {
@@ -58,6 +57,31 @@ describe("binaryInfo", () => {
else delete process.env.CLOAKBROWSER_CACHE_DIR;
}
});
it("threads the channel into the Pro download URL", () => {
const orig = process.env.CLOAKBROWSER_CACHE_DIR;
const dir = `/tmp/cloakbrowser-test-${Date.now()}-chan`;
fs.mkdirSync(dir, { recursive: true });
process.env.CLOAKBROWSER_CACHE_DIR = dir;
try {
// Same cached Pro binary satisfies both channels (version-keyed cache dir).
fs.writeFileSync(path.join(dir, `latest_pro_version_preview_${getPlatformTag()}`), "151.0.7900.10.1");
fs.writeFileSync(path.join(dir, `latest_pro_version_${getPlatformTag()}`), "151.0.7900.10.1");
const bp = getBinaryPath("151.0.7900.10.1", true);
fs.mkdirSync(path.dirname(bp), { recursive: true });
fs.writeFileSync(bp, "fake");
fs.chmodSync(bp, 0o755);
expect(binaryInfo(undefined, "preview").downloadUrl).toMatch(/\/api\/download\/latest\?channel=preview$/);
const stableUrl = binaryInfo(undefined, "stable").downloadUrl;
expect(stableUrl).toMatch(/\/api\/download\/latest$/);
expect(stableUrl).not.toContain("channel=preview");
} finally {
fs.rmSync(dir, { recursive: true, force: true });
if (orig) process.env.CLOAKBROWSER_CACHE_DIR = orig;
else delete process.env.CLOAKBROWSER_CACHE_DIR;
}
});
});
describe("composable Playwright launch helpers", () => {
@@ -78,6 +102,21 @@ describe("composable Playwright launch helpers", () => {
}
});
it("forwards releaseChannel to Playwright binary resolution", async () => {
delete process.env.CLOAKBROWSER_BINARY_PATH;
const ensureBinary = vi.fn().mockResolvedValue("/fake/chrome");
vi.doMock("../src/download.js", () => ({ ensureBinary }));
try {
const { buildLaunchOptions } = await import("../src/playwright.js");
await buildLaunchOptions({ releaseChannel: "preview" });
expect(ensureBinary).toHaveBeenCalledWith(undefined, undefined, "preview");
} finally {
vi.doUnmock("../src/download.js");
}
});
it("exports composable helpers from the package entrypoint", async () => {
const entry = await import("../src/index.js");
@@ -253,7 +292,7 @@ describe.skipIf(!process.env.CLOAKBROWSER_BINARY_PATH)(
const webdriver = await page.evaluate(() => navigator.webdriver);
expect(webdriver).toBeFalsy();
const plugins = await page.evaluate(() => navigator.plugins.length);
const plugins = await page.evaluate(() => Array.from(navigator.plugins).length);
expect(plugins).toBeGreaterThan(0);
await browser.close();
+162 -1
View File
@@ -7,6 +7,7 @@ import crypto from "node:crypto";
import {
resolveLicenseKey,
validateLicense,
getProLatestRelease,
getProLatestVersion,
getActiveSessionCount,
buildLaunchEnv,
@@ -28,9 +29,12 @@ beforeEach(() => {
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
delete process.env.CLOAKBROWSER_RELEASE_CHANNEL;
try {
fs.rmSync(tmpDir, { recursive: true, force: true });
} catch {}
} catch (error) {
console.error(`Failed to remove test directory ${tmpDir}:`, error);
}
});
// ── resolveLicenseKey ─────────────────────────────────
@@ -283,6 +287,113 @@ describe("getProLatestVersion", () => {
expect(version).toBe("147.0.1234.5");
});
it("uses an isolated endpoint and marker for preview", async () => {
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: true,
json: async () => ({
version: "150.0.7871.114.3",
requested_channel: "preview",
resolved_channel: "stable",
fallback: true,
}),
} as Response);
const release = await getProLatestRelease("preview");
expect(release).toEqual({
version: "150.0.7871.114.3",
requestedChannel: "preview",
resolvedChannel: "stable",
fallback: true,
});
expect(fetchSpy.mock.calls[0]![0]).toBe(
"https://cloakbrowser.dev/api/download/version?channel=preview",
);
expect(
fs.readFileSync(
path.join(tmpDir, `.last_pro_version_check_preview_${config.getPlatformTag()}`),
"utf-8",
),
).toBe("150.0.7871.114.3");
expect(
fs.existsSync(path.join(tmpDir, `.last_pro_version_check_${config.getPlatformTag()}`)),
).toBe(false);
const resolution = JSON.parse(fs.readFileSync(
path.join(tmpDir, `.last_pro_version_resolution_preview_${config.getPlatformTag()}`),
"utf-8",
));
expect(resolution).toMatchObject({
requested_channel: "preview",
resolved_channel: "stable",
fallback: true,
});
expect(resolution.requestedChannel).toBeUndefined();
});
it("reports an old server Preview response as Stable fallback", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: true,
json: async () => ({ version: "150.0.7871.114.3" }),
} as Response);
const release = await getProLatestRelease("preview");
expect(release?.resolvedChannel).toBe("stable");
expect(release?.fallback).toBe(true);
});
it("reads a legacy JavaScript camelCase sidecar", async () => {
const platform = config.getPlatformTag();
fs.writeFileSync(
path.join(tmpDir, `.last_pro_version_check_preview_${platform}`),
"150.0.7871.114.3",
);
fs.writeFileSync(
path.join(tmpDir, `.last_pro_version_resolution_preview_${platform}`),
JSON.stringify({
version: "150.0.7871.114.3",
requestedChannel: "preview",
resolvedChannel: "stable",
fallback: true,
}),
);
const fetchSpy = vi.spyOn(globalThis, "fetch");
const release = await getProLatestRelease("preview");
expect(fetchSpy).not.toHaveBeenCalled();
expect(release?.resolvedChannel).toBe("stable");
expect(release?.fallback).toBe(true);
});
it("reads preview from the environment", async () => {
process.env.CLOAKBROWSER_RELEASE_CHANNEL = "preview";
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: true,
json: async () => ({ version: "151.0.1234.5" }),
} as Response);
await getProLatestVersion();
expect(fetchSpy.mock.calls[0]![0]).toBe(
"https://cloakbrowser.dev/api/download/version?channel=preview",
);
});
it("explicit stable overrides a preview environment", async () => {
process.env.CLOAKBROWSER_RELEASE_CHANNEL = "preview";
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: true,
json: async () => ({ version: "150.0.1234.5" }),
} as Response);
await getProLatestVersion("stable");
expect(fetchSpy.mock.calls[0]![0]).toBe(
"https://cloakbrowser.dev/api/download/version",
);
});
it("sends X-Platform header", async () => {
vi.spyOn(config, "getPlatformTag").mockReturnValue("darwin-arm64");
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue({
@@ -302,6 +413,15 @@ describe("getProLatestVersion", () => {
vi.spyOn(config, "getPlatformTag").mockReturnValue("darwin-arm64");
const marker = path.join(tmpDir, ".last_pro_version_check_darwin-arm64");
fs.writeFileSync(marker, "147.0.1234.5");
fs.writeFileSync(
path.join(tmpDir, ".last_pro_version_resolution_darwin-arm64"),
JSON.stringify({
version: "147.0.1234.5",
requestedChannel: "stable",
resolvedChannel: "stable",
fallback: false,
}),
);
const fetchSpy = vi.spyOn(globalThis, "fetch");
const version = await getProLatestVersion();
@@ -315,6 +435,47 @@ describe("getProLatestVersion", () => {
const version = await getProLatestVersion();
expect(version).toBeNull();
});
it("offline preview preserves the sidecar channel", async () => {
// Stale marker (rate-limit expired) forces the network path; the server is
// unreachable, so the offline branch must reuse the resolution sidecar
// instead of mislabeling a genuine preview build as a stable fallback.
vi.spyOn(config, "getPlatformTag").mockReturnValue("linux-x64");
const marker = path.join(tmpDir, ".last_pro_version_check_preview_linux-x64");
fs.writeFileSync(marker, "151.0.7900.10.1");
fs.writeFileSync(
path.join(tmpDir, ".last_pro_version_resolution_preview_linux-x64"),
JSON.stringify({
version: "151.0.7900.10.1",
requestedChannel: "preview",
resolvedChannel: "preview",
fallback: false,
}),
);
const old = Date.now() / 1000 - 7200; // 2h ago → past the 1h rate-limit window
fs.utimesSync(marker, old, old);
vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("network"));
const release = await getProLatestRelease("preview");
expect(release?.version).toBe("151.0.7900.10.1");
expect(release?.resolvedChannel).toBe("preview");
expect(release?.fallback).toBe(false);
});
it("offline preview without a sidecar falls back to stable", async () => {
vi.spyOn(config, "getPlatformTag").mockReturnValue("linux-x64");
const marker = path.join(tmpDir, ".last_pro_version_check_preview_linux-x64");
fs.writeFileSync(marker, "151.0.7900.10.1");
const old = Date.now() / 1000 - 7200;
fs.utimesSync(marker, old, old);
vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("network"));
const release = await getProLatestRelease("preview");
expect(release?.resolvedChannel).toBe("stable");
expect(release?.fallback).toBe(true);
});
});
// ── Config pro parameter ──────────────────────────────
+50
View File
@@ -1,4 +1,6 @@
import { describe, it, expect, vi } from "vitest";
import fs from "node:fs";
import path from "node:path";
import { parseProxyUrl, isSocksProxy, resolveProxyConfig } from "../src/proxy.js";
import * as config from "../src/config.js";
import type { LaunchOptions } from "../src/types.js";
@@ -452,4 +454,52 @@ describe("resolveProxyConfig", () => {
restorePlatform();
}
});
it("uses the selected release channel for inline-auth gating", () => {
const originalCacheDir = process.env.CLOAKBROWSER_CACHE_DIR;
const cacheDir = `/tmp/cloakbrowser-proxy-preview-${Date.now()}`;
process.env.CLOAKBROWSER_CACHE_DIR = cacheDir;
try {
for (const [channel, version] of [
["stable", "145.0.1000.1"],
["preview", "148.0.7778.215.4"],
] as const) {
const binaryPath = config.getBinaryPath(version, true);
fs.mkdirSync(path.dirname(binaryPath), { recursive: true });
fs.writeFileSync(binaryPath, "fake");
fs.chmodSync(binaryPath, 0o755);
const markerPrefix = channel === "preview"
? "latest_pro_version_preview"
: "latest_pro_version";
fs.writeFileSync(
path.join(cacheDir, `${markerPrefix}_${config.getPlatformTag()}`),
version,
);
}
const stable = resolveProxyConfig(
"http://user:pass@proxy:8080",
undefined,
"cb_test",
"stable",
);
const preview = resolveProxyConfig(
"http://user:pass@proxy:8080",
undefined,
"cb_test",
"preview",
);
expect(stable.proxyOption).toBeDefined();
expect(stable.proxyArgs).toEqual([]);
expect(preview.proxyOption).toBeUndefined();
expect(preview.proxyArgs).toEqual([
"--proxy-server=http://user:pass@proxy:8080",
]);
} finally {
fs.rmSync(cacheDir, { recursive: true, force: true });
if (originalCacheDir === undefined) delete process.env.CLOAKBROWSER_CACHE_DIR;
else process.env.CLOAKBROWSER_CACHE_DIR = originalCacheDir;
}
});
});
+9
View File
@@ -49,6 +49,15 @@ describe("puppeteer launch", () => {
);
});
it("forwards releaseChannel to binary resolution", async () => {
const { ensureBinary } = await import("../src/download.js");
const { launch } = await import("../src/puppeteer.js");
await launch({ releaseChannel: "preview" });
expect(ensureBinary).toHaveBeenCalledWith(undefined, undefined, "preview");
});
it("includes stealth args by default", async () => {
const { launch } = await import("../src/puppeteer.js");
await launch();
+188 -5
View File
@@ -1,9 +1,13 @@
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import {
CHROMIUM_VERSION,
binarySupportsHeadlessNoViewport,
binarySupportsHttpProxyInlineAuth,
binarySupportsMaximizedWindow,
getChromiumVersion,
getBinaryPath,
getDownloadUrl,
getEffectiveVersion,
getPlatformTag,
@@ -11,7 +15,7 @@ import {
versionNewer,
} from "../src/config.js";
import {
binaryInfo,
checkForProUpdate,
checkForUpdate,
checkWrapperUpdate,
clearCache,
@@ -20,6 +24,7 @@ import {
getLatestChromiumVersion,
parseChecksums,
resetWrapperUpdateChecked,
resetPreviewFallbackWarned,
} from "../src/download.js";
describe("version comparison", () => {
@@ -85,8 +90,6 @@ describe("download URL", () => {
});
describe("latest version (platform-aware)", () => {
const platformTarball = `cloakbrowser-${getPlatformTag()}.tar.gz`;
function makeAssets(platforms: string[]) {
return platforms.map((p) => ({ name: `cloakbrowser-${p}.tar.gz` }));
}
@@ -114,7 +117,7 @@ describe("latest version (platform-aware)", () => {
});
it("skips release without platform asset", async () => {
const spy = mockFetch([
mockFetch([
{
tag_name: "chromium-v145.0.7718.0",
draft: false,
@@ -366,6 +369,186 @@ describe("effective version", () => {
});
});
describe("preview Pro selection", () => {
let cacheDir: string;
let originalCacheDir: string | undefined;
function createProBinary(version: string): string {
const binaryPath = getBinaryPath(version, true);
fs.mkdirSync(path.dirname(binaryPath), { recursive: true });
fs.writeFileSync(binaryPath, "fake");
fs.chmodSync(binaryPath, 0o755);
return binaryPath;
}
beforeEach(() => {
originalCacheDir = process.env.CLOAKBROWSER_CACHE_DIR;
cacheDir = fs.mkdtempSync(path.join(os.tmpdir(), "cloakbrowser-preview-"));
process.env.CLOAKBROWSER_CACHE_DIR = cacheDir;
delete process.env.CLOAKBROWSER_BINARY_PATH;
delete process.env.CLOAKBROWSER_RELEASE_CHANNEL;
});
afterEach(() => {
vi.restoreAllMocks();
fs.rmSync(cacheDir, { recursive: true, force: true });
if (originalCacheDir === undefined) delete process.env.CLOAKBROWSER_CACHE_DIR;
else process.env.CLOAKBROWSER_CACHE_DIR = originalCacheDir;
delete process.env.CLOAKBROWSER_RELEASE_CHANNEL;
});
it("uses the selected channel for binary capability gates", () => {
const stable = "145.0.1000.1";
const preview = "148.0.7778.215.4";
createProBinary(stable);
createProBinary(preview);
fs.writeFileSync(
path.join(cacheDir, `latest_pro_version_${getPlatformTag()}`),
stable,
);
fs.writeFileSync(
path.join(cacheDir, `latest_pro_version_preview_${getPlatformTag()}`),
preview,
);
expect(binarySupportsHeadlessNoViewport("cb_test", undefined, "stable")).toBe(false);
expect(binarySupportsHeadlessNoViewport("cb_test", undefined, "preview")).toBe(true);
expect(binarySupportsMaximizedWindow("cb_test", undefined, "preview")).toBe(true);
expect(binarySupportsHttpProxyInlineAuth("cb_test", undefined, "stable")).toBe(false);
expect(binarySupportsHttpProxyInlineAuth("cb_test", undefined, "preview")).toBe(true);
});
it("keeps a pinned preview launch isolated from latest markers", async () => {
const pinned = "151.0.1000.1";
const binaryPath = createProBinary(pinned);
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: true,
json: async () => ({ valid: true, plan: "solo", expires: null }),
} as Response);
await expect(ensureBinary("cb_test", pinned, "preview")).resolves.toBe(binaryPath);
expect(fetchSpy).toHaveBeenCalledTimes(1);
expect(String(fetchSpy.mock.calls[0]![0])).toContain("/api/license/validate");
expect(
fs.existsSync(path.join(cacheDir, `latest_pro_version_preview_${getPlatformTag()}`)),
).toBe(false);
expect(
fs.existsSync(path.join(cacheDir, `latest_pro_version_${getPlatformTag()}`)),
).toBe(false);
});
it("uses preview latest without changing the stable marker", async () => {
const stable = "150.0.1000.1";
const preview = "151.0.1000.1";
createProBinary(stable);
const previewPath = createProBinary(preview);
const stableMarker = path.join(cacheDir, `latest_pro_version_${getPlatformTag()}`);
fs.writeFileSync(stableMarker, stable);
const fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => {
const url = String(input);
if (url.includes("/api/license/validate")) {
return {
ok: true,
json: async () => ({ valid: true, plan: "solo", expires: null }),
} as Response;
}
return {
ok: true,
json: async () => ({ version: preview }),
} as Response;
});
await expect(ensureBinary("cb_test", undefined, "preview")).resolves.toBe(previewPath);
expect(fetchSpy.mock.calls.some(([url]) => String(url).endsWith("?channel=preview"))).toBe(true);
expect(fs.readFileSync(stableMarker, "utf-8")).toBe(stable);
expect(
fs.readFileSync(
path.join(cacheDir, `latest_pro_version_preview_${getPlatformTag()}`),
"utf-8",
),
).toBe(preview);
});
it("manual preview update advances only the preview marker", async () => {
const stable = "150.0.1000.1";
const preview = "151.0.1000.1";
createProBinary(stable);
createProBinary(preview);
const stableMarker = path.join(cacheDir, `latest_pro_version_${getPlatformTag()}`);
fs.writeFileSync(stableMarker, stable);
vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: true,
json: async () => ({ version: preview }),
} as Response);
await expect(checkForProUpdate("cb_test", "preview")).resolves.toBe(preview);
expect(fs.readFileSync(stableMarker, "utf-8")).toBe(stable);
expect(
fs.readFileSync(
path.join(cacheDir, `latest_pro_version_preview_${getPlatformTag()}`),
"utf-8",
),
).toBe(preview);
});
it("warns when preview falls back to stable (no preview build for platform)", async () => {
resetPreviewFallbackWarned();
const stable = "150.0.1000.1";
createProBinary(stable); // stable-fallback build already cached → no download
const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => {
if (String(input).includes("/api/license/validate")) {
return { ok: true, json: async () => ({ valid: true, plan: "solo", expires: null }) } as Response;
}
return {
ok: true,
json: async () => ({
version: stable,
requested_channel: "preview",
resolved_channel: "stable",
fallback: true,
}),
} as Response;
});
await ensureBinary("cb_test", undefined, "preview");
expect(
errSpy.mock.calls.some((c) => String(c[0]).includes("no preview build is available")),
).toBe(true);
});
it("does not warn for a genuine preview build", async () => {
resetPreviewFallbackWarned();
const preview = "151.0.1000.1";
createProBinary(preview);
const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => {
if (String(input).includes("/api/license/validate")) {
return { ok: true, json: async () => ({ valid: true, plan: "solo", expires: null }) } as Response;
}
return {
ok: true,
json: async () => ({
version: preview,
requested_channel: "preview",
resolved_channel: "preview",
fallback: false,
}),
} as Response;
});
await ensureBinary("cb_test", undefined, "preview");
expect(
errSpy.mock.calls.some((c) => String(c[0]).includes("no preview build is available")),
).toBe(false);
});
});
describe("ensureBinary", () => {
afterEach(() => {
delete process.env.CLOAKBROWSER_BINARY_PATH;
+17 -3
View File
@@ -1,6 +1,7 @@
"""Unit tests for the cloakbrowser CLI diagnostics (`info` / `doctor`)."""
import json
import os
import sys
from argparse import Namespace
from unittest.mock import patch
@@ -8,7 +9,7 @@ from unittest.mock import patch
import pytest
from cloakbrowser.__main__ import _binary_version, cmd_info
from cloakbrowser.license import LicenseInfo
from cloakbrowser.license import LicenseInfo, ProReleaseInfo
def _run(args, *, key=None, license_info=None, sessions=None):
@@ -71,7 +72,8 @@ def test_valid_key_reports_pro_binary(capsys):
valid = LicenseInfo(valid=True, plan="business", expires=None)
# quick=False: the server latest lookup is skipped under --quick (network-free),
# so exercise the full path to see latest_version populated.
with patch("cloakbrowser.license.get_pro_latest_version", return_value="148.0.0.0"):
release = ProReleaseInfo("148.0.0.0", "stable", "stable", False)
with patch("cloakbrowser.license.get_pro_latest_release", return_value=release):
_run(Namespace(quick=False, json=True), key="cb_test", license_info=valid)
data = json.loads(capsys.readouterr().out)
assert data["binary"]["tier"] == "pro"
@@ -85,7 +87,8 @@ def test_quick_skips_pro_latest_lookup(capsys):
"""--quick keeps `info` network-free: no server latest-version lookup for Pro."""
valid = LicenseInfo(valid=True, plan="business", expires=None)
with patch(
"cloakbrowser.license.get_pro_latest_version", return_value="148.0.0.0"
"cloakbrowser.license.get_pro_latest_release",
return_value=ProReleaseInfo("148.0.0.0", "stable", "stable", False),
) as mock_latest:
_run(Namespace(quick=True, json=True), key="cb_test", license_info=valid)
data = json.loads(capsys.readouterr().out)
@@ -93,6 +96,17 @@ def test_quick_skips_pro_latest_lookup(capsys):
assert data["binary"]["latest_version"] is None
def test_info_reports_preview_stable_fallback(capsys):
valid = LicenseInfo(valid=True, plan="business", expires=None)
release = ProReleaseInfo("150.0.7871.114.3", "preview", "stable", True)
with (
patch.dict(os.environ, {"CLOAKBROWSER_RELEASE_CHANNEL": "preview"}),
patch("cloakbrowser.license.get_pro_latest_release", return_value=release),
):
_run(Namespace(quick=False, json=False), key="cb_test", license_info=valid)
assert "Channel: Preview → Stable fallback" in capsys.readouterr().out
def test_invalid_key_falls_back_to_free(capsys):
"""A key the server rejects -> not entitled -> free binary, not Pro."""
invalid = LicenseInfo(valid=False, plan="solo", expires=None)
+2 -1
View File
@@ -12,9 +12,10 @@ def _run(monkeypatch, plan):
monkeypatch.setattr(dl, "normalize_requested_version", lambda v: v)
captured = {}
def fake_pro(key, requested_version=None, plan=None):
def fake_pro(key, requested_version=None, plan=None, release_channel=None):
captured["rv"] = requested_version
captured["plan"] = plan
captured["release_channel"] = release_channel
return "/tmp/chrome"
monkeypatch.setattr(dl, "_ensure_pro_binary", fake_pro)
+14 -1
View File
@@ -1,6 +1,6 @@
"""Unit tests for launch_context() — context kwargs, viewport defaults, close cleanup."""
from unittest.mock import AsyncMock, MagicMock, call, patch
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
@@ -33,6 +33,19 @@ def test_default_viewport(mock_launch, _mock_bin):
assert ctx_kwargs[1]["viewport"] == DEFAULT_VIEWPORT
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
@patch("cloakbrowser.browser.launch")
def test_release_channel_forwarded(mock_launch, _mock_bin):
browser, _ = _make_mock_browser()
mock_launch.return_value = browser
from cloakbrowser.browser import launch_context
launch_context(release_channel="preview")
assert mock_launch.call_args.kwargs["release_channel"] == "preview"
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
@patch("cloakbrowser.browser.launch")
def test_headed_no_viewport(mock_launch, _mock_bin):
+210 -1
View File
@@ -14,6 +14,7 @@ from cloakbrowser.license import (
LicenseInfo,
build_launch_env,
get_active_session_count,
get_pro_latest_release,
get_pro_latest_version,
resolve_license_key,
validate_license,
@@ -266,10 +267,142 @@ class TestGetProLatestVersion:
mock_resp.raise_for_status = MagicMock()
with patch("cloakbrowser.license.get_cache_dir", return_value=tmp_path):
with patch("cloakbrowser.license.httpx.get", return_value=mock_resp):
with patch(
"cloakbrowser.license.httpx.get", return_value=mock_resp
) as mock_get:
version = get_pro_latest_version()
assert version == "147.0.1234.5"
assert mock_get.call_args.args[0] == "https://cloakbrowser.dev/api/download/version"
def test_preview_uses_preview_endpoint_and_marker(self, tmp_path):
mock_resp = MagicMock()
mock_resp.json.return_value = {
"version": "150.0.7871.114.3",
"requested_channel": "preview",
"resolved_channel": "stable",
"fallback": True,
}
mock_resp.raise_for_status = MagicMock()
with (
patch("cloakbrowser.license.get_cache_dir", return_value=tmp_path),
patch(
"cloakbrowser.license.get_platform_tag", return_value="linux-x64"
),
patch(
"cloakbrowser.license.httpx.get", return_value=mock_resp
) as mock_get,
):
release = get_pro_latest_release("preview")
assert release is not None
assert release.version == "150.0.7871.114.3"
assert release.resolved_channel == "stable"
assert release.fallback is True
assert mock_get.call_args.args[0].endswith("?channel=preview")
assert (
tmp_path / ".last_pro_version_check_preview_linux-x64"
).read_text() == "150.0.7871.114.3"
assert not (tmp_path / ".last_pro_version_check_linux-x64").exists()
def test_old_server_preview_response_is_reported_as_stable_fallback(self, tmp_path):
mock_resp = MagicMock()
mock_resp.json.return_value = {"version": "150.0.7871.114.3"}
mock_resp.raise_for_status = MagicMock()
with (
patch("cloakbrowser.license.get_cache_dir", return_value=tmp_path),
patch("cloakbrowser.license.httpx.get", return_value=mock_resp),
):
release = get_pro_latest_release("preview")
assert release is not None
assert release.resolved_channel == "stable"
assert release.fallback is True
def test_reads_legacy_javascript_camelcase_sidecar(self, tmp_path):
platform_tag = "linux-x64"
(tmp_path / f".last_pro_version_check_preview_{platform_tag}").write_text(
"150.0.7871.114.3"
)
(tmp_path / f".last_pro_version_resolution_preview_{platform_tag}").write_text(
json.dumps({
"version": "150.0.7871.114.3",
"requestedChannel": "preview",
"resolvedChannel": "stable",
"fallback": True,
})
)
with (
patch("cloakbrowser.license.get_cache_dir", return_value=tmp_path),
patch("cloakbrowser.license.get_platform_tag", return_value=platform_tag),
patch("cloakbrowser.license.httpx.get") as mock_get,
):
release = get_pro_latest_release("preview")
mock_get.assert_not_called()
assert release is not None
assert release.resolved_channel == "stable"
assert release.fallback is True
def test_sidecar_missing_fallback_defaults_to_requested_ne_resolved(self, tmp_path):
# A sidecar with no explicit "fallback" and requested == resolved must NOT
# be reported as a fallback — matches the JS and .NET default.
platform_tag = "linux-x64"
(tmp_path / f".last_pro_version_check_preview_{platform_tag}").write_text(
"151.0.7900.10.1"
)
(tmp_path / f".last_pro_version_resolution_preview_{platform_tag}").write_text(
json.dumps({
"version": "151.0.7900.10.1",
"requested_channel": "preview",
"resolved_channel": "preview",
}) # no "fallback" key
)
with (
patch("cloakbrowser.license.get_cache_dir", return_value=tmp_path),
patch("cloakbrowser.license.get_platform_tag", return_value=platform_tag),
patch("cloakbrowser.license.httpx.get") as mock_get,
):
release = get_pro_latest_release("preview")
mock_get.assert_not_called()
assert release is not None
assert release.resolved_channel == "preview"
assert release.fallback is False
def test_environment_selects_preview(self, tmp_path):
mock_resp = MagicMock()
mock_resp.json.return_value = {"version": "151.0.1234.5"}
mock_resp.raise_for_status = MagicMock()
with (
patch.dict(os.environ, {"CLOAKBROWSER_RELEASE_CHANNEL": "preview"}),
patch("cloakbrowser.license.get_cache_dir", return_value=tmp_path),
patch(
"cloakbrowser.license.httpx.get", return_value=mock_resp
) as mock_get,
):
get_pro_latest_version()
assert mock_get.call_args.args[0].endswith("?channel=preview")
def test_explicit_stable_overrides_preview_environment(self, tmp_path):
mock_resp = MagicMock()
mock_resp.json.return_value = {"version": "150.0.1234.5"}
mock_resp.raise_for_status = MagicMock()
with (
patch.dict(os.environ, {"CLOAKBROWSER_RELEASE_CHANNEL": "preview"}),
patch("cloakbrowser.license.get_cache_dir", return_value=tmp_path),
patch(
"cloakbrowser.license.httpx.get", return_value=mock_resp
) as mock_get,
):
get_pro_latest_version("stable")
assert mock_get.call_args.args[0] == "https://cloakbrowser.dev/api/download/version"
def test_sends_platform_header(self, tmp_path):
mock_resp = MagicMock()
@@ -291,6 +424,13 @@ class TestGetProLatestVersion:
def test_rate_limited(self, tmp_path):
marker = tmp_path / ".last_pro_version_check_darwin-arm64"
marker.write_text("147.0.1234.5")
resolution = tmp_path / ".last_pro_version_resolution_darwin-arm64"
resolution.write_text(json.dumps({
"version": "147.0.1234.5",
"requested_channel": "stable",
"resolved_channel": "stable",
"fallback": False,
}))
with patch("cloakbrowser.license.get_cache_dir", return_value=tmp_path):
with patch(
@@ -309,6 +449,54 @@ class TestGetProLatestVersion:
assert version is None
def test_offline_preview_preserves_sidecar_channel(self, tmp_path):
# Stale marker (rate-limit expired) forces the network path; the server is
# unreachable, so the offline branch must reuse the resolution sidecar
# instead of mislabeling a genuine preview build as a stable fallback.
platform_tag = "linux-x64"
marker = tmp_path / f".last_pro_version_check_preview_{platform_tag}"
marker.write_text("151.0.7900.10.1")
resolution = tmp_path / f".last_pro_version_resolution_preview_{platform_tag}"
resolution.write_text(json.dumps({
"version": "151.0.7900.10.1",
"requested_channel": "preview",
"resolved_channel": "preview",
"fallback": False,
}))
old = time.time() - 7200 # 2h ago → past the 1h rate-limit window
os.utime(marker, (old, old))
with (
patch("cloakbrowser.license.get_cache_dir", return_value=tmp_path),
patch("cloakbrowser.license.get_platform_tag", return_value=platform_tag),
patch("cloakbrowser.license.httpx.get", side_effect=Exception("network")),
):
release = get_pro_latest_release("preview")
assert release is not None
assert release.version == "151.0.7900.10.1"
assert release.resolved_channel == "preview"
assert release.fallback is False
def test_offline_preview_without_sidecar_falls_back_to_stable(self, tmp_path):
# No resolution sidecar (older wrapper) → conservative stable fallback.
platform_tag = "linux-x64"
marker = tmp_path / f".last_pro_version_check_preview_{platform_tag}"
marker.write_text("151.0.7900.10.1")
old = time.time() - 7200
os.utime(marker, (old, old))
with (
patch("cloakbrowser.license.get_cache_dir", return_value=tmp_path),
patch("cloakbrowser.license.get_platform_tag", return_value=platform_tag),
patch("cloakbrowser.license.httpx.get", side_effect=Exception("network")),
):
release = get_pro_latest_release("preview")
assert release is not None
assert release.resolved_channel == "stable"
assert release.fallback is True
# ── get_active_session_count ──────────────────────────
@@ -452,6 +640,27 @@ class TestBinaryInfoTier:
assert info["tier"] == "pro"
assert info["version"] == "147.0.5555.1"
def test_preview_pro_download_url_carries_channel(self, tmp_path):
from cloakbrowser.config import get_binary_path, get_platform_tag
from cloakbrowser.download import binary_info
with patch.dict(os.environ, {"CLOAKBROWSER_CACHE_DIR": str(tmp_path)}, clear=False):
tag = get_platform_tag()
# Same cached Pro binary satisfies both channels (version-keyed cache dir);
# only the reported download URL should differ by channel.
(tmp_path / f"latest_pro_version_preview_{tag}").write_text("151.0.7900.10.1")
(tmp_path / f"latest_pro_version_{tag}").write_text("151.0.7900.10.1")
bp = get_binary_path("151.0.7900.10.1", pro=True)
bp.parent.mkdir(parents=True, exist_ok=True)
bp.write_text("fake")
bp.chmod(0o755)
preview_info = binary_info(release_channel="preview")
stable_info = binary_info(release_channel="stable")
assert preview_info["download_url"].endswith("/api/download/latest?channel=preview")
assert stable_info["download_url"].endswith("/api/download/latest")
assert "channel=preview" not in stable_info["download_url"]
# ── ensure_binary Pro routing (fail-closed vs fall-back) ──────────────────────
+96 -4
View File
@@ -552,12 +552,22 @@ class TestEnsureBinary:
fake_binary.write_bytes(b"binary")
fake_binary.chmod(0o755)
with patch(
"cloakbrowser.download._download_pro_binary",
side_effect=fake_pro_download,
with (
patch(
"cloakbrowser.download._download_pro_binary",
side_effect=fake_pro_download,
),
patch(
"cloakbrowser.license.get_pro_latest_version"
) as mock_latest,
):
result = _ensure_pro_binary("cb_key", requested_version=requested)
result = _ensure_pro_binary(
"cb_key",
requested_version=requested,
release_channel="preview",
)
assert result == str(fake_binary)
mock_latest.assert_not_called()
assert not marker.exists()
@patch("cloakbrowser.download._maybe_trigger_update_check")
@@ -646,6 +656,88 @@ class TestUnpinnedProUpgrade:
assert result == str(get_binary_path(self.NEW, pro=True))
assert marker.read_text() == self.NEW # marker advanced, not stuck
def test_preview_uses_server_version_and_preview_marker(self, tmp_path):
stable_marker = tmp_path / f"latest_pro_version_{get_platform_tag()}"
preview_marker = (
tmp_path / f"latest_pro_version_preview_{get_platform_tag()}"
)
with patch.dict(os.environ, {"CLOAKBROWSER_CACHE_DIR": str(tmp_path)}):
stable_marker.write_text(self.OLD)
_make_pro_binary(self.OLD)
with (
patch(
"cloakbrowser.license.get_pro_latest_version",
return_value=self.NEW,
) as mock_latest,
patch(
"cloakbrowser.download._download_pro_binary",
side_effect=lambda version, key: _make_pro_binary(version),
) as mock_dl,
):
from cloakbrowser.config import get_binary_path
result = _ensure_pro_binary(
"cb_key", release_channel="preview"
)
expected_path = str(get_binary_path(self.NEW, pro=True))
mock_latest.assert_called_once_with("preview")
mock_dl.assert_called_once_with(self.NEW, "cb_key")
assert result == expected_path
assert preview_marker.read_text() == self.NEW
assert stable_marker.read_text() == self.OLD
def test_preview_with_no_preview_build_warns_stable_fallback(self, tmp_path, capsys):
"""Preview requested but the server has no preview for this platform (fallback
to stable) → print a one-line notice at launch."""
import cloakbrowser.download as dl
from cloakbrowser.license import ProReleaseInfo
dl._preview_fallback_warned = False # reset once-per-process guard
with patch.dict(os.environ, {"CLOAKBROWSER_CACHE_DIR": str(tmp_path)}):
_make_pro_binary(self.NEW) # stable-fallback build already cached → no download
with (
patch(
"cloakbrowser.license.get_pro_latest_version",
return_value=self.NEW,
),
patch(
"cloakbrowser.license.get_pro_latest_release",
return_value=ProReleaseInfo(self.NEW, "preview", "stable", True),
),
patch("cloakbrowser.download._download_pro_binary") as mock_dl,
):
_ensure_pro_binary("cb_key", release_channel="preview")
mock_dl.assert_not_called()
assert "no preview build is available" in capsys.readouterr().err
def test_genuine_preview_does_not_warn(self, tmp_path, capsys):
"""A real preview build (no fallback) prints no fallback notice."""
import cloakbrowser.download as dl
from cloakbrowser.license import ProReleaseInfo
dl._preview_fallback_warned = False
with patch.dict(os.environ, {"CLOAKBROWSER_CACHE_DIR": str(tmp_path)}):
_make_pro_binary(self.NEW)
with (
patch(
"cloakbrowser.license.get_pro_latest_version",
return_value=self.NEW,
),
patch(
"cloakbrowser.license.get_pro_latest_release",
return_value=ProReleaseInfo(self.NEW, "preview", "preview", False),
),
patch("cloakbrowser.download._download_pro_binary"),
):
_ensure_pro_binary("cb_key", release_channel="preview")
assert "no preview build is available" not in capsys.readouterr().err
def test_cached_newer_build_advances_marker_no_download(self, tmp_path):
"""Marker names an OLD build but a NEWER build is already cached (the customer's
multi-version cache): resolve to newest, no download, and advance the marker so