mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-09-10 03:38:56 +02:00
refactor: cleanup
This commit is contained in:
@@ -1,18 +1,17 @@
|
||||
name: Publish sidecars to R2
|
||||
|
||||
# Publishes the `donut-proxy` sidecar to the bucket behind
|
||||
# https://download.wayfern.com, which is where the Wayfern VM fleet's bootstrap
|
||||
# scripts fetch it from.
|
||||
# https://download.wayfern.com, where remote hosts fetch it from.
|
||||
#
|
||||
# WHY THIS EXISTS SEPARATELY FROM release.yml
|
||||
# The desktop app ships donut-proxy INSIDE the bundle as a Tauri sidecar, so a
|
||||
# desktop release never needs it in a bucket. The fleet is the opposite: a leased
|
||||
# macOS or Windows host has no bundle, and its agent refuses to launch a browser
|
||||
# at all when the sidecar is missing (agent/launcher.go). Tying publication to a
|
||||
# desktop release would mean the fleet could only be unblocked by cutting one.
|
||||
# desktop release never needs it in a bucket. Remote execution is the opposite:
|
||||
# a remote host has no bundle and cannot launch a browser without the sidecar.
|
||||
# Tying publication to a desktop release would mean remote execution could only
|
||||
# be unblocked by cutting one.
|
||||
#
|
||||
# The fleet needs exactly two targets. Other platforms get their sidecar from the
|
||||
# app bundle and are deliberately not built here.
|
||||
# Only three targets are needed here. Everything else gets its sidecar from the
|
||||
# app bundle and is deliberately not built.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
@@ -62,19 +61,26 @@ jobs:
|
||||
name: Build donut-proxy (${{ matrix.target }})
|
||||
runs-on: ${{ matrix.platform }}
|
||||
strategy:
|
||||
# One target failing must not leave the other unpublished and the pair
|
||||
# One target failing must not leave the others unpublished and the set
|
||||
# skewed; publish what built and report the rest.
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
# The leased Mac mini (Apple silicon).
|
||||
# macOS arm64 remote host.
|
||||
- platform: macos-latest
|
||||
target: aarch64-apple-darwin
|
||||
artifact: donut-proxy-aarch64-apple-darwin
|
||||
# The leased Elastic Metal Windows box.
|
||||
# Windows x86_64 remote host.
|
||||
- platform: windows-latest
|
||||
target: x86_64-pc-windows-msvc
|
||||
artifact: donut-proxy-x86_64-pc-windows-msvc.exe
|
||||
# Linux x86_64 remote host. Pinned to 22.04, not -latest: the
|
||||
# deployment target is glibc 2.35, and a binary linked on 24.04
|
||||
# (glibc 2.39) refuses to load there. The stage step proves the pin
|
||||
# held.
|
||||
- platform: ubuntu-22.04
|
||||
target: x86_64-unknown-linux-gnu
|
||||
artifact: donut-proxy-x86_64-unknown-linux-gnu
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
|
||||
@@ -91,6 +97,15 @@ jobs:
|
||||
toolchain: stable
|
||||
targets: ${{ matrix.target }}
|
||||
|
||||
# The proxy bin links donutbrowser_lib, which pulls in Tauri and therefore
|
||||
# GTK and WebKit at link time even though the proxy never opens a window.
|
||||
# Same package list as release.yml, so the two cannot drift apart.
|
||||
- name: Install Linux build dependencies
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev libxdo-dev pkg-config unzip xdg-utils
|
||||
|
||||
- name: Build donut-proxy
|
||||
shell: bash
|
||||
working-directory: ./src-tauri
|
||||
@@ -117,7 +132,7 @@ jobs:
|
||||
|
||||
# Prove the thing we are about to publish actually runs and is the
|
||||
# binary we think it is. A sidecar that cannot start is indistinguishable
|
||||
# from a missing one once it is on a leased host, except that it fails
|
||||
# from a missing one once it is on a remote host, except that it fails
|
||||
# later and less clearly.
|
||||
version="$("$dest" --version)"
|
||||
case "$version" in
|
||||
@@ -125,6 +140,46 @@ jobs:
|
||||
*) echo "::error::unexpected --version output: $version"; exit 1 ;;
|
||||
esac
|
||||
|
||||
if [ "$RUNNER_OS" = "Linux" ]; then
|
||||
# The Linux deployment target is glibc 2.35. A binary linked on a
|
||||
# newer runner fails there with "version GLIBC_2.xx not found",
|
||||
# which reaches the host only as a sidecar that "will not run".
|
||||
# The runner is pinned to 22.04 for that reason; this proves the
|
||||
# pin held, and that every library the binary names resolves at
|
||||
# all.
|
||||
fleet_glibc_max=2.35
|
||||
if ! ldd_out="$(ldd "$dest")"; then
|
||||
echo "::error::ldd cannot read $dest"
|
||||
printf '%s\n' "$ldd_out"
|
||||
exit 1
|
||||
fi
|
||||
if grep -q 'not found' <<< "$ldd_out"; then
|
||||
echo "::error::$dest needs a shared library this runner cannot resolve, and the fleet host will not either"
|
||||
printf '%s\n' "$ldd_out"
|
||||
exit 1
|
||||
fi
|
||||
needed="$(objdump -p "$dest" | awk '$1 == "NEEDED" { print $2 }')"
|
||||
glibc_max="$(objdump -T "$dest" | grep -o 'GLIBC_[0-9]*\.[0-9]*' | sed 's/^GLIBC_//' | sort -uV | tail -n 1)"
|
||||
if [ -z "$glibc_max" ]; then
|
||||
echo "::error::could not read the glibc symbol versions of $dest"
|
||||
exit 1
|
||||
fi
|
||||
{
|
||||
echo "### ${{ matrix.artifact }} shared libraries (DT_NEEDED)"
|
||||
echo ""
|
||||
echo '```'
|
||||
printf '%s\n' "$needed"
|
||||
echo '```'
|
||||
echo ""
|
||||
echo "- highest glibc symbol version: \`GLIBC_$glibc_max\` (fleet host ceiling: \`GLIBC_$fleet_glibc_max\`)"
|
||||
echo ""
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
if [ "$(printf '%s\n' "$glibc_max" "$fleet_glibc_max" | sort -V | tail -n 1)" != "$fleet_glibc_max" ]; then
|
||||
echo "::error::$dest needs GLIBC_$glibc_max, but the fleet host (Ubuntu 22.04) ships glibc $fleet_glibc_max; build it on ubuntu-22.04"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
if command -v sha256sum >/dev/null; then
|
||||
digest="$(sha256sum "$dest" | cut -d' ' -f1)"
|
||||
else
|
||||
@@ -205,9 +260,9 @@ jobs:
|
||||
src="$RUNNER_TEMP/sidecars/$ARTIFACT"
|
||||
|
||||
# no-cache, not a long max-age: this key is deliberately overwritten in
|
||||
# place, and a CDN copy of the previous build would make a host fail
|
||||
# the SHA check the bootstrap performs, which reads as a corrupt
|
||||
# download rather than a stale cache.
|
||||
# place, and a cached copy of the previous build would make a host
|
||||
# fail its integrity check, which reads as a corrupt download rather
|
||||
# than a stale cache.
|
||||
aws s3 cp "$src" "s3://${bucket}/${ARTIFACT}" \
|
||||
--endpoint-url "$endpoint" \
|
||||
--content-type application/octet-stream \
|
||||
@@ -221,7 +276,7 @@ jobs:
|
||||
|
||||
# Read it back and compare. Without this, "published" is an assumption:
|
||||
# a truncated upload or a write to the wrong bucket both look like
|
||||
# success, and the failure would surface days later on a leased host
|
||||
# success, and the failure would surface days later on a remote host
|
||||
# as an unexplained checksum mismatch.
|
||||
verify="$RUNNER_TEMP/verify-$ARTIFACT"
|
||||
aws s3 cp "s3://${bucket}/${ARTIFACT}" "$verify" \
|
||||
|
||||
@@ -43,11 +43,14 @@ donutbrowser/
|
||||
│ │ ├── browser_runner.rs # Profile launch/kill orchestration
|
||||
│ │ ├── browser.rs # Browser trait & launch logic
|
||||
│ │ ├── profile/ # Profile CRUD (manager.rs, types.rs)
|
||||
│ │ ├── proxy_manager.rs # Proxy lifecycle & connection testing
|
||||
│ │ ├── proxy_manager.rs # Proxy lifecycle, connection testing, per-proxy check history
|
||||
│ │ ├── proxy_udp.rs # SOCKS5 UDP ASSOCIATE probe (yes/no/unknown UDP verdict)
|
||||
│ │ ├── proxy_server.rs # Local proxy binary (donut-proxy)
|
||||
│ │ ├── proxy_storage.rs # Proxy config persistence (JSON files)
|
||||
│ │ ├── api_server.rs # REST API (utoipa + axum)
|
||||
│ │ ├── mcp_server.rs # MCP protocol server
|
||||
│ │ ├── mcp_server.rs # MCP protocol server (tool engine + local loopback listener)
|
||||
│ │ ├── mcp_remote.rs # Remote MCP bridge: outbound websocket to Donut cloud (Enterprise remote control)
|
||||
│ │ ├── mcp_integrations.rs # 20-client MCP installer: local URL or remote endpoint with bearer, format-preserving JSONC/TOML edits
|
||||
│ │ ├── automation_rate_limiter.rs # Shared REST/MCP automation quota
|
||||
│ │ ├── sync/ # Cloud sync (engine, encryption, manifest, scheduler)
|
||||
│ │ ├── vpn/ # WireGuard tunnels
|
||||
@@ -58,14 +61,16 @@ donutbrowser/
|
||||
│ │ ├── downloader.rs # Browser binary downloader
|
||||
│ │ ├── extraction.rs # Archive extraction (zip, tar, dmg, msi)
|
||||
│ │ ├── settings_manager.rs # App settings persistence
|
||||
│ │ ├── data_root.rs # Moving the data directory (copy, verify, then delete) + the pointer read at startup
|
||||
│ │ ├── cookie_manager.rs # Cookie import/export
|
||||
│ │ ├── profile_importer.rs # Bulk profile import (Chromium-family detection, ZIP, batch)
|
||||
│ │ ├── fingerprint_consistency.rs # Launch-time proxy exit vs fingerprint timezone/language check
|
||||
│ │ ├── dns_blocklist.rs # Hagezi DNS blocklists + user custom lists/allowlist
|
||||
│ │ ├── traffic_stats.rs # Per-profile traffic stats + secure history erase
|
||||
│ │ ├── extension_manager.rs # Browser extension management
|
||||
│ │ ├── extension_fetch.rs # Web Store link/id and direct .crx/.zip import, CRX3 unwrapping
|
||||
│ │ ├── group_manager.rs # Profile group management
|
||||
│ │ ├── synchronizer.rs # Real-time profile synchronizer
|
||||
│ │ ├── synchronizer.rs # Real-time profile synchronizer (pause/resume, hold a follower out, window layouts)
|
||||
│ │ ├── daemon/ # Background daemon + tray icon (currently disabled)
|
||||
│ │ └── cloud_auth.rs # Cloud authentication
|
||||
│ ├── tests/ # Integration tests
|
||||
@@ -75,7 +80,11 @@ donutbrowser/
|
||||
├── e2e/ # Isolated native UI/sync/Wayfern E2E system
|
||||
│ ├── app/ # Test-only Tauri harness that injects the private driver
|
||||
│ ├── lib/ # WebDriver, CDP, fixtures, app-session helpers
|
||||
│ └── tests/ # Smoke, UI, entity, integration, sync, browser suites
|
||||
│ └── tests/ # Smoke, UI/motion, entity, network, integration, sync, browser suites
|
||||
├── sdk/ # Standalone Python + Node clients for the local REST API
|
||||
│ ├── api-paths.json # Snapshot of every published operation; drift check for both SDKs
|
||||
│ ├── python/ # `donutbrowser` (stdlib only, pytest)
|
||||
│ └── node/ # `@donutbrowser/sdk` (ESM TypeScript, node --test)
|
||||
├── patches/ # pnpm compatibility patches for secured dependencies
|
||||
├── flake.nix # Nix development environment
|
||||
└── .github/workflows/ # CI/CD pipelines
|
||||
@@ -101,6 +110,9 @@ The native suites use the published `tauri-wd` driver (pinned in `e2e/app/Cargo.
|
||||
into the ignored `e2e/.driver` root) and launch an `e2e`-feature build.
|
||||
Every session gets its own temporary Donut data/cache/log root, home directory,
|
||||
WebView store, ports, and sync bucket. Never point a suite at production or development data.
|
||||
Every suite runs the Donut window headless (`DONUT_E2E_HEADLESS=1`, forwarded as the tauri-wd
|
||||
`headless` capability), so a run never pops a window or steals focus. `DONUT_E2E_HEADED=1` shows
|
||||
the window when a failure needs watching.
|
||||
|
||||
`e2e/app/Cargo.lock` is generated, gitignored, and never edited by hand. `e2e/run.mjs` seeds it
|
||||
from `src-tauri/Cargo.lock` whenever that file is newer, so the harness always links the exact
|
||||
|
||||
@@ -10,6 +10,10 @@ extend-exclude = [
|
||||
]
|
||||
|
||||
[default.extend-words]
|
||||
# The IDN test fixtures in src-tauri/src/xray encode "cafe" as punycode
|
||||
# ("xn--caf-dma") and as percent-escapes ("caf%C3%A9"). Both leave a bare "caf"
|
||||
# token that is an encoding artefact, never a misspelling of "calf".
|
||||
caf = "caf"
|
||||
DBE = "DBE"
|
||||
nd = "nd"
|
||||
|
||||
|
||||
@@ -770,8 +770,7 @@ export class SyncService implements OnModuleInit {
|
||||
* 2. a write touched the scope and bumped its manifest ETag.
|
||||
*
|
||||
* This is *eventual* cross-device sync, gated by the poll interval.
|
||||
* Real-time push is intentionally not provided here — that lives in the
|
||||
* paid backend.
|
||||
* Real-time push is intentionally not provided here.
|
||||
*/
|
||||
subscribe(
|
||||
ctx: UserContext,
|
||||
|
||||
+16
-8
@@ -13,12 +13,14 @@ Install Donut dependencies with `pnpm install`. The runner installs the driver i
|
||||
`cargo install`, so a working Rust toolchain is the only extra requirement. The browser suite also
|
||||
needs
|
||||
`WAYFERN_TEST_TOKEN`. The runner reads it from the environment or Donut's ignored `.env` without
|
||||
printing it. When a local browser fixture is configured, the runner copies it into the test data
|
||||
root (using an isolated APFS clone on macOS); otherwise the browser suite downloads the current
|
||||
published build into that root.
|
||||
printing it. The browser suites always run the newest published Wayfern build. The download is
|
||||
saved as an ignored cache fixture under `.cache/e2e-wayfern-fixture`, which the runner copies into
|
||||
the test data root (using an isolated APFS clone on macOS) on later runs; a cached fixture holding
|
||||
any other version is replaced before the suite uses it, so the cache can never keep an old browser
|
||||
under test.
|
||||
|
||||
Set `DONUT_E2E_WAYFERN_PATH` to use a local browser fixture. Without it, the runner uses an ignored
|
||||
cache fixture when present and otherwise downloads the published test build.
|
||||
Set `DONUT_E2E_WAYFERN_PATH` to pin an explicit local bundle instead, for example a browser built
|
||||
from source. A pinned bundle is used as given, without the published-version check.
|
||||
|
||||
The real-network suite additionally requires Docker plus
|
||||
`RESIDENTIAL_PROXY_URL_ONE_HTTP` and `RESIDENTIAL_PROXY_URL_ONE_SOCKS`. It creates its own
|
||||
@@ -39,10 +41,15 @@ pnpm e2e:browser
|
||||
|
||||
Run everything with `pnpm e2e`. A normal run builds the Next frontend, `donut-proxy`, and the
|
||||
harness in `e2e/app`, then installs the `tauri-wd` CLI into the ignored `e2e/.driver` root when the
|
||||
version pinned by `e2e/app/Cargo.lock` is not already there. The harness enables Donut's `e2e`
|
||||
version pinned by `e2e/app/Cargo.toml` is not already there. The harness enables Donut's `e2e`
|
||||
feature and injects the WebDriver plugin so the production crate never depends on it. Both the
|
||||
plugin and the CLI come from the same pinned crates.io release, so they cannot drift apart. Bump
|
||||
the pin in `e2e/app/Cargo.toml` to move to a newer driver. Add `--no-build` to
|
||||
the pin in `e2e/app/Cargo.toml` to move to a newer driver. Every suite runs the Donut window
|
||||
headless (on macOS the window is transparent, click-through and never focused; elsewhere it is
|
||||
hidden), so a run never pops a window or steals focus. Set `DONUT_E2E_HEADED=1` to watch the
|
||||
window while debugging a failure; Wayfern browsers launched by a test are separate processes and
|
||||
show their own windows unless the test asks for a headless launch.
|
||||
Add `--no-build` to
|
||||
`node e2e/run.mjs --suite=<name>` only when all four outputs are current.
|
||||
`DONUT_E2E_KEEP_ARTIFACTS=1` retains successful local runs; failed runs are always retained and
|
||||
their location is printed. Raw screenshots, captured HTML, logs, and isolated app state stay local.
|
||||
@@ -74,7 +81,8 @@ runner redirects:
|
||||
- each sync test to a new MinIO bucket and random token.
|
||||
|
||||
The E2E feature suppresses automatic updater/download traffic, but explicit browser tests still
|
||||
exercise published Wayfern downloads when no local fixture exists. Entitlement fallback from
|
||||
exercise published Wayfern downloads whenever the cache fixture is missing or holds a different
|
||||
version than the published build. Entitlement fallback from
|
||||
`WAYFERN_TEST_TOKEN` exists only in the feature-gated test binary. Production builds never include
|
||||
the WebDriver plugin or this fallback.
|
||||
|
||||
|
||||
Generated
+223
-174
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -6,4 +6,4 @@ publish = false
|
||||
|
||||
[dependencies]
|
||||
donutbrowser-lib = { package = "donutbrowser", path = "../../src-tauri", features = ["e2e"] }
|
||||
tauri-wd = "=0.1.11"
|
||||
tauri-wd = "=0.2.0"
|
||||
|
||||
@@ -28,6 +28,9 @@ export const commandCoverage = {
|
||||
"window_decorations::get_window_decoration_layout",
|
||||
"get_onboarding_completed",
|
||||
"complete_onboarding",
|
||||
"data_root::get_data_root_info",
|
||||
"data_root::move_data_root",
|
||||
"data_root::clear_data_root_choice",
|
||||
],
|
||||
},
|
||||
profileEntities: {
|
||||
@@ -60,7 +63,22 @@ export const commandCoverage = {
|
||||
"update_profile_group",
|
||||
"delete_profile_group",
|
||||
"assign_profiles_to_group",
|
||||
"get_group_bookmarks",
|
||||
"set_group_bookmarks",
|
||||
"apply_group_bookmarks_to_profile",
|
||||
"delete_selected_profiles",
|
||||
"plan_proxy_distribution",
|
||||
"distribute_proxies_to_profiles",
|
||||
],
|
||||
},
|
||||
trash: {
|
||||
suite: "entities",
|
||||
level: "integration",
|
||||
commands: [
|
||||
"list_trashed_profiles",
|
||||
"restore_trashed_profile",
|
||||
"purge_trashed_profile",
|
||||
"empty_trash",
|
||||
],
|
||||
},
|
||||
proxyEntities: {
|
||||
@@ -74,6 +92,7 @@ export const commandCoverage = {
|
||||
"check_proxy_validity",
|
||||
"validate_vless_uri",
|
||||
"get_cached_proxy_check",
|
||||
"get_proxy_check_history",
|
||||
"export_proxies",
|
||||
"import_proxies_json",
|
||||
"parse_txt_proxies",
|
||||
@@ -88,6 +107,7 @@ export const commandCoverage = {
|
||||
"get_extension_icon",
|
||||
"add_extension",
|
||||
"add_unpacked_extension",
|
||||
"fetch_extension_from_url",
|
||||
"update_extension",
|
||||
"update_extension_from_path",
|
||||
"delete_extension",
|
||||
@@ -182,6 +202,13 @@ export const commandCoverage = {
|
||||
"fingerprint_consistency::match_profile_fingerprint_to_exit",
|
||||
"launch_gate::get_profile_pre_launch_checks",
|
||||
"launch_gate::ack_launch_gate",
|
||||
"wayfern_persona::get_profile_persona",
|
||||
"recorder::start_recipe_recording",
|
||||
"recorder::stop_recipe_recording",
|
||||
"recorder::get_recipe_recording",
|
||||
"profile::portable::export_profile",
|
||||
"profile::portable::preview_profile_archive",
|
||||
"profile::portable::import_profile_archive",
|
||||
"check_wayfern_terms_accepted",
|
||||
"check_wayfern_downloaded",
|
||||
"accept_wayfern_terms",
|
||||
@@ -194,6 +221,7 @@ export const commandCoverage = {
|
||||
"start_api_server",
|
||||
"stop_api_server",
|
||||
"get_api_server_status",
|
||||
"check_integration_connection",
|
||||
"start_mcp_server",
|
||||
"stop_mcp_server",
|
||||
"get_mcp_server_status",
|
||||
@@ -201,10 +229,20 @@ export const commandCoverage = {
|
||||
"list_mcp_agents",
|
||||
"add_mcp_to_agent",
|
||||
"remove_mcp_from_agent",
|
||||
"start_mcp_remote_bridge",
|
||||
"stop_mcp_remote_bridge",
|
||||
"get_mcp_remote_status",
|
||||
"get_remote_control_entitlement",
|
||||
"get_mcp_remote_credential",
|
||||
"rotate_mcp_remote_credential",
|
||||
"forget_mcp_remote_credential",
|
||||
"synchronizer::start_sync_session",
|
||||
"synchronizer::stop_sync_session",
|
||||
"synchronizer::remove_sync_follower",
|
||||
"synchronizer::get_sync_sessions",
|
||||
"synchronizer::set_sync_session_paused",
|
||||
"synchronizer::set_sync_follower_held",
|
||||
"synchronizer::arrange_sync_windows",
|
||||
],
|
||||
},
|
||||
syncAndEncryption: {
|
||||
@@ -289,6 +327,23 @@ export const commandCoverage = {
|
||||
"cookie_bot::delete_cookie_bot_user_template",
|
||||
],
|
||||
},
|
||||
agent: {
|
||||
suite: "integrations",
|
||||
level: "contract",
|
||||
commands: [
|
||||
"agent::start_agent_run",
|
||||
"agent::get_agent_runs",
|
||||
"agent::get_agent_run",
|
||||
"agent::cancel_agent_run",
|
||||
"agent::get_agent_recipes",
|
||||
"agent::create_agent_recipe",
|
||||
"agent::update_agent_recipe",
|
||||
"agent::delete_agent_recipe",
|
||||
"agent::start_agent_run_events",
|
||||
"agent::stop_agent_run_events",
|
||||
"agent::get_agent_run_events_status",
|
||||
],
|
||||
},
|
||||
updateContracts: {
|
||||
suite: "integrations",
|
||||
level: "contract",
|
||||
|
||||
+63
-50
@@ -91,6 +91,30 @@ export class AppSession {
|
||||
return path.join(this.root, "donut");
|
||||
}
|
||||
|
||||
/** Where this session's app looks for the Wayfern terms marker. */
|
||||
get wayfernTermsFile() {
|
||||
if (process.platform === "darwin") {
|
||||
return path.join(
|
||||
this.root,
|
||||
"home",
|
||||
"Library",
|
||||
"Application Support",
|
||||
"Wayfern",
|
||||
"license-accepted",
|
||||
);
|
||||
}
|
||||
if (process.platform === "win32") {
|
||||
return path.join(
|
||||
this.root,
|
||||
"windows",
|
||||
"roaming",
|
||||
"Wayfern",
|
||||
"license-accepted",
|
||||
);
|
||||
}
|
||||
return path.join(this.root, "xdg", "config", "Wayfern", "license-accepted");
|
||||
}
|
||||
|
||||
async start() {
|
||||
await Promise.all([
|
||||
mkdir(path.join(this.root, "home"), { recursive: true }),
|
||||
@@ -126,31 +150,7 @@ export class AppSession {
|
||||
});
|
||||
}
|
||||
if (this.wayfernTermsAccepted) {
|
||||
const termsFile =
|
||||
process.platform === "darwin"
|
||||
? path.join(
|
||||
this.root,
|
||||
"home",
|
||||
"Library",
|
||||
"Application Support",
|
||||
"Wayfern",
|
||||
"license-accepted",
|
||||
)
|
||||
: process.platform === "win32"
|
||||
? path.join(
|
||||
this.root,
|
||||
"windows",
|
||||
"roaming",
|
||||
"Wayfern",
|
||||
"license-accepted",
|
||||
)
|
||||
: path.join(
|
||||
this.root,
|
||||
"xdg",
|
||||
"config",
|
||||
"Wayfern",
|
||||
"license-accepted",
|
||||
);
|
||||
const termsFile = this.wayfernTermsFile;
|
||||
await mkdir(path.dirname(termsFile), { recursive: true });
|
||||
await writeFile(termsFile, `${Math.floor(Date.now() / 1000)}\n`, {
|
||||
flag: "wx",
|
||||
@@ -244,6 +244,14 @@ export class AppSession {
|
||||
DONUT_E2E_GEOIP_DOWNLOAD_URL: `${process.env.DONUT_E2E_FIXTURE_URL}/geoip.mmdb`,
|
||||
}
|
||||
: {}),
|
||||
// The city database has no organisation for an address; the ASN
|
||||
// one does, and it is what a proxy check reports as the exit's
|
||||
// ISP. Seeded separately so the suite can assert a real value.
|
||||
...(process.env.DONUT_E2E_GEOIP_ASN_FIXTURE_READY === "1"
|
||||
? {
|
||||
DONUT_E2E_GEOIP_ASN_DOWNLOAD_URL: `${process.env.DONUT_E2E_FIXTURE_URL}/geoip-asn.mmdb`,
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
: {}),
|
||||
...(this.token ? { WAYFERN_TEST_TOKEN: this.token } : {}),
|
||||
@@ -255,6 +263,11 @@ export class AppSession {
|
||||
env,
|
||||
cwd: this.cwd,
|
||||
startupTimeout: 120_000,
|
||||
// Set by run.mjs for every suite. The driver keeps the Donut window off
|
||||
// the user's screen (on macOS transparent, click-through and never key,
|
||||
// with the app as an accessory; hidden elsewhere), so a suite never
|
||||
// pops a window or steals focus.
|
||||
headless: process.env.DONUT_E2E_HEADLESS === "1",
|
||||
});
|
||||
await this.session.setTimeouts();
|
||||
await this.waitFor(
|
||||
@@ -369,13 +382,19 @@ export class AppSession {
|
||||
});
|
||||
}
|
||||
|
||||
async clickElement(element, description = "element") {
|
||||
async clickElement(target, description = "element") {
|
||||
let element;
|
||||
await this.waitFor(
|
||||
() =>
|
||||
this.execute(
|
||||
async () => {
|
||||
// Event-backed tables may replace a cell while its data is loading.
|
||||
// Resolve the current control on each attempt, as a browser locator does.
|
||||
element = typeof target === "function" ? await target() : target;
|
||||
if (!element) return false;
|
||||
return this.execute(
|
||||
`
|
||||
const node = arguments[0];
|
||||
if (!(node instanceof Element) || !node.isConnected) return false;
|
||||
if (node.matches(":disabled") || node.getAttribute("aria-disabled") === "true") return false;
|
||||
node.scrollIntoView({ block: "center", inline: "center" });
|
||||
const rect = node.getBoundingClientRect();
|
||||
const x = Math.floor(rect.left + rect.width / 2);
|
||||
@@ -384,7 +403,8 @@ export class AppSession {
|
||||
return Boolean(hit && (hit === node || node.contains(hit)));
|
||||
`,
|
||||
[element],
|
||||
),
|
||||
);
|
||||
},
|
||||
{ description: `pointer-interactable ${description}` },
|
||||
);
|
||||
await this.session.click(element);
|
||||
@@ -394,8 +414,9 @@ export class AppSession {
|
||||
text,
|
||||
{ exact = true, roles = ["button", "tab", "menuitem", "link"] } = {},
|
||||
) {
|
||||
const element = await this.execute(
|
||||
`
|
||||
const findElement = () =>
|
||||
this.execute(
|
||||
`
|
||||
const wanted = arguments[0];
|
||||
const exact = arguments[1];
|
||||
const roles = new Set(arguments[2]);
|
||||
@@ -412,13 +433,9 @@ export class AppSession {
|
||||
return roles.has(role) && visible(node) && (exact ? label === wanted : label.includes(wanted));
|
||||
}) ?? null;
|
||||
`,
|
||||
[text, exact, roles],
|
||||
);
|
||||
assert.ok(
|
||||
element,
|
||||
`No visible interactive element matched ${JSON.stringify(text)}`,
|
||||
);
|
||||
await this.clickElement(element, JSON.stringify(text));
|
||||
[text, exact, roles],
|
||||
);
|
||||
await this.clickElement(findElement, JSON.stringify(text));
|
||||
}
|
||||
|
||||
async clickTextIn(
|
||||
@@ -426,8 +443,9 @@ export class AppSession {
|
||||
text,
|
||||
{ exact = true, roles = ["button", "tab", "menuitem", "link"] } = {},
|
||||
) {
|
||||
const element = await this.execute(
|
||||
`
|
||||
const findElement = () =>
|
||||
this.execute(
|
||||
`
|
||||
const containers = [...document.querySelectorAll(arguments[0])];
|
||||
const wanted = arguments[1];
|
||||
const exact = arguments[2];
|
||||
@@ -450,20 +468,16 @@ export class AppSession {
|
||||
}
|
||||
return null;
|
||||
`,
|
||||
[containerSelector, text, exact, roles],
|
||||
);
|
||||
assert.ok(
|
||||
element,
|
||||
`No visible interactive element inside ${containerSelector} matched ${JSON.stringify(text)}`,
|
||||
);
|
||||
[containerSelector, text, exact, roles],
|
||||
);
|
||||
await this.clickElement(
|
||||
element,
|
||||
findElement,
|
||||
`${JSON.stringify(text)} inside ${containerSelector}`,
|
||||
);
|
||||
}
|
||||
|
||||
async clickSelector(selector) {
|
||||
const element = await this.waitFor(
|
||||
await this.clickElement(
|
||||
() =>
|
||||
this.execute(
|
||||
`
|
||||
@@ -476,9 +490,8 @@ export class AppSession {
|
||||
`,
|
||||
[selector],
|
||||
),
|
||||
{ description: `visible selector ${selector}` },
|
||||
selector,
|
||||
);
|
||||
await this.clickElement(element, selector);
|
||||
}
|
||||
|
||||
async fillSelector(selector, value) {
|
||||
|
||||
+186
-25
@@ -1,7 +1,7 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { existsSync } from "node:fs";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import {
|
||||
chmod,
|
||||
copyFile,
|
||||
@@ -15,6 +15,10 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
import { crc32 } from "node:zlib";
|
||||
import {
|
||||
WAYFERN_DOWNLOAD_CLIENT_TIMEOUT_MS,
|
||||
WAYFERN_DOWNLOAD_TIMEOUT_MS,
|
||||
} from "./limits.mjs";
|
||||
|
||||
export const TEST_BROWSER_VERSION = "150.0.7871.100";
|
||||
|
||||
@@ -31,6 +35,43 @@ export function defaultWayfernPath(projectRoot) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the cache fixture records which PUBLISHED version it was installed for.
|
||||
*
|
||||
* The bundle's own `CFBundleShortVersionString` cannot answer that question: a
|
||||
* published version and the version stamped inside the bundle it serves do not
|
||||
* always agree, and the app keys everything (download registry, profile
|
||||
* `version`, release types) off the PUBLISHED string. Comparing the bundle's
|
||||
* own version against the published one would therefore call an up-to-date
|
||||
* fixture stale and re-download 1 GB on every single run.
|
||||
*/
|
||||
function fixtureStampPath(projectRoot) {
|
||||
return path.join(
|
||||
path.dirname(defaultWayfernPath(projectRoot)),
|
||||
"published-version.txt",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The published version the cache fixture stands for, or `null` when there is
|
||||
* no fixture.
|
||||
*
|
||||
* Falls back to the bundle's own version when no stamp is present, which is
|
||||
* what a hand-installed fixture looks like: it is only right when the two
|
||||
* agree, and when they do not the fixture is replaced, which is the safe way
|
||||
* to be wrong.
|
||||
*/
|
||||
export function cachedFixtureVersion(projectRoot) {
|
||||
const bundle = defaultWayfernPath(projectRoot);
|
||||
if (!existsSync(bundle)) return null;
|
||||
const stamp = fixtureStampPath(projectRoot);
|
||||
if (existsSync(stamp)) {
|
||||
const recorded = readFileSync(stamp, "utf8").trim();
|
||||
if (recorded) return recorded;
|
||||
}
|
||||
return inspectWayfern(bundle).version;
|
||||
}
|
||||
|
||||
export function wayfernExecutable(bundlePath) {
|
||||
if (process.platform === "darwin") {
|
||||
return path.join(bundlePath, "Contents", "MacOS", "Wayfern");
|
||||
@@ -80,10 +121,57 @@ async function cloneAppBundle(source, destination) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Where the app itself resolves the current Wayfern build (api_client.rs). */
|
||||
const WAYFERN_RELEASE_URL = "https://donutbrowser.com/wayfern.json";
|
||||
|
||||
/**
|
||||
* The newest published Wayfern version, read from the same manifest the app
|
||||
* reads.
|
||||
*
|
||||
* Deliberately NOT asked of a running app session. Seeding a browser into a
|
||||
* session's data root only works before that session starts: a running app
|
||||
* runs `cleanup_unused_binaries`, which deletes any binary directory no
|
||||
* profile references, and a just-seeded fixture is exactly that. Resolving the
|
||||
* version over plain HTTP keeps the seed ahead of app startup.
|
||||
*/
|
||||
async function publishedWayfernVersion() {
|
||||
const response = await fetch(WAYFERN_RELEASE_URL, {
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
});
|
||||
assert.ok(
|
||||
response.ok,
|
||||
`Could not read ${WAYFERN_RELEASE_URL}: HTTP ${response.status}`,
|
||||
);
|
||||
const manifest = await response.json();
|
||||
assert.ok(
|
||||
typeof manifest.version === "string" && manifest.version,
|
||||
`No Wayfern version published at ${WAYFERN_RELEASE_URL}`,
|
||||
);
|
||||
return manifest.version;
|
||||
}
|
||||
|
||||
async function downloadWayfern(app, version) {
|
||||
await app.session.setTimeouts({ script: WAYFERN_DOWNLOAD_TIMEOUT_MS });
|
||||
try {
|
||||
await app.invoke(
|
||||
"download_browser",
|
||||
{ browserStr: "wayfern", version },
|
||||
WAYFERN_DOWNLOAD_CLIENT_TIMEOUT_MS,
|
||||
);
|
||||
} finally {
|
||||
await app.session.setTimeouts();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Put the build this session just downloaded into the cache fixture, in place
|
||||
* of whatever build the cache held before. The swap goes through a staging
|
||||
* copy and renames, so a suite that dies mid-copy leaves the old fixture or
|
||||
* the new one on disk, never a half-written bundle.
|
||||
*/
|
||||
async function cacheDownloadedWayfern(app, projectRoot, version) {
|
||||
if (process.env.DONUT_E2E_WAYFERN_PATH) return;
|
||||
const destination = defaultWayfernPath(projectRoot);
|
||||
if (existsSync(destination)) return;
|
||||
|
||||
const installDir = path.join(
|
||||
app.dataRoot,
|
||||
@@ -100,7 +188,9 @@ async function cacheDownloadedWayfern(app, projectRoot, version) {
|
||||
process.platform === "win32" ? "wayfern.exe" : "wayfern",
|
||||
);
|
||||
const staging = `${destination}.tmp-${process.pid}`;
|
||||
const retired = `${destination}.stale-${process.pid}`;
|
||||
await rm(staging, { recursive: true, force: true });
|
||||
await rm(retired, { recursive: true, force: true });
|
||||
try {
|
||||
if (process.platform === "darwin") {
|
||||
await cloneAppBundle(source, staging);
|
||||
@@ -109,10 +199,25 @@ async function cacheDownloadedWayfern(app, projectRoot, version) {
|
||||
await copyFile(source, staging);
|
||||
if (process.platform !== "win32") await chmod(staging, 0o755);
|
||||
}
|
||||
if (existsSync(destination)) await rename(destination, retired);
|
||||
await rename(staging, destination);
|
||||
// Stamped only after the bundle is in place, so an interrupted swap can
|
||||
// never leave a stamp claiming a version the fixture does not hold.
|
||||
await writeFile(fixtureStampPath(projectRoot), `${version}\n`);
|
||||
} catch (error) {
|
||||
await rm(staging, { recursive: true, force: true });
|
||||
if (!existsSync(destination) && existsSync(retired)) {
|
||||
await rename(retired, destination);
|
||||
}
|
||||
if (!existsSync(destination)) throw error;
|
||||
// The session itself runs the build it downloaded; only the cache is
|
||||
// behind, and the next run resolves the published version again and
|
||||
// replaces it then.
|
||||
console.warn(
|
||||
`[donut-e2e] Could not refresh the Wayfern fixture cache: ${error}`,
|
||||
);
|
||||
} finally {
|
||||
await rm(retired, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,36 +265,46 @@ export async function seedWayfern(dataRoot, wayfern) {
|
||||
return installDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Make the newest published Wayfern available to `app` and report the version
|
||||
* it will run.
|
||||
*
|
||||
* `DONUT_E2E_WAYFERN_PATH` pins an explicit bundle and is used as given: that
|
||||
* is how a locally built browser gets under test. Without it the suite runs
|
||||
* the build the product would offer today, always. The ignored cache fixture
|
||||
* only ever saves the download: it is used when it holds exactly that build
|
||||
* and replaced when it holds any other, so a cache filled months ago can never
|
||||
* quietly keep an old browser under test.
|
||||
*/
|
||||
export async function prepareWayfern(app, projectRoot) {
|
||||
const localBundle = defaultWayfernPath(projectRoot);
|
||||
if (existsSync(localBundle)) {
|
||||
if (process.env.DONUT_E2E_WAYFERN_PATH) {
|
||||
const wayfern = inspectWayfern(localBundle);
|
||||
await seedWayfern(app.dataRoot, wayfern);
|
||||
return { version: wayfern.version, source: "local fixture" };
|
||||
return { version: wayfern.version, source: "pinned fixture" };
|
||||
}
|
||||
|
||||
const version = await publishedWayfernVersion();
|
||||
const cachedVersion = cachedFixtureVersion(projectRoot);
|
||||
if (cachedVersion === version) {
|
||||
// Seeded under the PUBLISHED version, not the bundle's own, because that
|
||||
// is the string the app itself would have registered had it downloaded
|
||||
// this build, and what every later `version` assertion compares against.
|
||||
// Seeded BEFORE the app starts, or its unused-binary cleanup deletes it.
|
||||
await seedWayfern(app.dataRoot, {
|
||||
...inspectWayfern(localBundle),
|
||||
version,
|
||||
});
|
||||
return { version, source: "cached fixture" };
|
||||
}
|
||||
if (cachedVersion) {
|
||||
console.log(
|
||||
`[donut-e2e] Cached Wayfern fixture ${cachedVersion} is not the published ${version}; replacing it`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!app.session) await app.start();
|
||||
const current = await app.invoke("fetch_browser_versions_with_count", {
|
||||
browserStr: "wayfern",
|
||||
});
|
||||
assert.ok(
|
||||
current.versions.length > 0,
|
||||
"No Wayfern build is published for this platform",
|
||||
);
|
||||
const version = current.versions[0];
|
||||
await app.session.setTimeouts({ script: 600_000 });
|
||||
try {
|
||||
await app.invoke(
|
||||
"download_browser",
|
||||
{
|
||||
browserStr: "wayfern",
|
||||
version,
|
||||
},
|
||||
620_000,
|
||||
);
|
||||
} finally {
|
||||
await app.session.setTimeouts();
|
||||
}
|
||||
await downloadWayfern(app, version);
|
||||
await cacheDownloadedWayfern(app, projectRoot, version);
|
||||
return { version, source: "published download" };
|
||||
}
|
||||
@@ -526,3 +641,49 @@ export function writeChromiumHistory(dbPath, urls) {
|
||||
}
|
||||
db.close();
|
||||
}
|
||||
|
||||
/** The name and version the CRX fixture's own manifest declares. */
|
||||
export const CRX_EXTENSION_NAME = "Donut E2E Web Extension";
|
||||
export const CRX_EXTENSION_VERSION = "3.2.1";
|
||||
|
||||
/**
|
||||
* Wrap `zip` in a CRX3 container, the shape the Chrome Web Store actually
|
||||
* serves: `Cr24`, a little-endian format version of 3, a little-endian header
|
||||
* length, that many bytes of signature header, and only then the ZIP.
|
||||
*
|
||||
* The header bytes are filler — nothing in Donut verifies the signature, and a
|
||||
* real one would need a packing key. What a test built on this proves is that
|
||||
* the importer reads the ZIP at the offset the header declares instead of
|
||||
* scanning the file for a `PK` marker, which is the bug the format invites.
|
||||
*/
|
||||
export function buildCrx3(zip, headerBytes = 137) {
|
||||
const prefix = Buffer.alloc(12);
|
||||
prefix.write("Cr24", 0, "ascii");
|
||||
prefix.writeUInt32LE(3, 4);
|
||||
prefix.writeUInt32LE(headerBytes, 8);
|
||||
return Buffer.concat([prefix, Buffer.alloc(headerBytes, 0x42), zip]);
|
||||
}
|
||||
|
||||
/** A CRX3 whose payload is a real Manifest V3 archive. */
|
||||
export function extensionCrx3({
|
||||
name = CRX_EXTENSION_NAME,
|
||||
version = CRX_EXTENSION_VERSION,
|
||||
} = {}) {
|
||||
return buildCrx3(
|
||||
buildStoredZip([
|
||||
{
|
||||
name: "manifest.json",
|
||||
data: `${JSON.stringify(
|
||||
{
|
||||
manifest_version: 3,
|
||||
name,
|
||||
version,
|
||||
description: "Isolated test extension served over a link",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
},
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* The longest command the harness ever waits on: `download_browser` pulling a
|
||||
* published Wayfern build of about 1 GB, which a slow link needs the better
|
||||
* part of half an hour for.
|
||||
*
|
||||
* Every clock around that command is derived from this one number so they can
|
||||
* never disagree again. The session script timeout is this value; the client
|
||||
* gives up a little later; the driver's outer per-command bound
|
||||
* (`--command-timeout`) later still. Ordered that way, a download that is
|
||||
* genuinely too slow surfaces as the driver's own script-timeout error rather
|
||||
* than as a torn connection somewhere in between.
|
||||
*/
|
||||
export const WAYFERN_DOWNLOAD_TIMEOUT_MS = 30 * 60 * 1000;
|
||||
|
||||
/** How long the client waits on a download command before it gives up. */
|
||||
export const WAYFERN_DOWNLOAD_CLIENT_TIMEOUT_MS =
|
||||
WAYFERN_DOWNLOAD_TIMEOUT_MS + 20_000;
|
||||
|
||||
/** The driver's outer per-command bound, in the whole seconds its flag takes. */
|
||||
export const DRIVER_COMMAND_TIMEOUT_SECONDS =
|
||||
Math.ceil(WAYFERN_DOWNLOAD_TIMEOUT_MS / 1000) + 60;
|
||||
+67
-14
@@ -1,9 +1,59 @@
|
||||
import assert from "node:assert/strict";
|
||||
import http from "node:http";
|
||||
|
||||
export const ELEMENT_KEY = "element-6066-11e4-a52e-4f735466cecf";
|
||||
|
||||
function abortAfter(timeoutMs) {
|
||||
return AbortSignal.timeout(timeoutMs);
|
||||
/**
|
||||
* One HTTP exchange with the driver, over `node:http` rather than `fetch`.
|
||||
*
|
||||
* `fetch` is undici, and undici gives every request a 300 s headers timeout
|
||||
* of its own. A long `execute/async` sends no headers until the script
|
||||
* completes, so a `download_browser` that pulls a 1 GB Wayfern build over a
|
||||
* slow link died at 300 s whatever `timeoutMs` asked for. `node:http` has no
|
||||
* such default, which leaves `timeoutMs` as the only clock.
|
||||
*/
|
||||
function exchange(method, url, body, timeoutMs) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const payload = body === undefined ? undefined : JSON.stringify(body);
|
||||
const request = http.request(
|
||||
url,
|
||||
{
|
||||
method,
|
||||
headers:
|
||||
payload === undefined
|
||||
? {}
|
||||
: {
|
||||
"content-type": "application/json",
|
||||
"content-length": Buffer.byteLength(payload),
|
||||
},
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
},
|
||||
(response) => {
|
||||
const chunks = [];
|
||||
response.on("data", (chunk) => chunks.push(chunk));
|
||||
response.on("error", reject);
|
||||
response.on("end", () =>
|
||||
resolve({
|
||||
status: response.statusCode ?? 0,
|
||||
text: Buffer.concat(chunks).toString("utf8"),
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
request.on("error", (error) => {
|
||||
const timedOut =
|
||||
error?.name === "AbortError" || error?.name === "TimeoutError";
|
||||
reject(
|
||||
timedOut
|
||||
? new Error(
|
||||
`WebDriver ${method} ${url} gave no response within ${timeoutMs}ms`,
|
||||
{ cause: error },
|
||||
)
|
||||
: error,
|
||||
);
|
||||
});
|
||||
request.end(payload);
|
||||
});
|
||||
}
|
||||
|
||||
export class WebDriverClient {
|
||||
@@ -12,30 +62,27 @@ export class WebDriverClient {
|
||||
}
|
||||
|
||||
async request(method, pathname, body, timeoutMs = 330_000) {
|
||||
const response = await fetch(`${this.baseUrl}${pathname}`, {
|
||||
const { status, text } = await exchange(
|
||||
method,
|
||||
headers:
|
||||
body === undefined ? undefined : { "content-type": "application/json" },
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
signal: abortAfter(timeoutMs),
|
||||
});
|
||||
const text = await response.text();
|
||||
`${this.baseUrl}${pathname}`,
|
||||
body,
|
||||
timeoutMs,
|
||||
);
|
||||
let payload = null;
|
||||
if (text) {
|
||||
try {
|
||||
payload = JSON.parse(text);
|
||||
} catch {
|
||||
throw new Error(
|
||||
`WebDriver ${method} ${pathname} returned non-JSON HTTP ${response.status}: ${text.slice(0, 500)}`,
|
||||
`WebDriver ${method} ${pathname} returned non-JSON HTTP ${status}: ${text.slice(0, 500)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
const error = payload?.value?.error;
|
||||
if (!response.ok) {
|
||||
const message =
|
||||
payload?.value?.message ?? text ?? `HTTP ${response.status}`;
|
||||
if (status < 200 || status >= 300) {
|
||||
const message = payload?.value?.message ?? text ?? `HTTP ${status}`;
|
||||
throw new Error(
|
||||
`WebDriver ${method} ${pathname} failed (${error ?? response.status}): ${message}`,
|
||||
`WebDriver ${method} ${pathname} failed (${error ?? status}): ${message}`,
|
||||
);
|
||||
}
|
||||
return payload?.value;
|
||||
@@ -51,11 +98,17 @@ export class WebDriverClient {
|
||||
env = {},
|
||||
cwd,
|
||||
startupTimeout = 90_000,
|
||||
headless = false,
|
||||
}) {
|
||||
const options = { application, args, env, startupTimeout };
|
||||
if (cwd) {
|
||||
options.cwd = cwd;
|
||||
}
|
||||
// Only sent when asked, so a driver build without the capability is not
|
||||
// handed an option it would reject.
|
||||
if (headless) {
|
||||
options.headless = true;
|
||||
}
|
||||
const value = await this.request(
|
||||
"POST",
|
||||
"/session",
|
||||
|
||||
+90
-12
@@ -27,6 +27,8 @@ import path from "node:path";
|
||||
import { pipeline } from "node:stream/promises";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { createSafeDiagnostics } from "./lib/diagnostics.mjs";
|
||||
import { extensionCrx3 } from "./lib/fixtures.mjs";
|
||||
import { DRIVER_COMMAND_TIMEOUT_SECONDS } from "./lib/limits.mjs";
|
||||
|
||||
const dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const projectRoot = path.resolve(dirname, "..");
|
||||
@@ -51,7 +53,7 @@ const donutLockfile = path.join(projectRoot, "src-tauri", "Cargo.lock");
|
||||
|
||||
const suiteFiles = {
|
||||
smoke: ["diagnostics.test.mjs", "smoke.test.mjs", "coverage.test.mjs"],
|
||||
ui: ["ui.test.mjs"],
|
||||
ui: ["ui.test.mjs", "motion.test.mjs"],
|
||||
entities: ["entities.test.mjs"],
|
||||
network: ["network.test.mjs"],
|
||||
integrations: ["integrations.test.mjs"],
|
||||
@@ -62,6 +64,7 @@ const suiteFiles = {
|
||||
"coverage.test.mjs",
|
||||
"smoke.test.mjs",
|
||||
"ui.test.mjs",
|
||||
"motion.test.mjs",
|
||||
"entities.test.mjs",
|
||||
"network.test.mjs",
|
||||
"integrations.test.mjs",
|
||||
@@ -246,7 +249,7 @@ function pinnedDriverVersion() {
|
||||
const match = manifest.match(/^tauri-wd\s*=\s*"=([^"]+)"$/m);
|
||||
if (!match) {
|
||||
throw new Error(
|
||||
'e2e/app/Cargo.toml must pin tauri-wd to an exact version, e.g. tauri-wd = "=0.1.11"',
|
||||
'e2e/app/Cargo.toml must pin tauri-wd to an exact version, e.g. tauri-wd = "=0.2.0"',
|
||||
);
|
||||
}
|
||||
return match[1];
|
||||
@@ -310,7 +313,7 @@ function buildAll() {
|
||||
ensureDriver();
|
||||
}
|
||||
|
||||
function startFixtureServer(geoIpFixture) {
|
||||
function startFixtureServer(geoIpFixture, geoIpAsnFixture) {
|
||||
const server = http.createServer((request, response) => {
|
||||
const url = new URL(request.url, "http://127.0.0.1");
|
||||
if (url.pathname === "/health") {
|
||||
@@ -344,6 +347,32 @@ function startFixtureServer(geoIpFixture) {
|
||||
response.end("ads.e2e.invalid\ntracker.e2e.invalid\n");
|
||||
return;
|
||||
}
|
||||
// A CRX3 container, the shape the Chrome Web Store serves. The extension
|
||||
// importer has to find the ZIP at the offset the header declares rather
|
||||
// than scanning the file, so the fixture is a real container and not a
|
||||
// renamed archive.
|
||||
if (url.pathname === "/extension.crx") {
|
||||
const crx = extensionCrx3();
|
||||
response.writeHead(200, {
|
||||
"content-type": "application/x-chrome-extension",
|
||||
"content-length": String(crx.length),
|
||||
"cache-control": "no-store",
|
||||
});
|
||||
response.end(crx);
|
||||
return;
|
||||
}
|
||||
// Named like an archive, but not one. A link import must refuse this
|
||||
// rather than storing a broken extension.
|
||||
if (url.pathname === "/not-an-extension.zip") {
|
||||
const body = Buffer.from("<!doctype html><html>not an archive</html>");
|
||||
response.writeHead(200, {
|
||||
"content-type": "application/zip",
|
||||
"content-length": String(body.length),
|
||||
"cache-control": "no-store",
|
||||
});
|
||||
response.end(body);
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/geoip.mmdb" && geoIpFixture) {
|
||||
response.writeHead(200, {
|
||||
"content-type": "application/octet-stream",
|
||||
@@ -352,6 +381,14 @@ function startFixtureServer(geoIpFixture) {
|
||||
createReadStream(geoIpFixture).pipe(response);
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/geoip-asn.mmdb" && geoIpAsnFixture) {
|
||||
response.writeHead(200, {
|
||||
"content-type": "application/octet-stream",
|
||||
"content-length": String(statSync(geoIpAsnFixture).size),
|
||||
});
|
||||
createReadStream(geoIpAsnFixture).pipe(response);
|
||||
return;
|
||||
}
|
||||
response.writeHead(200, {
|
||||
"content-type": "text/html; charset=utf-8",
|
||||
"cache-control": "no-store",
|
||||
@@ -376,19 +413,42 @@ function startFixtureServer(geoIpFixture) {
|
||||
}
|
||||
|
||||
async function ensureGeoIpFixture() {
|
||||
if (process.env.DONUT_E2E_GEOIP_FIXTURE) {
|
||||
const fixture = path.resolve(process.env.DONUT_E2E_GEOIP_FIXTURE);
|
||||
return ensureMmdbFixture("GeoLite2-City.mmdb", "-City.mmdb", {
|
||||
override: process.env.DONUT_E2E_GEOIP_FIXTURE,
|
||||
overrideName: "DONUT_E2E_GEOIP_FIXTURE",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The autonomous-system database. Separate from the city one because that is
|
||||
* how MaxMind publishes them, and because the organisation a proxy check
|
||||
* reports as the exit's ISP lives only in this file.
|
||||
*/
|
||||
async function ensureGeoIpAsnFixture() {
|
||||
return ensureMmdbFixture("GeoLite2-ASN.mmdb", "-ASN.mmdb", {
|
||||
override: process.env.DONUT_E2E_GEOIP_ASN_FIXTURE,
|
||||
overrideName: "DONUT_E2E_GEOIP_ASN_FIXTURE",
|
||||
});
|
||||
}
|
||||
|
||||
async function ensureMmdbFixture(
|
||||
fileName,
|
||||
assetSuffix,
|
||||
{ override, overrideName },
|
||||
) {
|
||||
if (override) {
|
||||
const fixture = path.resolve(override);
|
||||
if (!existsSync(fixture)) {
|
||||
throw new Error(`DONUT_E2E_GEOIP_FIXTURE does not exist: ${fixture}`);
|
||||
throw new Error(`${overrideName} does not exist: ${fixture}`);
|
||||
}
|
||||
return fixture;
|
||||
}
|
||||
const toolsDir = path.join(os.tmpdir(), "donut-e2e-tools");
|
||||
const fixture = path.join(toolsDir, "GeoLite2-City.mmdb");
|
||||
const fixture = path.join(toolsDir, fileName);
|
||||
await mkdir(toolsDir, { recursive: true });
|
||||
if (existsSync(fixture)) return fixture;
|
||||
|
||||
log("Downloading GeoLite City E2E dependency");
|
||||
log(`Downloading ${fileName} E2E dependency`);
|
||||
const releases = await fetch(
|
||||
"https://api.github.com/repos/P3TERX/GeoLite.mmdb/releases",
|
||||
{
|
||||
@@ -405,8 +465,8 @@ async function ensureGeoIpFixture() {
|
||||
});
|
||||
const url = releases
|
||||
.flatMap((release) => release.assets ?? [])
|
||||
.find((asset) => asset.name.endsWith("-City.mmdb"))?.browser_download_url;
|
||||
if (!url) throw new Error("No GeoLite City MMDB asset was found");
|
||||
.find((asset) => asset.name.endsWith(assetSuffix))?.browser_download_url;
|
||||
if (!url) throw new Error(`No GeoLite ${assetSuffix} asset was found`);
|
||||
const temporary = `${fixture}.${process.pid}.tmp`;
|
||||
await download(url, temporary);
|
||||
await rename(temporary, fixture);
|
||||
@@ -913,8 +973,10 @@ async function main() {
|
||||
"4",
|
||||
"--startup-timeout",
|
||||
"120",
|
||||
// Sized to the longest command the suites issue (a Wayfern download),
|
||||
// so the driver never cuts a command shorter than the session asked.
|
||||
"--command-timeout",
|
||||
"630",
|
||||
String(DRIVER_COMMAND_TIMEOUT_SECONDS),
|
||||
"--log",
|
||||
options.verbose ? "debug" : "info",
|
||||
],
|
||||
@@ -936,7 +998,8 @@ async function main() {
|
||||
(options.suite === "network" || options.suite === "full") &&
|
||||
process.env.DONUT_E2E_SKIP_NETWORK_TEST !== "1";
|
||||
const geoIpFixture = needsBrowser ? await ensureGeoIpFixture() : null;
|
||||
fixture = await startFixtureServer(geoIpFixture);
|
||||
const geoIpAsnFixture = needsBrowser ? await ensureGeoIpAsnFixture() : null;
|
||||
fixture = await startFixtureServer(geoIpFixture, geoIpAsnFixture);
|
||||
let sync = {};
|
||||
if (options.suite === "sync" || options.suite === "full") {
|
||||
sync = await startSyncInfrastructure(runRoot, options, records);
|
||||
@@ -973,6 +1036,11 @@ async function main() {
|
||||
"--test",
|
||||
"--test-concurrency=1",
|
||||
"--test-reporter=spec",
|
||||
// One test out of a suite, for iterating on a failure without paying
|
||||
// for the rest of the file. Never set in CI.
|
||||
...(process.env.DONUT_E2E_TEST_NAME_PATTERN
|
||||
? [`--test-name-pattern=${process.env.DONUT_E2E_TEST_NAME_PATTERN}`]
|
||||
: []),
|
||||
...files,
|
||||
];
|
||||
const child = spawn(process.execPath, testArgs, {
|
||||
@@ -985,6 +1053,16 @@ async function main() {
|
||||
DONUT_E2E_DRIVER_URL: `http://127.0.0.1:${driverPort}`,
|
||||
DONUT_E2E_FIXTURE_URL: `http://127.0.0.1:${fixture.port}`,
|
||||
DONUT_E2E_GEOIP_FIXTURE_READY: geoIpFixture ? "1" : "0",
|
||||
DONUT_E2E_GEOIP_ASN_FIXTURE_READY: geoIpAsnFixture ? "1" : "0",
|
||||
// Every suite runs the Donut window headless so a local run never
|
||||
// pops a window or steals focus: the app builds it hidden and the
|
||||
// tauri-wd plugin keeps it off screen (on macOS transparent,
|
||||
// click-through and never key, with the app as an accessory; hidden
|
||||
// elsewhere). AppSession forwards this as the headless capability;
|
||||
// only the driver's TAURI_WEBDRIVER_HEADLESS reaches the app.
|
||||
// DONUT_E2E_HEADED=1 shows the window again when a failure needs
|
||||
// watching. Wayfern itself is a separate process and unaffected.
|
||||
DONUT_E2E_HEADLESS: process.env.DONUT_E2E_HEADED === "1" ? "0" : "1",
|
||||
WAYFERN_TEST_TOKEN: token,
|
||||
RESIDENTIAL_PROXY_URL_ONE_SOCKS:
|
||||
localValues.RESIDENTIAL_PROXY_URL_ONE_SOCKS ?? "",
|
||||
|
||||
+623
-36
@@ -5,11 +5,12 @@ import { mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import en from "../../src/i18n/locales/en.json" with { type: "json" };
|
||||
import { appFromEnvironment } from "../lib/app.mjs";
|
||||
import { CdpClient } from "../lib/cdp.mjs";
|
||||
import {
|
||||
defaultWayfernPath,
|
||||
inspectWayfern,
|
||||
cachedFixtureVersion,
|
||||
currentHostOs,
|
||||
prepareWayfern,
|
||||
writeUnpackedExtension,
|
||||
} from "../lib/fixtures.mjs";
|
||||
@@ -114,6 +115,22 @@ async function snapshotFile(file) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The exit-derived fields `WayfernConfig.location` may hold. Mirrors
|
||||
* `LOCALE_CARRY_OVER_KEYS` in wayfern_manager.rs: anything outside this set is
|
||||
* a device field, and a device field never belongs to the location.
|
||||
*/
|
||||
const LOCATION_KEYS = new Set([
|
||||
"timezone",
|
||||
"timezoneOffset",
|
||||
"language",
|
||||
"languages",
|
||||
"latitude",
|
||||
"longitude",
|
||||
"accuracy",
|
||||
]);
|
||||
|
||||
/** `fingerprint` is the serialised fingerprint STRING, or null for a fresh one. */
|
||||
async function createRealProfile(app, version, name, fingerprint = null) {
|
||||
return app.invoke("create_browser_profile_new", {
|
||||
name,
|
||||
@@ -138,12 +155,9 @@ test("real Wayfern fingerprinting, terms, API automation, CDP, cookies, and proc
|
||||
assert.ok(process.env.WAYFERN_TEST_TOKEN, "WAYFERN_TEST_TOKEN is required");
|
||||
const realTermsFile = realWayfernTermsPath();
|
||||
const realTermsBefore = await snapshotFile(realTermsFile);
|
||||
const localWayfernPath = defaultWayfernPath(
|
||||
const localWayfernVersion = cachedFixtureVersion(
|
||||
process.env.DONUT_E2E_PROJECT_ROOT,
|
||||
);
|
||||
const localWayfernVersion = existsSync(localWayfernPath)
|
||||
? inspectWayfern(localWayfernPath).version
|
||||
: null;
|
||||
const app = appFromEnvironment("browser-wayfern", {
|
||||
seedVersionCache: localWayfernVersion ?? false,
|
||||
wayfernTermsAccepted: false,
|
||||
@@ -159,8 +173,23 @@ test("real Wayfern fingerprinting, terms, API automation, CDP, cookies, and proc
|
||||
|
||||
assert.equal(await app.invoke("check_wayfern_downloaded"), true);
|
||||
assert.equal(await app.invoke("check_wayfern_terms_accepted"), false);
|
||||
// The gate is a real modal until the terms are accepted, and acceptance
|
||||
// through the bridge (not the dialog's own button) must lift it too: the
|
||||
// frontend learns about the marker from the backend's event, not from a
|
||||
// restart.
|
||||
const termsDialogVisible = () =>
|
||||
app.execute(
|
||||
`return [...document.querySelectorAll('[role="dialog"]')].some(node => node.textContent.includes(arguments[0]));`,
|
||||
[en.wayfernTerms.title],
|
||||
);
|
||||
await app.waitFor(termsDialogVisible, {
|
||||
description: "the Wayfern terms dialog before acceptance",
|
||||
});
|
||||
await app.invoke("accept_wayfern_terms");
|
||||
assert.equal(await app.invoke("check_wayfern_terms_accepted"), true);
|
||||
await app.waitFor(async () => !(await termsDialogVisible()), {
|
||||
description: "the Wayfern terms dialog to close after acceptance",
|
||||
});
|
||||
assert.ok(
|
||||
(
|
||||
await app.invoke("get_downloaded_browser_versions", {
|
||||
@@ -199,6 +228,18 @@ test("real Wayfern fingerprinting, terms, API automation, CDP, cookies, and proc
|
||||
})
|
||||
).versions.includes(prepared.version),
|
||||
);
|
||||
// The app's own resolver must agree with the release manifest the harness
|
||||
// read when it decided the cached fixture was current. If these two ever
|
||||
// diverge, the fixture check compares against a version the app will never
|
||||
// ask for, and the suite silently runs an old browser again.
|
||||
assert.ok(
|
||||
(
|
||||
await app.invoke("fetch_browser_versions_with_count", {
|
||||
browserStr: "wayfern",
|
||||
})
|
||||
).versions.includes(prepared.version),
|
||||
"the app must resolve the same published version the fixture was chosen for",
|
||||
);
|
||||
assert.equal(
|
||||
(await app.invoke("get_browser_release_types", { browserStr: "wayfern" }))
|
||||
.stable,
|
||||
@@ -223,8 +264,9 @@ test("real Wayfern fingerprinting, terms, API automation, CDP, cookies, and proc
|
||||
"Wayfern returned an incomplete fingerprint",
|
||||
);
|
||||
// A browser with the identity API must hand back the UUID the device was
|
||||
// derived from. Without it the profile cannot reproduce the device, since
|
||||
// it stores none.
|
||||
// derived from. The device itself is a view to show once and discard: an
|
||||
// identity-backed profile stores the id and the exit's location, never the
|
||||
// payload, so no fingerprint sits on disk to be copied.
|
||||
const identityCapable =
|
||||
Number.parseInt(prepared.version.split(".")[0], 10) >= 151;
|
||||
assert.equal(
|
||||
@@ -232,16 +274,34 @@ test("real Wayfern fingerprinting, terms, API automation, CDP, cookies, and proc
|
||||
identityCapable,
|
||||
"identity_id must be present exactly on browsers with the identity API",
|
||||
);
|
||||
assert.equal(
|
||||
sample.identity_baseline,
|
||||
undefined,
|
||||
"the retired identity baseline must not be handed back",
|
||||
);
|
||||
assert.ok(
|
||||
sample.location === null || typeof sample.location === "string",
|
||||
"location is the exit-derived JSON object, or null when none resolved",
|
||||
);
|
||||
if (typeof sample.location === "string") {
|
||||
const locationKeys = Object.keys(JSON.parse(sample.location));
|
||||
assert.ok(locationKeys.length > 0, "a resolved location is never empty");
|
||||
for (const key of locationKeys) {
|
||||
assert.ok(
|
||||
LOCATION_KEYS.has(key),
|
||||
`${key} is a device field and must not travel in the location`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const profile = await createRealProfile(
|
||||
app,
|
||||
prepared.version,
|
||||
`Real Wayfern (${prepared.source})`,
|
||||
);
|
||||
// An identity-backed profile stores the identity and never the device: the
|
||||
// browser rebuilds the device from the id on every launch. A browser
|
||||
// without the identity API has nowhere to put an id, so there the payload
|
||||
// is still what gets stored.
|
||||
// An identity-backed profile stores the identity and the location and never
|
||||
// the device: the browser rebuilds it from the id on every launch. A legacy
|
||||
// browser stores the whole payload.
|
||||
assert.equal(
|
||||
typeof profile.wayfern_config.identity_id === "string",
|
||||
identityCapable,
|
||||
@@ -263,6 +323,37 @@ test("real Wayfern fingerprinting, terms, API automation, CDP, cookies, and proc
|
||||
await app.invoke("download_geoip_database");
|
||||
assert.equal(await app.invoke("is_geoip_database_available"), true);
|
||||
assert.equal(await app.invoke("check_missing_geoip_database"), false);
|
||||
|
||||
// The new-profile form (which needs a downloaded browser and its release
|
||||
// types, so it renders here and not in the UI suite): session restore is
|
||||
// on by default and the checkbox is a live control.
|
||||
await app.clickSelector('[aria-label="Profiles"]');
|
||||
await app.clickText("New");
|
||||
const restoreChecked = () =>
|
||||
app.execute(
|
||||
`return document.querySelector("#restore-session")?.getAttribute("aria-checked") ?? null;`,
|
||||
);
|
||||
await app.waitFor(async () => (await restoreChecked()) !== null, {
|
||||
description: "the session-restore checkbox in the new-profile form",
|
||||
});
|
||||
assert.equal(
|
||||
await restoreChecked(),
|
||||
"true",
|
||||
"a new profile must default to continuing its last session",
|
||||
);
|
||||
await app.clickSelector("#restore-session");
|
||||
await app.waitFor(async () => (await restoreChecked()) === "false", {
|
||||
description: "the session-restore checkbox to switch off",
|
||||
});
|
||||
await app.pressShortcut({ key: "Escape" });
|
||||
await app.waitFor(
|
||||
() =>
|
||||
app.execute(
|
||||
`return !document.querySelector("[role='dialog'] #restore-session");`,
|
||||
),
|
||||
{ description: "the new-profile dialog to close" },
|
||||
);
|
||||
|
||||
await app.invoke("update_wayfern_config", {
|
||||
profileId: profile.id,
|
||||
config: profile.wayfern_config,
|
||||
@@ -274,7 +365,8 @@ test("real Wayfern fingerprinting, terms, API automation, CDP, cookies, and proc
|
||||
// The identity is internal state that neither call above sends back.
|
||||
// Losing it would silently re-mint the device on the next launch and throw
|
||||
// the user's edits away with it, so both paths must carry it forward
|
||||
// unchanged.
|
||||
// unchanged. The exit re-match moves only the location: the profile comes
|
||||
// out of it still identity-only, with the exit's timezone stored.
|
||||
if (identityCapable) {
|
||||
const stored = (await app.invoke("list_browser_profiles")).find(
|
||||
(p) => p.id === profile.id,
|
||||
@@ -289,7 +381,90 @@ test("real Wayfern fingerprinting, terms, API automation, CDP, cookies, and proc
|
||||
undefined,
|
||||
"neither call may leave a device payload behind",
|
||||
);
|
||||
assert.equal(
|
||||
typeof JSON.parse(stored.wayfern_config.location).timezone,
|
||||
"string",
|
||||
"an exit re-match stores the exit's timezone in the location",
|
||||
);
|
||||
}
|
||||
// The session-restore switch is profile configuration and round-trips
|
||||
// like the rest of it; `undefined` (the default) reads as on.
|
||||
await app.invoke("update_wayfern_config", {
|
||||
profileId: profile.id,
|
||||
config: {
|
||||
...(await app.invoke("list_browser_profiles")).find(
|
||||
(p) => p.id === profile.id,
|
||||
).wayfern_config,
|
||||
restore_session: false,
|
||||
},
|
||||
});
|
||||
assert.equal(
|
||||
(await app.invoke("list_browser_profiles")).find(
|
||||
(p) => p.id === profile.id,
|
||||
).wayfern_config.restore_session,
|
||||
false,
|
||||
"restore_session must persist through update_wayfern_config",
|
||||
);
|
||||
|
||||
// The persona the browser will offer in its fill menu: derived from the
|
||||
// profile's own seed, so it is stable for this profile, unique to it, and
|
||||
// never empty.
|
||||
const persona = await app.invoke("get_profile_persona", {
|
||||
profileId: profile.id,
|
||||
});
|
||||
assert.ok(
|
||||
persona.length >= 8,
|
||||
"a persona carries the fields to fill a form",
|
||||
);
|
||||
assert.deepEqual(
|
||||
await app.invoke("get_profile_persona", { profileId: profile.id }),
|
||||
persona,
|
||||
"the same profile presents the same person every time",
|
||||
);
|
||||
for (const entry of persona) {
|
||||
assert.ok(entry.id && entry.label && entry.value.trim());
|
||||
}
|
||||
const email = persona.find((entry) => entry.id === "email");
|
||||
assert.match(email.value, /@/);
|
||||
assert.match(
|
||||
await app.invokeError("get_profile_persona", {
|
||||
profileId: "00000000-0000-0000-0000-000000000000",
|
||||
}),
|
||||
/PROFILE_NOT_FOUND/,
|
||||
);
|
||||
// An edit replaces one value and leaves the rest derived.
|
||||
await app.invoke("update_wayfern_config", {
|
||||
profileId: profile.id,
|
||||
config: {
|
||||
...(await app.invoke("list_browser_profiles")).find(
|
||||
(p) => p.id === profile.id,
|
||||
).wayfern_config,
|
||||
persona: JSON.stringify([
|
||||
{ id: "email", label: "Email", value: "someone@example.com" },
|
||||
]),
|
||||
},
|
||||
});
|
||||
const edited = await app.invoke("get_profile_persona", {
|
||||
profileId: profile.id,
|
||||
});
|
||||
assert.equal(
|
||||
edited.find((entry) => entry.id === "email").value,
|
||||
"someone@example.com",
|
||||
);
|
||||
assert.equal(
|
||||
edited.find((entry) => entry.id === "full_name").value,
|
||||
persona.find((entry) => entry.id === "full_name").value,
|
||||
"an edit to one field must not redraw the others",
|
||||
);
|
||||
// What "reset to generated" shows: the person before any edit.
|
||||
assert.deepEqual(
|
||||
await app.invoke("get_profile_persona", {
|
||||
profileId: profile.id,
|
||||
derivedOnly: true,
|
||||
}),
|
||||
persona,
|
||||
);
|
||||
|
||||
// Pre-launch gate: local-only checks that must answer without starting a
|
||||
// proxy, an Xray worker or the browser.
|
||||
const checks = await app.invoke("get_profile_pre_launch_checks", {
|
||||
@@ -304,6 +479,24 @@ test("real Wayfern fingerprinting, terms, API automation, CDP, cookies, and proc
|
||||
assert.equal(typeof checks.consistency, "object");
|
||||
assert.equal(typeof checks.exit_probe_pending, "boolean");
|
||||
assert.equal(typeof checks.exit_measurement_unreliable, "boolean");
|
||||
// The third consistency state: what no probe can ever verify for this
|
||||
// profile. Reported so a launch that compared nothing is never rendered as
|
||||
// a launch that compared everything and agreed.
|
||||
assert.ok(
|
||||
Array.isArray(checks.exit_unverified),
|
||||
"the pre-launch report must say what it cannot verify",
|
||||
);
|
||||
assert.ok(
|
||||
Array.isArray(checks.consistency.unverified),
|
||||
"a consistency result must carry the dimensions nothing compared",
|
||||
);
|
||||
// "Donut will check it while starting" is only sayable while some
|
||||
// dimension is still checkable. Both dimensions unverifiable means the
|
||||
// probe would compare nothing, so it is not pending work.
|
||||
assert.ok(
|
||||
!checks.exit_probe_pending || checks.exit_unverified.length < 2,
|
||||
"a probe that can compare nothing must not be reported as pending",
|
||||
);
|
||||
// This profile has no VPN extension, so nothing may block its launch.
|
||||
assert.equal(
|
||||
checks.vpn_extensions.length,
|
||||
@@ -495,6 +688,17 @@ test("real Wayfern fingerprinting, terms, API automation, CDP, cookies, and proc
|
||||
command,
|
||||
new RegExp(app.dataRoot.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")),
|
||||
);
|
||||
// An automation run starts clean: it never reopens a person's session.
|
||||
// The crash-restore bubble stays hidden, and the retired switch that
|
||||
// Chromium no longer reads is gone from the command line.
|
||||
assert.doesNotMatch(command, /--restore-last-session/);
|
||||
assert.match(command, /--hide-crash-restore-bubble/);
|
||||
assert.doesNotMatch(command, /--disable-session-crashed-bubble/);
|
||||
assert.match(
|
||||
command,
|
||||
/--enable-logging=stderr/,
|
||||
"the browser's own verdicts reach the app through stderr",
|
||||
);
|
||||
}
|
||||
|
||||
const opened = await request(`${base}/v1/profiles/${profile.id}/open-url`, {
|
||||
@@ -533,7 +737,12 @@ test("real Wayfern fingerprinting, terms, API automation, CDP, cookies, and proc
|
||||
app,
|
||||
prepared.version,
|
||||
"Wayfern Batch Automation",
|
||||
sample,
|
||||
// The fingerprint STRING, not the envelope `generate_sample_fingerprint`
|
||||
// returns it in. `WayfernConfig.fingerprint` is an `Option<String>`
|
||||
// (wayfern_manager.rs), so passing `sample` made the whole command fail
|
||||
// to deserialise with "invalid type: map, expected a string", before any
|
||||
// of the automation this test exists to check could run.
|
||||
sample.fingerprint,
|
||||
);
|
||||
const batchRun = await request(`${base}/v1/profiles/batch/run`, {
|
||||
method: "POST",
|
||||
@@ -545,29 +754,201 @@ test("real Wayfern fingerprinting, terms, API automation, CDP, cookies, and proc
|
||||
},
|
||||
});
|
||||
assert.equal(batchRun.response.status, 200);
|
||||
assert.equal(
|
||||
batchRun.value.results[0].ok,
|
||||
true,
|
||||
batchRun.value.results[0].error,
|
||||
);
|
||||
const batchCdp = await CdpClient.connect(
|
||||
batchRun.value.results[0].remote_debugging_port,
|
||||
);
|
||||
assert.equal(
|
||||
await batchCdp.waitFor("window.__fixtureReady === true"),
|
||||
true,
|
||||
);
|
||||
batchCdp.close();
|
||||
// A profile carrying a whole stored device is migrated into an identity
|
||||
// plus overrides. Some override values are currently rejected by the
|
||||
// browser at launch, and the launcher reports that with the property
|
||||
// named, so this asserts the reported failure rather than pretending the
|
||||
// launch worked. If the launch succeeds instead, the else branch takes
|
||||
// over and the batch is asserted in full.
|
||||
const batchBlockedByBrowser =
|
||||
!batchRun.value.results[0].ok &&
|
||||
/was not applied: \w+/.test(batchRun.value.results[0].error ?? "");
|
||||
if (batchBlockedByBrowser) {
|
||||
console.log(
|
||||
`[donut-e2e] Batch profile could not launch: ${batchRun.value.results[0].error}`,
|
||||
);
|
||||
assert.match(
|
||||
batchRun.value.results[0].error,
|
||||
/WAYFERN_IDENTITY_REFUSED|WAYFERN_FINGERPRINT_APPLY_FAILED/,
|
||||
"a refused device must reach the caller as a coded error, never as a silent success",
|
||||
);
|
||||
} else {
|
||||
assert.equal(
|
||||
batchRun.value.results[0].ok,
|
||||
true,
|
||||
batchRun.value.results[0].error,
|
||||
);
|
||||
const batchCdp = await CdpClient.connect(
|
||||
batchRun.value.results[0].remote_debugging_port,
|
||||
);
|
||||
assert.equal(
|
||||
await batchCdp.waitFor("window.__fixtureReady === true"),
|
||||
true,
|
||||
);
|
||||
batchCdp.close();
|
||||
}
|
||||
const batchStop = await request(`${base}/v1/profiles/batch/stop`, {
|
||||
method: "POST",
|
||||
token: saved.api_token,
|
||||
body: { profile_ids: [batchProfile.id] },
|
||||
});
|
||||
assert.equal(batchStop.response.status, 200);
|
||||
// Stopping is idempotent: a profile that never launched is already
|
||||
// stopped, so the batch endpoint reports success either way.
|
||||
assert.equal(
|
||||
batchStop.value.results[0].ok,
|
||||
true,
|
||||
batchStop.value.results[0].error,
|
||||
`batch stop reported ${JSON.stringify(batchStop.value.results[0])}`,
|
||||
);
|
||||
|
||||
// The recipe recorder's refusals, which are the whole contract a caller can
|
||||
// rely on without a paid browser: what it will not start on, and that an
|
||||
// idle recorder answers rather than throwing. The capture itself is a paid
|
||||
// browser feature and is tested where that feature lives.
|
||||
assert.deepEqual(await app.invoke("get_recipe_recording"), {
|
||||
profile_id: null,
|
||||
steps: [],
|
||||
recording: false,
|
||||
});
|
||||
assert.deepEqual(await app.invoke("stop_recipe_recording"), {
|
||||
profile_id: null,
|
||||
steps: [],
|
||||
recording: false,
|
||||
});
|
||||
assert.match(
|
||||
await app.invokeError("start_recipe_recording", {
|
||||
profileId: "00000000-0000-0000-0000-000000000000",
|
||||
}),
|
||||
/PROFILE_NOT_FOUND/,
|
||||
);
|
||||
assert.match(
|
||||
await app.invokeError("start_recipe_recording", {
|
||||
profileId: profile.id,
|
||||
}),
|
||||
/PROFILE_NOT_RUNNING/,
|
||||
"a recording needs a live browser to attach to",
|
||||
);
|
||||
|
||||
// Export and import: a profile is moved to another machine as one archive
|
||||
// and comes back as a NEW profile, owing nothing to the machine that wrote
|
||||
// it. Exercised here because this is the suite with a real profile
|
||||
// directory to carry.
|
||||
const exportPath = path.join(app.dataRoot, "exported.donutprofile");
|
||||
const exported = await app.invoke("export_profile", {
|
||||
profileId: profile.id,
|
||||
destination: exportPath,
|
||||
includeData: true,
|
||||
});
|
||||
assert.equal(exported.profile_name, profile.name);
|
||||
assert.equal(exported.browser, "wayfern");
|
||||
assert.ok((await stat(exportPath)).size > 0);
|
||||
const archivePreview = await app.invoke("preview_profile_archive", {
|
||||
path: exportPath,
|
||||
});
|
||||
assert.equal(archivePreview.manifest.profile_name, profile.name);
|
||||
assert.deepEqual(archivePreview.tags, []);
|
||||
const importedProfile = await app.invoke("import_profile_archive", {
|
||||
path: exportPath,
|
||||
});
|
||||
assert.notEqual(importedProfile.id, profile.id);
|
||||
assert.equal(importedProfile.version, profile.version);
|
||||
assert.equal(
|
||||
importedProfile.process_id,
|
||||
null,
|
||||
"an imported profile is not running on this machine",
|
||||
);
|
||||
assert.equal(
|
||||
importedProfile.proxy_id ?? null,
|
||||
null,
|
||||
"a proxy id belongs to the machine that assigned it",
|
||||
);
|
||||
assert.equal(
|
||||
importedProfile.wayfern_config.identity_id,
|
||||
profile.wayfern_config.identity_id,
|
||||
"the device travels: the same identity rebuilds the same browser",
|
||||
);
|
||||
// Twice from one archive gives two profiles, under distinct names.
|
||||
const importedAgain = await app.invoke("import_profile_archive", {
|
||||
path: exportPath,
|
||||
});
|
||||
assert.notEqual(importedAgain.id, importedProfile.id);
|
||||
assert.notEqual(importedAgain.name, importedProfile.name);
|
||||
assert.match(
|
||||
await app.invokeError("preview_profile_archive", {
|
||||
path: path.join(app.dataRoot, "not-an-archive"),
|
||||
}),
|
||||
/PROFILE_IMPORT_FAILED/,
|
||||
);
|
||||
for (const created of [importedProfile, importedAgain]) {
|
||||
await app.invoke("delete_profile", {
|
||||
profileId: created.id,
|
||||
permanent: true,
|
||||
});
|
||||
}
|
||||
|
||||
// A temporary profile: created over REST for one run, gone once its
|
||||
// browser stops. Nothing else in the app removes it, so this is the
|
||||
// whole contract an automation client depends on.
|
||||
const temporary = await request(`${base}/v1/profiles`, {
|
||||
method: "POST",
|
||||
token: saved.api_token,
|
||||
body: {
|
||||
name: "Temporary Run",
|
||||
browser: "wayfern",
|
||||
version: prepared.version,
|
||||
temporary: true,
|
||||
},
|
||||
});
|
||||
assert.equal(
|
||||
temporary.response.status,
|
||||
200,
|
||||
JSON.stringify(temporary.value),
|
||||
);
|
||||
assert.equal(temporary.value.profile.temporary, true);
|
||||
assert.equal(
|
||||
temporary.value.profile.ephemeral,
|
||||
true,
|
||||
"a temporary profile keeps its browsing data in memory only",
|
||||
);
|
||||
const temporaryId = temporary.value.profile.id;
|
||||
const temporaryRun = await request(
|
||||
`${base}/v1/profiles/${temporaryId}/run`,
|
||||
{
|
||||
method: "POST",
|
||||
token: saved.api_token,
|
||||
body: { url: `${fixtureUrl}/temporary`, headless: true },
|
||||
},
|
||||
);
|
||||
assert.equal(
|
||||
temporaryRun.response.status,
|
||||
200,
|
||||
JSON.stringify(temporaryRun.value),
|
||||
);
|
||||
const temporaryPid = (await app.invoke("list_browser_profiles")).find(
|
||||
(item) => item.id === temporaryId,
|
||||
)?.process_id;
|
||||
assert.ok(
|
||||
temporaryPid,
|
||||
"the temporary profile must report the browser it started",
|
||||
);
|
||||
await request(`${base}/v1/profiles/${temporaryId}/kill`, {
|
||||
method: "POST",
|
||||
token: saved.api_token,
|
||||
});
|
||||
await waitForProcessExit(app, temporaryPid);
|
||||
await app.waitFor(
|
||||
async () =>
|
||||
!(await app.invoke("list_browser_profiles")).some(
|
||||
(item) => item.id === temporaryId,
|
||||
),
|
||||
{ description: "the temporary profile to delete itself" },
|
||||
);
|
||||
assert.deepEqual(
|
||||
(await app.invoke("list_trashed_profiles")).filter(
|
||||
(entry) => entry.id === temporaryId,
|
||||
),
|
||||
[],
|
||||
"a disposable profile must not land in the trash",
|
||||
);
|
||||
|
||||
await app.invoke("stop_api_server");
|
||||
@@ -666,12 +1047,9 @@ async function launchWithWorker(app, version, name) {
|
||||
// order (app closed first, so nothing is left to reap anything).
|
||||
test("a proxy worker dies with its browser, with and without the app running", async () => {
|
||||
assert.ok(process.env.WAYFERN_TEST_TOKEN, "WAYFERN_TEST_TOKEN is required");
|
||||
const localWayfernPath = defaultWayfernPath(
|
||||
const localWayfernVersion = cachedFixtureVersion(
|
||||
process.env.DONUT_E2E_PROJECT_ROOT,
|
||||
);
|
||||
const localWayfernVersion = existsSync(localWayfernPath)
|
||||
? inspectWayfern(localWayfernPath).version
|
||||
: null;
|
||||
const app = appFromEnvironment("browser-worker-lifecycle", {
|
||||
seedVersionCache: localWayfernVersion ?? false,
|
||||
// Let the app run the real acceptance flow below; the pre-seeded marker is
|
||||
@@ -766,12 +1144,9 @@ test("a proxy worker dies with its browser, with and without the app running", a
|
||||
// second profile broke the extension in every browser already running.
|
||||
test("an assigned extension group reaches Wayfern and each profile stages its own copy", async () => {
|
||||
assert.ok(process.env.WAYFERN_TEST_TOKEN, "WAYFERN_TEST_TOKEN is required");
|
||||
const localWayfernPath = defaultWayfernPath(
|
||||
const localWayfernVersion = cachedFixtureVersion(
|
||||
process.env.DONUT_E2E_PROJECT_ROOT,
|
||||
);
|
||||
const localWayfernVersion = existsSync(localWayfernPath)
|
||||
? inspectWayfern(localWayfernPath).version
|
||||
: null;
|
||||
const app = appFromEnvironment("browser-extensions", {
|
||||
seedVersionCache: localWayfernVersion ?? false,
|
||||
wayfernTermsAccepted: false,
|
||||
@@ -934,3 +1309,215 @@ test("an assigned extension group reaches Wayfern and each profile stages its ow
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
|
||||
/// The browser's remote-debugging port, read off its own command line: an
|
||||
/// interactive launch does not hand the port back the way an API run does.
|
||||
function debuggingPortOf(pid) {
|
||||
const command = execFileSync(
|
||||
"ps",
|
||||
["-ww", "-o", "command=", "-p", String(pid)],
|
||||
{ encoding: "utf8" },
|
||||
);
|
||||
const match = command.match(/--remote-debugging-port=(\d+)/);
|
||||
assert.ok(match, `no debugging port on the command line: ${command}`);
|
||||
return { port: Number(match[1]), command };
|
||||
}
|
||||
|
||||
async function targetUrls(port) {
|
||||
const targets = await fetch(`http://127.0.0.1:${port}/json`).then((r) =>
|
||||
r.json(),
|
||||
);
|
||||
return targets
|
||||
.filter((target) => target.type === "page")
|
||||
.map((target) => target.url);
|
||||
}
|
||||
|
||||
test("an interactive launch continues the last session once the identity travels at launch", async () => {
|
||||
assert.ok(process.env.WAYFERN_TEST_TOKEN, "WAYFERN_TEST_TOKEN is required");
|
||||
const localWayfernVersion = cachedFixtureVersion(
|
||||
process.env.DONUT_E2E_PROJECT_ROOT,
|
||||
);
|
||||
const app = appFromEnvironment("browser-session", {
|
||||
seedVersionCache: localWayfernVersion ?? false,
|
||||
wayfernTermsAccepted: false,
|
||||
});
|
||||
let browserPid;
|
||||
try {
|
||||
const prepared = await prepareWayfern(
|
||||
app,
|
||||
process.env.DONUT_E2E_PROJECT_ROOT,
|
||||
);
|
||||
if (!app.session) await app.start();
|
||||
// The browser itself refuses to start until its terms marker exists, and
|
||||
// only its own acceptance run writes one it recognises.
|
||||
await app.invoke("accept_wayfern_terms");
|
||||
const major = Number.parseInt(prepared.version.split(".")[0], 10);
|
||||
if (major < 152) {
|
||||
// Older builds take no launch identity, so Donut starts them on a fresh
|
||||
// tab and there is nothing to continue.
|
||||
console.log(
|
||||
`[donut-e2e] Wayfern ${prepared.version} takes no launch identity; session restore is off by design, skipping the restore assertions`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const profile = await createRealProfile(
|
||||
app,
|
||||
prepared.version,
|
||||
"Session Restore",
|
||||
);
|
||||
// A launch identity needs the exit's timezone; the geoip match writes it.
|
||||
await app.invoke("download_geoip_database");
|
||||
await app.invoke("match_profile_fingerprint_to_exit", {
|
||||
profileId: profile.id,
|
||||
exitIp: "8.8.8.8",
|
||||
});
|
||||
const stored = (await app.invoke("list_browser_profiles")).find(
|
||||
(p) => p.id === profile.id,
|
||||
);
|
||||
const location = JSON.parse(stored.wayfern_config.location);
|
||||
assert.equal(typeof location.timezone, "string");
|
||||
const userDataDir = path.join(
|
||||
app.dataRoot,
|
||||
"data",
|
||||
"profiles",
|
||||
profile.id,
|
||||
"profile",
|
||||
);
|
||||
|
||||
const launch = async (url) => {
|
||||
const current = (await app.invoke("list_browser_profiles")).find(
|
||||
(p) => p.id === profile.id,
|
||||
);
|
||||
const launched = await app.invoke("launch_browser_profile", {
|
||||
profile: current,
|
||||
url,
|
||||
});
|
||||
assert.ok(launched.process_id);
|
||||
browserPid = launched.process_id;
|
||||
return launched;
|
||||
};
|
||||
const stop = async () => {
|
||||
const current = (await app.invoke("list_browser_profiles")).find(
|
||||
(p) => p.id === profile.id,
|
||||
);
|
||||
await app.invoke("kill_browser_profile", { profile: current });
|
||||
await waitForProcessExit(app, browserPid);
|
||||
};
|
||||
const waitForTargets = async (port, expected) => {
|
||||
let seen = [];
|
||||
await app
|
||||
.waitFor(
|
||||
async () => {
|
||||
seen = await targetUrls(port).catch(() => []);
|
||||
return expected.every((needle) =>
|
||||
seen.some((url) => url.includes(needle)),
|
||||
);
|
||||
},
|
||||
{ timeoutMs: 30_000, description: `targets ${expected.join(", ")}` },
|
||||
)
|
||||
.catch(() => {
|
||||
// The URLs it did see are the whole diagnosis: a restore that
|
||||
// dropped one tab looks identical to one that never ran.
|
||||
assert.fail(
|
||||
`waiting for ${expected.join(", ")} but the browser had ${
|
||||
seen.length ? seen.join(", ") : "no page targets"
|
||||
}`,
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
// First session: two tabs.
|
||||
const first = await launch(`${fixtureUrl}/session-a`);
|
||||
const { port: firstPort, command } = debuggingPortOf(first.process_id);
|
||||
assert.match(command, /--restore-last-session/);
|
||||
assert.match(command, /--wayfern-identity-file=/);
|
||||
const identityFile = JSON.parse(
|
||||
await readFile(path.join(userDataDir, "wayfern-identity.json"), "utf8"),
|
||||
);
|
||||
assert.equal(identityFile.identityId, stored.wayfern_config.identity_id);
|
||||
assert.equal(identityFile.timezone, location.timezone);
|
||||
// No claimed OS means the host, which is what an omitted operatingSystem
|
||||
// means over CDP as well; the document has to spell it out.
|
||||
assert.equal(
|
||||
identityFile.operatingSystem,
|
||||
stored.wayfern_config.os ?? currentHostOs(),
|
||||
);
|
||||
await waitForTargets(firstPort, ["/session-a"]);
|
||||
await app.invoke("open_url_with_profile", {
|
||||
profileId: profile.id,
|
||||
url: `${fixtureUrl}/session-b`,
|
||||
});
|
||||
await waitForTargets(firstPort, ["/session-a", "/session-b"]);
|
||||
await stop();
|
||||
const preferences = JSON.parse(
|
||||
await readFile(path.join(userDataDir, "Default", "Preferences"), "utf8"),
|
||||
);
|
||||
assert.equal(
|
||||
preferences.profile?.exit_type,
|
||||
"Normal",
|
||||
"a stop must run the browser's own shutdown so the session is written",
|
||||
);
|
||||
|
||||
// Second session: both tabs come back, and the launch URL gets its own
|
||||
// tab instead of replacing a restored one.
|
||||
const second = await launch(`${fixtureUrl}/session-c`);
|
||||
const { port: secondPort } = debuggingPortOf(second.process_id);
|
||||
await waitForTargets(secondPort, [
|
||||
"/session-a",
|
||||
"/session-b",
|
||||
"/session-c",
|
||||
]);
|
||||
|
||||
// A browser that died hard still comes back, with no bubble to answer.
|
||||
// Chromium commits a tab change to the session file on a short delay, so a
|
||||
// kill in the same second loses the newest tab through no fault of the
|
||||
// launcher; wait for the write before pulling the plug.
|
||||
await new Promise((resolve) => setTimeout(resolve, 6_000));
|
||||
process.kill(second.process_id, "SIGKILL");
|
||||
await waitForProcessExit(app, second.process_id);
|
||||
await app.waitFor(
|
||||
async () =>
|
||||
!(await app.invoke("check_browser_status", {
|
||||
profile: (
|
||||
await app.invoke("list_browser_profiles")
|
||||
).find((p) => p.id === profile.id),
|
||||
})),
|
||||
{ description: "the app to notice the killed browser" },
|
||||
);
|
||||
const third = await launch(null);
|
||||
const { port: thirdPort } = debuggingPortOf(third.process_id);
|
||||
await waitForTargets(thirdPort, ["/session-a", "/session-b", "/session-c"]);
|
||||
await stop();
|
||||
|
||||
// Switched off, the profile starts on a fresh tab.
|
||||
await app.invoke("update_wayfern_config", {
|
||||
profileId: profile.id,
|
||||
config: { ...stored.wayfern_config, restore_session: false },
|
||||
});
|
||||
const fourth = await launch(`${fixtureUrl}/session-d`);
|
||||
const { port: fourthPort, command: fourthCommand } = debuggingPortOf(
|
||||
fourth.process_id,
|
||||
);
|
||||
assert.doesNotMatch(fourthCommand, /--restore-last-session/);
|
||||
await waitForTargets(fourthPort, ["/session-d"]);
|
||||
assert.ok(
|
||||
!(await targetUrls(fourthPort)).some((url) => url.includes("/session-a")),
|
||||
"a profile with restore switched off must not reopen the old session",
|
||||
);
|
||||
await stop();
|
||||
await app.invoke("delete_profile", { profileId: profile.id });
|
||||
} catch (error) {
|
||||
await app.capture("failure");
|
||||
throw error;
|
||||
} finally {
|
||||
if (app.session && browserPid && processExists(browserPid)) {
|
||||
const profile = (
|
||||
await app.invoke("list_browser_profiles").catch(() => [])
|
||||
).find((item) => item.process_id === browserPid);
|
||||
if (profile)
|
||||
await app.invoke("kill_browser_profile", { profile }).catch(() => {});
|
||||
}
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -24,8 +24,15 @@ function commandHasExecutableEvidence(source, command) {
|
||||
.split("::")
|
||||
.at(-1)
|
||||
.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
// Every helper that actually CALLS the command counts. This list is the gate's
|
||||
// blind spot: a suite can strengthen its assertions by routing through a new
|
||||
// helper and silently lose the evidence, which is exactly what happened when
|
||||
// `assertContract` replaced eight `assert.ok(await invokeContract(...))` calls
|
||||
//, the assertions got stronger and the gate went red. `assertCommandErrorCode`
|
||||
// joined the list when the local-MCP tests moved to asserting refusal codes.
|
||||
return new RegExp(
|
||||
`(?:invoke|invokeError)\\(\\s*["']${name}["']|invokeContract\\(\\s*\\w+\\s*,\\s*["']${name}["']`,
|
||||
`(?:invoke|invokeError)\\(\\s*["']${name}["']` +
|
||||
`|(?:invokeContract|assertContract|assertCommandErrorCode)\\(\\s*\\w+\\s*,\\s*["']${name}["']`,
|
||||
).test(source);
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,8 @@ import { DatabaseSync } from "node:sqlite";
|
||||
import test from "node:test";
|
||||
import { withApp } from "../lib/app.mjs";
|
||||
import {
|
||||
CRX_EXTENSION_NAME,
|
||||
CRX_EXTENSION_VERSION,
|
||||
extensionIconPngBase64,
|
||||
extensionZipBase64,
|
||||
wireGuardFixture,
|
||||
@@ -103,6 +105,11 @@ test("profile, group, proxy, tag, metadata, clone, and bulk-delete lifecycle", a
|
||||
});
|
||||
assert.equal(parsedImport.imported_count, 1);
|
||||
|
||||
assert.deepEqual(
|
||||
await app.invoke("get_proxy_check_history", { proxyId: proxy.id }),
|
||||
[],
|
||||
"a proxy nobody has checked has no trail",
|
||||
);
|
||||
const validityError = await app.invokeError("check_proxy_validity", {
|
||||
proxyId: proxy.id,
|
||||
proxySettings: null,
|
||||
@@ -113,6 +120,49 @@ test("profile, group, proxy, tag, metadata, clone, and bulk-delete lifecycle", a
|
||||
});
|
||||
assert.ok(cachedValidity === null || cachedValidity.is_valid === false);
|
||||
|
||||
// A check that failed is still a check, and it is recorded as one. The
|
||||
// proxy above was edited to SOCKS5 on a closed port, so the UDP probe
|
||||
// could not reach it: the honest verdict is "unknown", never "no".
|
||||
const trail = await app.invoke("get_proxy_check_history", {
|
||||
proxyId: proxy.id,
|
||||
});
|
||||
assert.equal(trail.length, 1);
|
||||
assert.equal(trail[0].ok, false);
|
||||
assert.equal(trail[0].ip, null);
|
||||
assert.equal(trail[0].udp, "unknown");
|
||||
assert.ok(
|
||||
typeof trail[0].latency_ms === "number" && trail[0].latency_ms >= 0,
|
||||
);
|
||||
assert.ok(trail[0].timestamp > 0);
|
||||
|
||||
// Deleting the proxy takes the trail with it; it names exit addresses.
|
||||
const doomed = await app.invoke("create_stored_proxy", {
|
||||
name: "Trail Owner",
|
||||
proxySettings: {
|
||||
proxy_type: "http",
|
||||
host: "127.0.0.1",
|
||||
port: 9,
|
||||
username: null,
|
||||
password: null,
|
||||
},
|
||||
});
|
||||
await app.invokeError("check_proxy_validity", {
|
||||
proxyId: doomed.id,
|
||||
proxySettings: null,
|
||||
});
|
||||
const doomedTrail = await app.invoke("get_proxy_check_history", {
|
||||
proxyId: doomed.id,
|
||||
});
|
||||
assert.equal(doomedTrail.length, 1);
|
||||
// An HTTP proxy cannot carry a datagram at all, which is answered from
|
||||
// the protocol without dialling anything.
|
||||
assert.equal(doomedTrail[0].udp, "no");
|
||||
await app.invoke("delete_stored_proxy", { proxyId: doomed.id });
|
||||
assert.deepEqual(
|
||||
await app.invoke("get_proxy_check_history", { proxyId: doomed.id }),
|
||||
[],
|
||||
);
|
||||
|
||||
// Donut accepts one VLESS shape (REALITY + XTLS Vision over TCP). The form
|
||||
// uses this to tell the user WHICH part of their setup is unsupported
|
||||
// instead of implying they mistyped, so the reason must survive the IPC hop.
|
||||
@@ -661,6 +711,103 @@ test("extensions, extension groups, VPN storage, DNS rules, and event-backed ass
|
||||
"importing a folder must never move or consume the user's copy of it",
|
||||
);
|
||||
|
||||
// Importing from a link. The fixture server answers with a real CRX3
|
||||
// container, so this proves the importer unwraps the signed container to
|
||||
// the ZIP the store keeps rather than filing the container itself.
|
||||
const fixtureBase = process.env.DONUT_E2E_FIXTURE_URL;
|
||||
assert.ok(fixtureBase, "the fixture server URL has to reach the suite");
|
||||
const fetched = await app.invoke("fetch_extension_from_url", {
|
||||
url: `${fixtureBase}/extension.crx`,
|
||||
});
|
||||
assert.equal(fetched.name, CRX_EXTENSION_NAME);
|
||||
assert.equal(fetched.version, CRX_EXTENSION_VERSION);
|
||||
assert.equal(fetched.from_web_store, false);
|
||||
assert.equal(
|
||||
fetched.file_name,
|
||||
"extension.zip",
|
||||
"the stored payload is the ZIP, so it must not still be called a .crx",
|
||||
);
|
||||
assert.deepEqual(
|
||||
fetched.file_data.slice(0, 4),
|
||||
[0x50, 0x4b, 0x03, 0x04],
|
||||
"the CRX3 header has to be stripped, not stored",
|
||||
);
|
||||
|
||||
const fromLink = await app.invoke("add_extension", {
|
||||
name: "Overridden By The Manifest",
|
||||
fileName: fetched.file_name,
|
||||
fileData: fetched.file_data,
|
||||
});
|
||||
assert.equal(fromLink.name, CRX_EXTENSION_NAME);
|
||||
assert.equal(fromLink.version, CRX_EXTENSION_VERSION);
|
||||
assert.equal(fromLink.source_kind, "archive");
|
||||
assert.equal(fromLink.file_type, "zip");
|
||||
|
||||
// Assignable like any other extension: the link is only how it arrived.
|
||||
const linkGroup = await app.invoke("create_extension_group", {
|
||||
name: "Downloaded Extensions",
|
||||
});
|
||||
assert.deepEqual(
|
||||
(
|
||||
await app.invoke("add_extension_to_group", {
|
||||
groupId: linkGroup.id,
|
||||
extensionId: fromLink.id,
|
||||
})
|
||||
).extension_ids,
|
||||
[fromLink.id],
|
||||
);
|
||||
await app.invoke("assign_extension_group_to_profile", {
|
||||
profileId: profile.id,
|
||||
extensionGroupId: linkGroup.id,
|
||||
});
|
||||
assert.equal(
|
||||
(
|
||||
await app.invoke("get_extension_group_for_profile", {
|
||||
profileId: profile.id,
|
||||
})
|
||||
).id,
|
||||
linkGroup.id,
|
||||
);
|
||||
|
||||
// A body that is not an extension is refused with the code, and nothing
|
||||
// is stored for it.
|
||||
assert.match(
|
||||
await app.invokeError("fetch_extension_from_url", {
|
||||
url: `${fixtureBase}/not-an-extension.zip`,
|
||||
}),
|
||||
/EXTENSION_NOT_AN_EXTENSION/,
|
||||
);
|
||||
for (const rejected of [
|
||||
"not a link at all",
|
||||
"https://example.invalid/downloads",
|
||||
"https://example.invalid/installer.exe",
|
||||
// 32 characters, but an extension id only uses a-p.
|
||||
"abcdefghijklmnopabcdefghijklmnoz",
|
||||
// Plain HTTP off loopback never crosses the wire, whatever it points at.
|
||||
"http://files.example.invalid/pack.crx",
|
||||
]) {
|
||||
assert.match(
|
||||
await app.invokeError("fetch_extension_from_url", { url: rejected }),
|
||||
/EXTENSION_URL_INVALID/,
|
||||
rejected,
|
||||
);
|
||||
}
|
||||
assert.match(
|
||||
await app.invokeError("fetch_extension_from_url", {
|
||||
url: `${fixtureBase}/absent-extension.crx`,
|
||||
}),
|
||||
/EXTENSION_NOT_AN_EXTENSION|EXTENSION_DOWNLOAD_FAILED/,
|
||||
);
|
||||
assert.equal((await app.invoke("list_extensions")).length, 1);
|
||||
|
||||
await app.invoke("assign_extension_group_to_profile", {
|
||||
profileId: profile.id,
|
||||
extensionGroupId: null,
|
||||
});
|
||||
await app.invoke("delete_extension_group", { groupId: linkGroup.id });
|
||||
await app.invoke("delete_extension", { extensionId: fromLink.id });
|
||||
assert.deepEqual(await app.invoke("list_extensions"), []);
|
||||
|
||||
const vpn = await app.invoke("create_vpn_config_manual", {
|
||||
name: "E2E WireGuard",
|
||||
vpnType: "WireGuard",
|
||||
@@ -926,3 +1073,517 @@ test("cookie import/copy/export, profile encryption, and traffic-stat read/clear
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("deleted profiles land in the trash and come back intact on restore", async () => {
|
||||
await withApp("entities-trash", async (app) => {
|
||||
const initialSettings = await app.invoke("get_app_settings");
|
||||
assert.equal(initialSettings.trash_retention_days, 30);
|
||||
const savedSettings = await app.invoke("save_app_settings", {
|
||||
settings: { ...initialSettings, trash_retention_days: 7 },
|
||||
});
|
||||
assert.equal(savedSettings.trash_retention_days, 7);
|
||||
assert.equal(
|
||||
(await app.invoke("get_app_settings")).trash_retention_days,
|
||||
7,
|
||||
);
|
||||
// Out-of-range values are clamped, never rejected.
|
||||
const clamped = await app.invoke("save_app_settings", {
|
||||
settings: { ...savedSettings, trash_retention_days: 9000 },
|
||||
});
|
||||
assert.equal(clamped.trash_retention_days, 365);
|
||||
await app.invoke("save_app_settings", {
|
||||
settings: { ...clamped, trash_retention_days: 7 },
|
||||
});
|
||||
|
||||
assert.deepEqual(await app.invoke("list_trashed_profiles"), []);
|
||||
|
||||
const group = await app.invoke("create_profile_group", {
|
||||
name: "Trash Group",
|
||||
});
|
||||
const created = await app.invoke("create_browser_profile_new", {
|
||||
name: "Recoverable",
|
||||
browserStr: "wayfern",
|
||||
version: "150.0.7871.100",
|
||||
releaseType: "stable",
|
||||
proxyId: null,
|
||||
vpnId: null,
|
||||
wayfernConfig: {
|
||||
fingerprint: "{}",
|
||||
identity_id: "identity-e2e",
|
||||
identity_overrides: JSON.stringify({ userAgent: "Custom UA" }),
|
||||
location: JSON.stringify({
|
||||
timezone: "Europe/Berlin",
|
||||
language: "de-DE",
|
||||
}),
|
||||
},
|
||||
groupId: group.id,
|
||||
ephemeral: false,
|
||||
dnsBlocklist: null,
|
||||
launchHook: null,
|
||||
});
|
||||
await app.invoke("update_profile_tags", {
|
||||
profileId: created.id,
|
||||
tags: ["shop", "eu"],
|
||||
});
|
||||
const before = (await app.invoke("list_browser_profiles")).find(
|
||||
(item) => item.id === created.id,
|
||||
);
|
||||
assert.equal(before.wayfern_config.identity_id, "identity-e2e");
|
||||
assert.deepEqual(before.tags, ["shop", "eu"]);
|
||||
assert.equal(before.group_id, group.id);
|
||||
|
||||
// Real files to carry through the move, plus a cache the trash must drop.
|
||||
const profilesDir = path.join(app.dataRoot, "data", "profiles");
|
||||
const dataDir = path.join(profilesDir, created.id, "profile");
|
||||
await mkdir(path.join(dataDir, "Default"), { recursive: true });
|
||||
await writeFile(path.join(dataDir, "Default", "Cookies"), "cookie-db");
|
||||
await mkdir(path.join(dataDir, "Cache"), { recursive: true });
|
||||
await writeFile(path.join(dataDir, "Cache", "blob"), "cache-bytes");
|
||||
|
||||
await app.invoke("delete_profile", { profileId: created.id });
|
||||
assert.equal(
|
||||
(await app.invoke("list_browser_profiles")).some(
|
||||
(item) => item.id === created.id,
|
||||
),
|
||||
false,
|
||||
);
|
||||
const trashed = await app.invoke("list_trashed_profiles");
|
||||
assert.equal(trashed.length, 1);
|
||||
assert.equal(trashed[0].id, created.id);
|
||||
assert.equal(trashed[0].name, "Recoverable");
|
||||
assert.equal(trashed[0].browser, "wayfern");
|
||||
assert.equal(trashed[0].version, "150.0.7871.100");
|
||||
assert.equal(trashed[0].group_id, group.id);
|
||||
assert.equal(trashed[0].password_protected, false);
|
||||
assert.equal(
|
||||
trashed[0].expires_at - trashed[0].deleted_at,
|
||||
7 * 24 * 60 * 60,
|
||||
);
|
||||
assert.ok(trashed[0].size_bytes > 0);
|
||||
const trashDir = path.join(app.dataRoot, "data", "trash");
|
||||
const entryDir = path.join(trashDir, created.id);
|
||||
assert.ok(existsSync(path.join(entryDir, "profile.json")));
|
||||
assert.ok(existsSync(path.join(entryDir, "manifest.json")));
|
||||
assert.equal(
|
||||
await readFile(
|
||||
path.join(entryDir, "profile", "Default", "Cookies"),
|
||||
"utf8",
|
||||
),
|
||||
"cookie-db",
|
||||
);
|
||||
assert.equal(
|
||||
existsSync(path.join(entryDir, "profile", "Cache")),
|
||||
false,
|
||||
"caches are pruned before the move",
|
||||
);
|
||||
assert.equal(existsSync(path.join(profilesDir, created.id)), false);
|
||||
|
||||
// A live profile carrying the same name pushes the restored one to a suffix.
|
||||
const namesake = await createProfile(app, "Recoverable");
|
||||
const restored = await app.invoke("restore_trashed_profile", {
|
||||
profileId: created.id,
|
||||
});
|
||||
assert.equal(restored.id, created.id);
|
||||
assert.equal(restored.name, "Recoverable (restored)");
|
||||
assert.deepEqual(restored.wayfern_config, before.wayfern_config);
|
||||
assert.deepEqual(restored.tags, before.tags);
|
||||
assert.equal(restored.group_id, group.id);
|
||||
assert.ok(restored.updated_at >= (before.updated_at ?? 0));
|
||||
const live = (await app.invoke("list_browser_profiles")).find(
|
||||
(item) => item.id === created.id,
|
||||
);
|
||||
assert.deepEqual(live.wayfern_config, before.wayfern_config);
|
||||
assert.deepEqual(live.tags, before.tags);
|
||||
assert.equal(
|
||||
await readFile(
|
||||
path.join(profilesDir, created.id, "profile", "Default", "Cookies"),
|
||||
"utf8",
|
||||
),
|
||||
"cookie-db",
|
||||
);
|
||||
assert.equal(existsSync(entryDir), false);
|
||||
assert.deepEqual(await app.invoke("list_trashed_profiles"), []);
|
||||
assert.match(
|
||||
await app.invokeError("restore_trashed_profile", {
|
||||
profileId: created.id,
|
||||
}),
|
||||
/TRASH_ENTRY_NOT_FOUND/,
|
||||
);
|
||||
|
||||
// A group deleted while the profile sat in the trash is not resurrected.
|
||||
await app.invoke("delete_profile", { profileId: created.id });
|
||||
await app.invoke("delete_profile_group", { groupId: group.id });
|
||||
const restoredWithoutGroup = await app.invoke("restore_trashed_profile", {
|
||||
profileId: created.id,
|
||||
});
|
||||
assert.equal(restoredWithoutGroup.id, created.id);
|
||||
assert.equal(restoredWithoutGroup.group_id, null);
|
||||
assert.deepEqual(
|
||||
restoredWithoutGroup.wayfern_config,
|
||||
before.wayfern_config,
|
||||
);
|
||||
|
||||
// Delete again, then purge: gone for good.
|
||||
await app.invoke("delete_profile", { profileId: created.id });
|
||||
assert.equal((await app.invoke("list_trashed_profiles")).length, 1);
|
||||
await app.invoke("purge_trashed_profile", { profileId: created.id });
|
||||
assert.deepEqual(await app.invoke("list_trashed_profiles"), []);
|
||||
assert.equal(existsSync(entryDir), false);
|
||||
assert.equal(existsSync(path.join(profilesDir, created.id)), false);
|
||||
assert.match(
|
||||
await app.invokeError("purge_trashed_profile", {
|
||||
profileId: created.id,
|
||||
}),
|
||||
/TRASH_ENTRY_NOT_FOUND/,
|
||||
);
|
||||
|
||||
// An explicit permanent delete never lands in the trash.
|
||||
const doomed = await createProfile(app, "Doomed");
|
||||
await app.invoke("delete_profile", {
|
||||
profileId: doomed.id,
|
||||
permanent: true,
|
||||
});
|
||||
assert.deepEqual(await app.invoke("list_trashed_profiles"), []);
|
||||
assert.equal(existsSync(path.join(trashDir, doomed.id)), false);
|
||||
assert.equal(existsSync(path.join(profilesDir, doomed.id)), false);
|
||||
|
||||
// A bulk delete trashes every profile; emptying the trash clears them all.
|
||||
const bulkA = await createProfile(app, "Bulk A");
|
||||
const bulkB = await createProfile(app, "Bulk B");
|
||||
await app.invoke("delete_selected_profiles", {
|
||||
profileIds: [bulkA.id, bulkB.id],
|
||||
});
|
||||
assert.deepEqual(
|
||||
(await app.invoke("list_trashed_profiles"))
|
||||
.map((entry) => entry.name)
|
||||
.sort(),
|
||||
["Bulk A", "Bulk B"],
|
||||
);
|
||||
assert.equal(await app.invoke("empty_trash"), 2);
|
||||
assert.deepEqual(await app.invoke("list_trashed_profiles"), []);
|
||||
assert.equal(existsSync(path.join(trashDir, bulkA.id)), false);
|
||||
|
||||
// Restore refuses an entry whose id a live profile already carries.
|
||||
const conflictDir = path.join(trashDir, namesake.id);
|
||||
await mkdir(conflictDir, { recursive: true });
|
||||
await writeFile(
|
||||
path.join(conflictDir, "profile.json"),
|
||||
JSON.stringify(namesake),
|
||||
);
|
||||
await writeFile(
|
||||
path.join(conflictDir, "manifest.json"),
|
||||
JSON.stringify({
|
||||
deleted_at: 1,
|
||||
expires_at: 4_102_444_800,
|
||||
size_bytes: 0,
|
||||
original_name: namesake.name,
|
||||
}),
|
||||
);
|
||||
assert.match(
|
||||
await app.invokeError("restore_trashed_profile", {
|
||||
profileId: namesake.id,
|
||||
}),
|
||||
/TRASH_RESTORE_CONFLICT/,
|
||||
);
|
||||
await app.invoke("purge_trashed_profile", { profileId: namesake.id });
|
||||
assert.deepEqual(await app.invoke("list_trashed_profiles"), []);
|
||||
|
||||
await app.invoke("delete_profile", {
|
||||
profileId: namesake.id,
|
||||
permanent: true,
|
||||
});
|
||||
assert.deepEqual(await app.invoke("list_browser_profiles"), []);
|
||||
});
|
||||
});
|
||||
|
||||
test("proxies distribute one to one, and group bookmarks reach the profile's Bookmarks file", async () => {
|
||||
await withApp("entities-distribution-bookmarks", async (app) => {
|
||||
const profiles = [];
|
||||
for (const name of ["Fleet 1", "Fleet 2", "Fleet 3", "Fleet 4"]) {
|
||||
profiles.push(await createProfile(app, name));
|
||||
}
|
||||
const proxies = [];
|
||||
for (const [index, name] of ["Exit A", "Exit B", "Exit C"].entries()) {
|
||||
proxies.push(
|
||||
await app.invoke("create_stored_proxy", {
|
||||
name,
|
||||
proxySettings: {
|
||||
proxy_type: "http",
|
||||
host: "127.0.0.1",
|
||||
port: 9001 + index,
|
||||
username: null,
|
||||
password: null,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const profileIds = profiles.map((profile) => profile.id);
|
||||
const proxyIds = proxies.map((proxy) => proxy.id);
|
||||
|
||||
// Four profiles, three proxies: three pairs and one profile left alone.
|
||||
// The fourth must NEVER wrap around onto the first proxy.
|
||||
const plan = await app.invoke("plan_proxy_distribution", {
|
||||
profileIds,
|
||||
proxyIds,
|
||||
allowSharing: false,
|
||||
});
|
||||
assert.deepEqual(
|
||||
plan.pairs,
|
||||
proxyIds.map((proxyId, index) => ({
|
||||
profile_id: profileIds[index],
|
||||
proxy_id: proxyId,
|
||||
})),
|
||||
);
|
||||
assert.deepEqual(plan.unpaired_profile_ids, [profileIds[3]]);
|
||||
assert.deepEqual(plan.unused_proxy_ids, []);
|
||||
assert.deepEqual(plan.shared_proxy_ids, []);
|
||||
assert.deepEqual(plan.running_profile_ids, []);
|
||||
|
||||
const results = await app.invoke("distribute_proxies_to_profiles", {
|
||||
pairs: plan.pairs,
|
||||
});
|
||||
assert.equal(results.length, 3);
|
||||
assert.ok(results.every((result) => result.ok));
|
||||
|
||||
const afterDistribution = await app.invoke("list_browser_profiles");
|
||||
const proxyOf = (id) =>
|
||||
afterDistribution.find((profile) => profile.id === id).proxy_id;
|
||||
assert.equal(proxyOf(profileIds[0]), proxyIds[0]);
|
||||
assert.equal(proxyOf(profileIds[1]), proxyIds[1]);
|
||||
assert.equal(proxyOf(profileIds[2]), proxyIds[2]);
|
||||
assert.equal(proxyOf(profileIds[3]) ?? null, null);
|
||||
|
||||
// A proxy someone else holds is refused by default and only offered once
|
||||
// the caller asks for sharing explicitly.
|
||||
const strict = await app.invoke("plan_proxy_distribution", {
|
||||
profileIds: [profileIds[3]],
|
||||
proxyIds: [proxyIds[0]],
|
||||
allowSharing: false,
|
||||
});
|
||||
assert.deepEqual(strict.pairs, []);
|
||||
assert.deepEqual(strict.shared_proxy_ids, [proxyIds[0]]);
|
||||
assert.deepEqual(strict.unpaired_profile_ids, [profileIds[3]]);
|
||||
|
||||
const permissive = await app.invoke("plan_proxy_distribution", {
|
||||
profileIds: [profileIds[3]],
|
||||
proxyIds: [proxyIds[0]],
|
||||
allowSharing: true,
|
||||
});
|
||||
assert.deepEqual(permissive.pairs, [
|
||||
{ profile_id: profileIds[3], proxy_id: proxyIds[0] },
|
||||
]);
|
||||
|
||||
// Per-profile failures never break the batch: one good pair still lands.
|
||||
const mixed = await app.invoke("distribute_proxies_to_profiles", {
|
||||
pairs: [
|
||||
{ profile_id: profileIds[3], proxy_id: proxyIds[0] },
|
||||
{ profile_id: profileIds[3], proxy_id: proxyIds[1] },
|
||||
{
|
||||
profile_id: profileIds[0],
|
||||
proxy_id: "00000000-0000-4000-8000-000000000000",
|
||||
},
|
||||
],
|
||||
});
|
||||
assert.equal(mixed[0].ok, true);
|
||||
assert.equal(mixed[1].ok, false);
|
||||
assert.match(mixed[1].error, /PROFILE_PAIRED_TWICE/);
|
||||
assert.equal(mixed[2].ok, false);
|
||||
assert.match(mixed[2].error, /PROXY_NOT_FOUND/);
|
||||
assert.equal(
|
||||
(await app.invoke("list_browser_profiles")).find(
|
||||
(profile) => profile.id === profileIds[3],
|
||||
).proxy_id,
|
||||
proxyIds[0],
|
||||
);
|
||||
|
||||
// --- group bookmarks ---
|
||||
const group = await app.invoke("create_profile_group", {
|
||||
name: "Client Sites",
|
||||
});
|
||||
assert.deepEqual(
|
||||
await app.invoke("get_group_bookmarks", { groupId: group.id }),
|
||||
[],
|
||||
);
|
||||
|
||||
const refused = await app.invokeError("set_group_bookmarks", {
|
||||
groupId: group.id,
|
||||
bookmarks: [{ title: "Keys", url: "file:///etc/passwd", folder: null }],
|
||||
});
|
||||
assert.match(refused, /URL_SCHEME_NOT_ALLOWED/);
|
||||
const unnamed = await app.invokeError("set_group_bookmarks", {
|
||||
groupId: group.id,
|
||||
bookmarks: [{ title: " ", url: "https://ok.example", folder: null }],
|
||||
});
|
||||
assert.match(unnamed, /NAME_CANNOT_BE_EMPTY/);
|
||||
|
||||
const saved = await app.invoke("set_group_bookmarks", {
|
||||
groupId: group.id,
|
||||
bookmarks: [
|
||||
{ title: "Support", url: "https://support.example", folder: null },
|
||||
{ title: "Console", url: "https://console.example", folder: "Ops" },
|
||||
],
|
||||
});
|
||||
assert.equal(saved.length, 2);
|
||||
assert.equal(saved[1].folder, "Ops");
|
||||
assert.equal(
|
||||
(await app.invoke("get_groups_with_profile_counts")).find(
|
||||
(item) => item.id === group.id,
|
||||
).bookmark_count,
|
||||
2,
|
||||
);
|
||||
|
||||
const target = profiles[0];
|
||||
await app.invoke("assign_profiles_to_group", {
|
||||
profileIds: [target.id],
|
||||
groupId: group.id,
|
||||
});
|
||||
|
||||
// Seed the profile's own Bookmarks file the way a real Chromium session
|
||||
// would have left it, so the write has something of the user's to preserve.
|
||||
const bookmarksFile = path.join(
|
||||
app.dataRoot,
|
||||
"data",
|
||||
"profiles",
|
||||
target.id,
|
||||
"profile",
|
||||
"Default",
|
||||
"Bookmarks",
|
||||
);
|
||||
await mkdir(path.dirname(bookmarksFile), { recursive: true });
|
||||
const permanentFolder = (id, name) => ({
|
||||
children: [],
|
||||
date_added: "13300000000000000",
|
||||
date_modified: "13300000000000000",
|
||||
guid: `0000000${id}-0000-4000-8000-000000000000`,
|
||||
id: String(id),
|
||||
name,
|
||||
type: "folder",
|
||||
});
|
||||
await writeFile(
|
||||
bookmarksFile,
|
||||
JSON.stringify({
|
||||
checksum: "0".repeat(32),
|
||||
roots: {
|
||||
bookmark_bar: {
|
||||
...permanentFolder(1, "Bookmarks bar"),
|
||||
children: [
|
||||
{
|
||||
date_added: "13300000000000000",
|
||||
guid: "aaaaaaaa-0000-4000-8000-000000000000",
|
||||
id: "9",
|
||||
name: "My Bank",
|
||||
type: "url",
|
||||
url: "https://bank.example/",
|
||||
},
|
||||
],
|
||||
},
|
||||
other: permanentFolder(2, "Other bookmarks"),
|
||||
synced: permanentFolder(3, "Mobile bookmarks"),
|
||||
},
|
||||
sync_metadata: "Zm9v",
|
||||
version: 1,
|
||||
}),
|
||||
);
|
||||
|
||||
const readBookmarks = async () =>
|
||||
JSON.parse(await readFile(bookmarksFile, "utf8"));
|
||||
const managedFolderOf = (document) =>
|
||||
document.roots.bookmark_bar.children.filter(
|
||||
(child) =>
|
||||
child.type === "folder" &&
|
||||
child.meta_info?.donut_managed_group_bookmarks === "1",
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
await app.invoke("apply_group_bookmarks_to_profile", {
|
||||
profileId: target.id,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
|
||||
let document = await readBookmarks();
|
||||
let managed = managedFolderOf(document);
|
||||
assert.equal(managed.length, 1);
|
||||
assert.equal(managed[0].name, "Donut Group Bookmarks");
|
||||
assert.deepEqual(
|
||||
managed[0].children.map((child) => child.name),
|
||||
["Support", "Ops"],
|
||||
);
|
||||
assert.deepEqual(
|
||||
managed[0].children[1].children.map((child) => child.url),
|
||||
["https://console.example"],
|
||||
);
|
||||
// The user's own bookmark, the other roots and Chromium's opaque state all
|
||||
// survive; only the checksum is rewritten to describe the new tree.
|
||||
assert.equal(document.roots.bookmark_bar.children[0].name, "My Bank");
|
||||
assert.equal(document.sync_metadata, "Zm9v");
|
||||
assert.equal(document.version, 1);
|
||||
assert.notEqual(document.checksum, "0".repeat(32));
|
||||
assert.match(document.checksum, /^[0-9a-f]{32}$/);
|
||||
|
||||
// Applying again is a no-op: the folder is not duplicated and the file is
|
||||
// not even rewritten.
|
||||
const firstWrite = await readFile(bookmarksFile, "utf8");
|
||||
assert.equal(
|
||||
await app.invoke("apply_group_bookmarks_to_profile", {
|
||||
profileId: target.id,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(await readFile(bookmarksFile, "utf8"), firstWrite);
|
||||
|
||||
// Removing a bookmark from the group removes it from the folder next time.
|
||||
await app.invoke("set_group_bookmarks", {
|
||||
groupId: group.id,
|
||||
bookmarks: [
|
||||
{ title: "Support", url: "https://support.example", folder: null },
|
||||
],
|
||||
});
|
||||
assert.equal(
|
||||
await app.invoke("apply_group_bookmarks_to_profile", {
|
||||
profileId: target.id,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
document = await readBookmarks();
|
||||
managed = managedFolderOf(document);
|
||||
assert.equal(managed.length, 1);
|
||||
assert.deepEqual(
|
||||
managed[0].children.map((child) => child.name),
|
||||
["Support"],
|
||||
);
|
||||
|
||||
// Emptying the group takes the whole folder away and leaves the user's own.
|
||||
await app.invoke("set_group_bookmarks", {
|
||||
groupId: group.id,
|
||||
bookmarks: [],
|
||||
});
|
||||
assert.equal(
|
||||
await app.invoke("apply_group_bookmarks_to_profile", {
|
||||
profileId: target.id,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
document = await readBookmarks();
|
||||
assert.deepEqual(managedFolderOf(document), []);
|
||||
assert.deepEqual(
|
||||
document.roots.bookmark_bar.children.map((child) => child.name),
|
||||
["My Bank"],
|
||||
);
|
||||
|
||||
// A profile in no group is left entirely alone.
|
||||
assert.equal(
|
||||
await app.invoke("apply_group_bookmarks_to_profile", {
|
||||
profileId: profileIds[1],
|
||||
}),
|
||||
false,
|
||||
);
|
||||
|
||||
await app.invoke("delete_selected_profiles", { profileIds });
|
||||
await app.invoke("delete_profile_group", { groupId: group.id });
|
||||
for (const proxy of proxies) {
|
||||
await app.invoke("delete_stored_proxy", { proxyId: proxy.id });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
+575
-379
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+123
-52
@@ -1,5 +1,6 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { readdir, readFile, stat, writeFile } from "node:fs/promises";
|
||||
import { isIP } from "node:net";
|
||||
import path from "node:path";
|
||||
@@ -270,57 +271,55 @@ async function createProfileThroughUi(app, groupName) {
|
||||
return profiles.find((profile) => profile.name === "Visible Network Profile");
|
||||
}
|
||||
|
||||
async function assignNetworkThroughUi(app, profileName, currentName, newName) {
|
||||
const trigger = await app.execute(
|
||||
/**
|
||||
* The popover trigger sitting in a named COLUMN of a profile's row.
|
||||
*
|
||||
* Anchored to the column, never to the label the cell happens to show. Both
|
||||
* callers used to search the whole row for the cell's current text, "Default"
|
||||
* for the extension group, "Not selected" for the network, and both of those
|
||||
* strings had long since become "None" in the app. Neither string exists
|
||||
* anywhere in src/ any more, so the assertions failed against a UI that was
|
||||
* working correctly, and with no E2E in CI nothing reported it.
|
||||
*
|
||||
* Matching on "None" instead would only move the problem: Proxy / VPN and EXT
|
||||
* render the identical text, so a row-wide search would pick whichever came
|
||||
* first in the DOM. The column is the thing that actually identifies the
|
||||
* control, so that is what this matches on.
|
||||
*
|
||||
* A renamed header returns the header list rather than null, so the failure
|
||||
* says which column went missing instead of "was not visible".
|
||||
*/
|
||||
async function columnTrigger(app, profileName, header) {
|
||||
return app.execute(
|
||||
`
|
||||
const row = [...document.querySelectorAll("tr")].find((candidate) =>
|
||||
const row = [...document.querySelectorAll("tbody tr")].find((candidate) =>
|
||||
(candidate.innerText || "").includes(arguments[0])
|
||||
);
|
||||
const expected = arguments[1].toLocaleLowerCase();
|
||||
return [...(row?.querySelectorAll('[aria-haspopup="dialog"]') ?? [])].find(
|
||||
(trigger) => (trigger.innerText || trigger.textContent || "")
|
||||
.toLocaleLowerCase()
|
||||
.includes(expected)
|
||||
) ?? null;
|
||||
if (!row) return null;
|
||||
const headers = [
|
||||
...(row.closest("table")?.querySelectorAll("thead th") ?? []),
|
||||
].map((cell) => (cell.innerText || cell.textContent || "").trim());
|
||||
const index = headers.indexOf(arguments[1]);
|
||||
if (index < 0) return "MISSING_COLUMN:" + headers.join(" | ");
|
||||
return (
|
||||
row.children[index]?.querySelector(
|
||||
'[aria-haspopup="dialog"], button',
|
||||
) ?? null
|
||||
);
|
||||
`,
|
||||
[profileName, currentName],
|
||||
);
|
||||
assert.ok(trigger, `Network selector for ${profileName} was not visible`);
|
||||
await app.session.click(trigger);
|
||||
await app.clickText(newName, { exact: false, roles: ["option"] });
|
||||
await app.waitFor(
|
||||
() =>
|
||||
app.execute(
|
||||
`
|
||||
return ![...document.querySelectorAll('[data-slot="popover-content"]')]
|
||||
.some((content) => (content.innerText || "").includes(arguments[0]));
|
||||
`,
|
||||
[newName],
|
||||
),
|
||||
{ description: `${newName} network picker to unmount` },
|
||||
[profileName, header],
|
||||
);
|
||||
}
|
||||
|
||||
async function assignExtensionGroupThroughUi(
|
||||
app,
|
||||
profileName,
|
||||
currentName,
|
||||
newName,
|
||||
) {
|
||||
const trigger = await app.execute(
|
||||
`
|
||||
const row = [...document.querySelectorAll("tr")].find((candidate) =>
|
||||
(candidate.innerText || "").includes(arguments[0])
|
||||
);
|
||||
return [...(row?.querySelectorAll("button") ?? [])].find(
|
||||
(button) => (button.innerText || button.textContent || "")
|
||||
.trim()
|
||||
.includes(arguments[1])
|
||||
) ?? null;
|
||||
`,
|
||||
[profileName, currentName],
|
||||
);
|
||||
assert.ok(trigger, `Extension selector for ${profileName} was not visible`);
|
||||
async function assignThroughUi(app, profileName, header, newName, what) {
|
||||
const trigger = await columnTrigger(app, profileName, header);
|
||||
if (typeof trigger === "string") {
|
||||
assert.fail(
|
||||
`The "${header}" column is gone; the table now has: ` +
|
||||
trigger.replace("MISSING_COLUMN:", ""),
|
||||
);
|
||||
}
|
||||
assert.ok(trigger, `${what} selector for ${profileName} was not visible`);
|
||||
await app.session.click(trigger);
|
||||
await app.clickText(newName, { exact: false, roles: ["option"] });
|
||||
await app.waitFor(
|
||||
@@ -332,10 +331,16 @@ async function assignExtensionGroupThroughUi(
|
||||
`,
|
||||
[newName],
|
||||
),
|
||||
{ description: `${newName} extension picker to unmount` },
|
||||
{ description: `${newName} ${what.toLowerCase()} picker to unmount` },
|
||||
);
|
||||
}
|
||||
|
||||
const assignNetworkThroughUi = (app, profileName, newName) =>
|
||||
assignThroughUi(app, profileName, "Proxy / VPN", newName, "Network");
|
||||
|
||||
const assignExtensionGroupThroughUi = (app, profileName, newName) =>
|
||||
assignThroughUi(app, profileName, "EXT", newName, "Extension");
|
||||
|
||||
async function runProfile(_app, base, token, profileId, url) {
|
||||
const launched = await request(`${base}/v1/profiles/${profileId}/run`, {
|
||||
method: "POST",
|
||||
@@ -808,7 +813,6 @@ test("visible UI creates and assigns profiles, groups, proxies, VPNs, extensions
|
||||
await assignExtensionGroupThroughUi(
|
||||
app,
|
||||
profile.name,
|
||||
"Default",
|
||||
extensionEntities.group.name,
|
||||
);
|
||||
await app.waitFor(
|
||||
@@ -824,6 +828,23 @@ test("visible UI creates and assigns profiles, groups, proxies, VPNs, extensions
|
||||
name: "Residential SOCKS5",
|
||||
proxySettings: socksSettings,
|
||||
});
|
||||
|
||||
// The exit's ISP and timezone are read from the MaxMind databases on this
|
||||
// machine, never from an outside lookup service, so they have to actually
|
||||
// be in place before a check can report them. The create-profile dialog
|
||||
// starts that download in the background; this waits for it rather than
|
||||
// racing it.
|
||||
await app.invoke("download_geoip_database");
|
||||
await app.waitFor(
|
||||
async () =>
|
||||
existsSync(path.join(app.dataRoot, "cache", "GeoLite2-City.mmdb")) &&
|
||||
existsSync(path.join(app.dataRoot, "cache", "GeoLite2-ASN.mmdb")),
|
||||
{
|
||||
description: "the local MaxMind city and ASN databases",
|
||||
timeoutMs: 180_000,
|
||||
},
|
||||
);
|
||||
|
||||
const [httpCheck, socksCheck] = await Promise.all([
|
||||
app.invoke("check_proxy_validity", {
|
||||
proxyId: httpProxy.id,
|
||||
@@ -839,12 +860,62 @@ test("visible UI creates and assigns profiles, groups, proxies, VPNs, extensions
|
||||
assert.ok(isIP(httpCheck.ip));
|
||||
assert.ok(isIP(socksCheck.ip));
|
||||
|
||||
await assignNetworkThroughUi(
|
||||
app,
|
||||
profile.name,
|
||||
"Not selected",
|
||||
httpProxy.name,
|
||||
// An HTTP proxy tunnels TCP with CONNECT and has no datagram command, so
|
||||
// the verdict follows from the protocol and is never a probe result.
|
||||
assert.equal(httpCheck.udp, "no");
|
||||
// The SOCKS5 proxy answered the exit lookup, so the UDP probe reached it
|
||||
// too: the verdict has to be a real answer, never "unknown". Which answer
|
||||
// is the provider's to decide.
|
||||
assert.ok(
|
||||
["yes", "no"].includes(socksCheck.udp),
|
||||
`a reachable SOCKS5 proxy must give a definite UDP verdict, got ${socksCheck.udp}`,
|
||||
);
|
||||
|
||||
for (const [label, check] of [
|
||||
["http", httpCheck],
|
||||
["socks5", socksCheck],
|
||||
]) {
|
||||
assert.ok(
|
||||
typeof check.latency_ms === "number" && check.latency_ms > 0,
|
||||
`${label} check has to report how long it took`,
|
||||
);
|
||||
// Read out of the local MaxMind databases: the exit address is never
|
||||
// handed to an outside lookup service to learn these.
|
||||
assert.ok(
|
||||
typeof check.isp === "string" && check.isp.trim().length > 0,
|
||||
`${label} check has to name the exit's ISP, got ${JSON.stringify(check.isp)}`,
|
||||
);
|
||||
assert.ok(
|
||||
typeof check.timezone === "string" && check.timezone.includes("/"),
|
||||
`${label} check has to report the exit's timezone, got ${JSON.stringify(check.timezone)}`,
|
||||
);
|
||||
}
|
||||
|
||||
// The trail grows by one line per check, newest first, and carries what
|
||||
// the receipt carried.
|
||||
const firstTrail = await app.invoke("get_proxy_check_history", {
|
||||
proxyId: socksProxy.id,
|
||||
});
|
||||
assert.equal(firstTrail.length, 1);
|
||||
assert.equal(firstTrail[0].ok, true);
|
||||
assert.equal(firstTrail[0].ip, socksCheck.ip);
|
||||
assert.equal(firstTrail[0].udp, socksCheck.udp);
|
||||
assert.equal(firstTrail[0].isp, socksCheck.isp);
|
||||
|
||||
await app.invoke("check_proxy_validity", {
|
||||
proxyId: socksProxy.id,
|
||||
proxySettings: null,
|
||||
});
|
||||
const grownTrail = await app.invoke("get_proxy_check_history", {
|
||||
proxyId: socksProxy.id,
|
||||
});
|
||||
assert.equal(grownTrail.length, 2);
|
||||
assert.ok(
|
||||
grownTrail[0].timestamp >= grownTrail[1].timestamp,
|
||||
"the trail is newest first",
|
||||
);
|
||||
|
||||
await assignNetworkThroughUi(app, profile.name, httpProxy.name);
|
||||
await app.waitFor(
|
||||
async () =>
|
||||
(await app.invoke("list_browser_profiles")).find(
|
||||
@@ -885,7 +956,7 @@ test("visible UI creates and assigns profiles, groups, proxies, VPNs, extensions
|
||||
activeCdp = null;
|
||||
await assertProxyWorkerLogsRedacted(app, [httpSettings, socksSettings]);
|
||||
|
||||
await assignNetworkThroughUi(app, profile.name, httpProxy.name, vpn.name);
|
||||
await assignNetworkThroughUi(app, profile.name, vpn.name);
|
||||
await app.waitFor(
|
||||
async () =>
|
||||
(await app.invoke("list_browser_profiles")).find(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { existsSync } from "node:fs";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
@@ -196,3 +197,115 @@ test("tray labels, hide-to-tray, and confirmed quit follow the native lifecycle"
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("the data directory can be moved to another folder and the choice survives a restart", async () => {
|
||||
await withApp(
|
||||
"smoke-data-root",
|
||||
async (app) => {
|
||||
// Every path below is inside this session's own temporary root. The
|
||||
// real installation is never a source or a destination here.
|
||||
const defaultRoot = path.join(app.dataRoot, "data");
|
||||
const pointerFile = path.join(app.dataRoot, "data-root.json");
|
||||
const destination = path.join(app.root, "moved-donut-data");
|
||||
|
||||
const before = await app.invoke("get_data_root_info");
|
||||
assert.equal(before.active_path, defaultRoot);
|
||||
assert.equal(before.configured_path, null);
|
||||
assert.equal(before.restart_required, false);
|
||||
assert.equal(before.active_path_missing, false);
|
||||
assert.equal(before.overridden_by_environment, false);
|
||||
assert.ok(before.file_count > 0, "the seeded settings file is counted");
|
||||
assert.ok(before.size_bytes > 0, "the directory reports a real size");
|
||||
assert.equal(typeof before.app_directory_name, "string");
|
||||
|
||||
const profile = await app.invoke("create_browser_profile_new", {
|
||||
name: "Carried Across",
|
||||
browserStr: "wayfern",
|
||||
version: "150.0.7871.100",
|
||||
releaseType: "stable",
|
||||
proxyId: null,
|
||||
vpnId: null,
|
||||
wayfernConfig: { fingerprint: "{}" },
|
||||
groupId: null,
|
||||
ephemeral: false,
|
||||
dnsBlocklist: null,
|
||||
launchHook: null,
|
||||
});
|
||||
|
||||
// Each refusal is its own code, because each one has a different fix.
|
||||
assert.match(
|
||||
await app.invokeError("move_data_root", { destination: defaultRoot }),
|
||||
/DATA_ROOT_SAME_AS_CURRENT/,
|
||||
);
|
||||
assert.match(
|
||||
await app.invokeError("move_data_root", {
|
||||
destination: path.join(defaultRoot, "profiles", "elsewhere"),
|
||||
}),
|
||||
/DATA_ROOT_DESTINATION_INSIDE_SOURCE/,
|
||||
);
|
||||
assert.match(
|
||||
await app.invokeError("move_data_root", {
|
||||
destination: "not/absolute",
|
||||
}),
|
||||
/DATA_ROOT_DESTINATION_NOT_WRITABLE/,
|
||||
);
|
||||
assert.equal(
|
||||
existsSync(destination),
|
||||
false,
|
||||
"a refused move must not create the destination",
|
||||
);
|
||||
|
||||
const moved = await app.invoke("move_data_root", { destination });
|
||||
assert.equal(moved.configured_path, destination);
|
||||
assert.equal(moved.restart_required, true);
|
||||
// The move takes effect at the next start: this process keeps every
|
||||
// path it resolved when it started.
|
||||
assert.equal(moved.active_path, defaultRoot);
|
||||
|
||||
// Copy, then verify, then delete: the old directory only goes once the
|
||||
// copy has been proven whole.
|
||||
assert.equal(existsSync(defaultRoot), false, "the source is removed");
|
||||
assert.ok(
|
||||
existsSync(path.join(destination, "settings", "app_settings.json")),
|
||||
"settings travelled with the move",
|
||||
);
|
||||
assert.ok(
|
||||
existsSync(path.join(destination, "profiles")),
|
||||
"profiles travelled with the move",
|
||||
);
|
||||
|
||||
// The pointer lives beside the data directory, never inside it, or the
|
||||
// delete above would have taken it and the next start would forget.
|
||||
const pointer = JSON.parse(await readFile(pointerFile, "utf8"));
|
||||
assert.equal(pointer.path, destination);
|
||||
|
||||
await app.restart();
|
||||
|
||||
const after = await app.invoke("get_data_root_info");
|
||||
assert.equal(after.active_path, destination);
|
||||
assert.equal(after.configured_path, destination);
|
||||
assert.equal(after.restart_required, false);
|
||||
assert.equal(after.active_path_missing, false);
|
||||
|
||||
const profiles = await app.invoke("list_browser_profiles");
|
||||
assert.ok(
|
||||
profiles.some((entry) => entry.id === profile.id),
|
||||
"the moved directory still holds the profile",
|
||||
);
|
||||
const settings = await app.invoke("get_app_settings");
|
||||
assert.equal(settings.onboarding_completed, true);
|
||||
|
||||
// Forgetting the choice is the escape hatch for a drive that is gone
|
||||
// for good; it moves nothing, so it too only lands on the next start.
|
||||
const cleared = await app.invoke("clear_data_root_choice");
|
||||
assert.equal(cleared.configured_path, null);
|
||||
assert.equal(existsSync(pointerFile), false);
|
||||
|
||||
await app.restart();
|
||||
const restored = await app.invoke("get_data_root_info");
|
||||
assert.equal(restored.active_path, defaultRoot);
|
||||
assert.equal(restored.configured_path, null);
|
||||
},
|
||||
{ seedDownloadedBrowser: true },
|
||||
);
|
||||
});
|
||||
|
||||
+49
-1
@@ -483,10 +483,54 @@ test("global config sealing and encrypted profile sync reject a wrong password,
|
||||
"correct password decrypts profile browser file",
|
||||
);
|
||||
|
||||
const emptyProfile = await createProfile(source, "Encrypted Empty Profile");
|
||||
await source.invoke("set_profile_sync_mode", {
|
||||
profileId: emptyProfile.id,
|
||||
syncMode: "Encrypted",
|
||||
});
|
||||
await waitFor(
|
||||
source,
|
||||
async () =>
|
||||
(await listRemote(`profiles/${emptyProfile.id}/`)).some(
|
||||
(object) =>
|
||||
object.key === `profiles/${emptyProfile.id}/metadata.json`,
|
||||
),
|
||||
"empty profile metadata uploaded before rollover",
|
||||
);
|
||||
|
||||
await source.invoke("set_e2e_password", {
|
||||
password: "rolled encryption password",
|
||||
});
|
||||
await source.invoke("rollover_encryption_for_all_entities");
|
||||
let rollingOver = true;
|
||||
let manifestDisappeared = false;
|
||||
await Promise.all([
|
||||
source.invoke("rollover_encryption_for_all_entities").finally(() => {
|
||||
rollingOver = false;
|
||||
}),
|
||||
(async () => {
|
||||
while (rollingOver) {
|
||||
const objects = await listRemote(`profiles/${encryptedProfile.id}/`);
|
||||
manifestDisappeared ||= !objects.some(
|
||||
(object) =>
|
||||
object.key === `profiles/${encryptedProfile.id}/manifest.json`,
|
||||
);
|
||||
if (rollingOver) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
}
|
||||
})(),
|
||||
]);
|
||||
assert.equal(
|
||||
manifestDisappeared,
|
||||
false,
|
||||
"rollover must not let another device interpret a missing manifest as an empty remote profile",
|
||||
);
|
||||
assert.ok(
|
||||
(await listRemote(`profiles/${emptyProfile.id}/`)).some(
|
||||
(object) => object.key === `profiles/${emptyProfile.id}/manifest.json`,
|
||||
),
|
||||
"rollover must publish a manifest even for an empty profile",
|
||||
);
|
||||
await waitFor(
|
||||
source,
|
||||
async () => {
|
||||
@@ -555,6 +599,10 @@ test("global config sealing and encrypted profile sync reject a wrong password,
|
||||
profileId: encryptedProfile.id,
|
||||
syncMode: "Disabled",
|
||||
});
|
||||
await source.invoke("set_profile_sync_mode", {
|
||||
profileId: emptyProfile.id,
|
||||
syncMode: "Disabled",
|
||||
});
|
||||
await source.invoke("delete_e2e_password");
|
||||
assert.equal(await source.invoke("check_has_e2e_password"), false);
|
||||
const missingPassword = await source.invokeError("verify_e2e_password", {
|
||||
|
||||
+1027
-5
File diff suppressed because it is too large
Load Diff
+9
-2
@@ -10,11 +10,17 @@
|
||||
"prebuild": "pnpm licenses:generate",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"test": "pnpm test:themes && pnpm test:window-decorations && pnpm test:cookie-bot-limits && pnpm test:proxy-string && pnpm test:profile-search && pnpm test:licenses && pnpm test:xray-packaging && pnpm test:rust:unit && pnpm test:sync-e2e",
|
||||
"test": "pnpm test:themes && pnpm test:window-decorations && pnpm test:cookie-bot-limits && pnpm test:cookie-bot-outcomes && pnpm test:agent && pnpm test:backend-errors && pnpm test:i18n-parity && pnpm test:proxy-string && pnpm test:proxy-type && pnpm test:proxy-first-hop-claims && pnpm test:profile-search && pnpm test:licenses && pnpm test:xray-packaging && pnpm test:rust:unit && pnpm test:sync-e2e",
|
||||
"test:themes": "node --test src/lib/themes.test.mjs",
|
||||
"test:window-decorations": "node --test src/lib/window-decorations.test.mjs",
|
||||
"test:cookie-bot-limits": "node --test src/lib/cookie-bot-limits.test.mjs",
|
||||
"test:cookie-bot-limits": "node --test src/lib/cookie-bot-limits.test.mjs src/lib/schedule-layout.test.mjs",
|
||||
"test:cookie-bot-outcomes": "node --test src/lib/cookie-bot-outcomes.test.mjs",
|
||||
"test:agent": "node --test src/lib/agent.test.mjs",
|
||||
"test:backend-errors": "node --test src/lib/backend-errors.test.mjs",
|
||||
"test:i18n-parity": "node --test src/lib/i18n-parity.test.mjs",
|
||||
"test:proxy-string": "node --test src/lib/proxy-string.test.mjs",
|
||||
"test:proxy-type": "node --test src/lib/proxy-type.test.mjs",
|
||||
"test:proxy-first-hop-claims": "node --test src/lib/proxy-first-hop-claims.test.mjs",
|
||||
"test:profile-search": "node --test src/lib/profile-search.test.mjs",
|
||||
"test:licenses": "node --test scripts/generate-licenses.test.mjs && node scripts/generate-licenses.mjs --check",
|
||||
"test:xray-packaging": "node --test src-tauri/download-xray.test.mjs",
|
||||
@@ -74,6 +80,7 @@
|
||||
"@tauri-apps/plugin-log": "^2.9.0",
|
||||
"@tauri-apps/plugin-opener": "^2.5.4",
|
||||
"ahooks": "^3.9.7",
|
||||
"aria-hidden": "1.2.6",
|
||||
"canvas-confetti": "^1.9.4",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
|
||||
Generated
+10
-7
@@ -11,7 +11,7 @@ overrides:
|
||||
fast-xml-parser@<5.7.0: '>=5.7.2'
|
||||
fast-uri@<3.1.5: '>=3.1.5 <4'
|
||||
fast-xml-builder@<1.2.0: '>=1.2.0'
|
||||
qs@>=6.11.1 <6.15.2: '>=6.15.2'
|
||||
qs@<6.16.0: '>=6.16.0'
|
||||
js-cookie@<3.0.7: '>=3.0.7'
|
||||
nanoid@<3.3.17: '>=3.3.17 <4'
|
||||
fast-uri@>=4.0.0 <4.1.1: '>=4.1.1 <5'
|
||||
@@ -99,6 +99,9 @@ importers:
|
||||
ahooks:
|
||||
specifier: ^3.9.7
|
||||
version: 3.9.7(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||
aria-hidden:
|
||||
specifier: 1.2.6
|
||||
version: 1.2.6
|
||||
canvas-confetti:
|
||||
specifier: ^1.9.4
|
||||
version: 1.9.4
|
||||
@@ -4181,8 +4184,8 @@ packages:
|
||||
pure-rand@7.0.1:
|
||||
resolution: {integrity: sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ==}
|
||||
|
||||
qs@6.15.3:
|
||||
resolution: {integrity: sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==}
|
||||
qs@6.16.0:
|
||||
resolution: {integrity: sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==}
|
||||
engines: {node: '>=0.6'}
|
||||
|
||||
radix-ui@1.6.7:
|
||||
@@ -7670,7 +7673,7 @@ snapshots:
|
||||
http-errors: 2.0.1
|
||||
iconv-lite: 0.7.3
|
||||
on-finished: 2.4.1
|
||||
qs: 6.15.3
|
||||
qs: 6.16.0
|
||||
raw-body: 3.0.2
|
||||
type-is: 2.1.0
|
||||
transitivePeerDependencies:
|
||||
@@ -8087,7 +8090,7 @@ snapshots:
|
||||
once: 1.4.0
|
||||
parseurl: 1.3.3
|
||||
proxy-addr: 2.0.7
|
||||
qs: 6.15.3
|
||||
qs: 6.16.0
|
||||
range-parser: 1.3.0
|
||||
router: 2.2.0(supports-color@8.1.1)
|
||||
send: 1.2.1(supports-color@8.1.1)
|
||||
@@ -9158,7 +9161,7 @@ snapshots:
|
||||
|
||||
pure-rand@7.0.1: {}
|
||||
|
||||
qs@6.15.3:
|
||||
qs@6.16.0:
|
||||
dependencies:
|
||||
es-define-property: 1.0.1
|
||||
side-channel: 1.1.1
|
||||
@@ -9604,7 +9607,7 @@ snapshots:
|
||||
formidable: 3.5.4
|
||||
methods: 1.1.2
|
||||
mime: 2.6.0
|
||||
qs: 6.15.3
|
||||
qs: 6.16.0
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
|
||||
+1
-1
@@ -26,7 +26,7 @@ overrides:
|
||||
fast-xml-parser@<5.7.0: '>=5.7.2'
|
||||
fast-uri@<3.1.5: '>=3.1.5 <4'
|
||||
fast-xml-builder@<1.2.0: '>=1.2.0'
|
||||
qs@>=6.11.1 <6.15.2: '>=6.15.2'
|
||||
qs@<6.16.0: '>=6.16.0'
|
||||
js-cookie@<3.0.7: '>=3.0.7'
|
||||
nanoid@<3.3.17: '>=3.3.17 <4'
|
||||
fast-uri@>=4.0.0 <4.1.1: '>=4.1.1 <5'
|
||||
|
||||
@@ -5,7 +5,11 @@ import { pathToFileURL } from "node:url";
|
||||
const URL_PATTERN = /\b[a-z][a-z\d+.-]{1,20}:\/\/[^\s<>"'`]+/giu;
|
||||
const PRIVATE_KEY_PATTERN =
|
||||
/-----BEGIN [^-\r\n]*PRIVATE KEY-----[\s\S]*?-----END [^-\r\n]*PRIVATE KEY-----/giu;
|
||||
const BEARER_PATTERN = /\bBearer\s+[A-Za-z\d._~+/=-]+/giu;
|
||||
// Mirrors AUTH_SCHEME_RE in src-tauri/src/log_redaction.rs: schemes whose
|
||||
// credential is a bare token after the scheme name, which the assignment
|
||||
// pattern below cannot match because its value class stops at the space.
|
||||
const AUTH_SCHEME_PATTERN =
|
||||
/\b(Bearer|Basic|Token|Digest|Negotiate|NTLM)\s+[A-Za-z\d._~+/=-]+/giu;
|
||||
const SECRET_ASSIGNMENT_PATTERN =
|
||||
/\b(?:api[_-]?key|authorization|password|passwd|private[_-]?key|proxy[_-]?(?:password|username)|refresh[_-]?token|secret|token|username)\b\s*[:=]\s*[^\s,;]+/giu;
|
||||
const JWT_PATTERN = /\beyJ[A-Za-z\d_-]+\.[A-Za-z\d_-]+\.[A-Za-z\d_-]+\b/gu;
|
||||
@@ -66,7 +70,7 @@ export function redactSensitiveText(text, { sensitiveValues = [] } = {}) {
|
||||
return redacted
|
||||
.replace(PRIVATE_KEY_PATTERN, "<redacted-private-key>")
|
||||
.replace(URL_PATTERN, safeUrlLabel)
|
||||
.replace(BEARER_PATTERN, "Bearer <redacted-secret>")
|
||||
.replace(AUTH_SCHEME_PATTERN, "$1 <redacted-secret>")
|
||||
.replace(SECRET_ASSIGNMENT_PATTERN, "<redacted-secret>")
|
||||
.replace(JWT_PATTERN, "<redacted-token>")
|
||||
.replace(TOKEN_PATTERN, "<redacted-token>")
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
# Build and test artifacts for the two standalone SDK packages. Neither is part
|
||||
# of the pnpm workspace, so they carry their own ignores rather than adding
|
||||
# Python and npm noise to the repository root.
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.egg-info/
|
||||
.pytest_cache/
|
||||
.venv/
|
||||
build/
|
||||
dist/
|
||||
node_modules/
|
||||
+285
@@ -0,0 +1,285 @@
|
||||
# Donut Browser SDKs
|
||||
|
||||
Two thin clients for the REST API that Donut Browser serves on this machine:
|
||||
[`python/`](python) (`donutbrowser`) and [`node/`](node) (`@donutbrowser/sdk`).
|
||||
|
||||
They are deliberately thin. Every method is one request to one path that the
|
||||
app publishes in its own `/openapi.json`, with the request and response shapes
|
||||
taken from the Rust handlers in `src-tauri/src/api_server.rs`. Nothing is
|
||||
cached, nothing is retried, and no endpoint is invented. What the two add on top
|
||||
of a bare HTTP call is the part that is tedious to redo in every script:
|
||||
|
||||
- the bearer token and the port, read from arguments or the environment,
|
||||
- one exception class per documented status, with `Retry-After` parsed and the
|
||||
app's `{"code": ...}` error bodies decoded,
|
||||
- a launch-and-stop helper, so a script cannot leave a browser running,
|
||||
- a drift check that fails the tests when the app grows an endpoint the SDK
|
||||
does not cover.
|
||||
|
||||
Neither package is part of the pnpm workspace. They build, test and publish on
|
||||
their own, so they never slow the desktop app's own checks down.
|
||||
|
||||
## Switch the API on first
|
||||
|
||||
**The local REST API is off by default. It must be enabled in the app under
|
||||
Settings → Integrations → Local API → "Enable Local API Server".**
|
||||
|
||||
That screen also shows the two things a client needs:
|
||||
|
||||
- the **port**, `10108` unless it was already taken or you changed it, and
|
||||
- the **authentication token**, sent as `Authorization: Bearer <token>`.
|
||||
|
||||
The server binds `127.0.0.1` only, so it is never reachable from another
|
||||
machine. Requests are also refused with `403` until the Wayfern terms have been
|
||||
accepted in the app.
|
||||
|
||||
Both SDKs read arguments first, then the environment:
|
||||
|
||||
| Setting | Argument | Environment | Default |
|
||||
| --- | --- | --- | --- |
|
||||
| Token | `token` | `DONUT_API_TOKEN` | none; required |
|
||||
| Port | `port` | `DONUT_API_PORT` | `10108` |
|
||||
| Host | `host` | — | `127.0.0.1` |
|
||||
|
||||
`base_url` / `baseUrl` overrides host and port entirely, for the rare case of a
|
||||
tunnel or a path prefix in front of the app.
|
||||
|
||||
## Python
|
||||
|
||||
Requires Python 3.10 or newer. **No runtime dependencies:** the client talks to
|
||||
a loopback server on the same machine, so `http.client` from the standard
|
||||
library is enough. That keeps `pip install donutbrowser` from dragging anything
|
||||
into an automation environment, and it sidesteps a real trap — `urllib.request`
|
||||
honours `http_proxy` from the environment, which would send calls meant for the
|
||||
local app through whatever proxy the shell happens to have set.
|
||||
|
||||
```bash
|
||||
cd sdk/python
|
||||
pip install -e .
|
||||
```
|
||||
|
||||
A worked example: launch a profile, drive the page through the agent endpoints,
|
||||
and stop the browser.
|
||||
|
||||
```python
|
||||
from donutbrowser import Conflict, DonutClient, NotFound, RateLimited
|
||||
|
||||
PROFILE_ID = "your-profile-id"
|
||||
|
||||
with DonutClient(token="...") as client:
|
||||
# `run` starts the browser on entry and stops it on exit, even if the body
|
||||
# raises. `session.cdp_url` is the DevTools endpoint the launch returned.
|
||||
with client.run(PROFILE_ID, url="https://example.com", headless=True) as session:
|
||||
print("CDP:", session.cdp_url)
|
||||
|
||||
# Read the page the way the agent sees it: roles, names, text, bounds.
|
||||
page = client.agent_perceive(PROFILE_ID, viewport_only=True)
|
||||
print(page["stats"]["returnedNodes"], "nodes,", len(page["text"]), "characters")
|
||||
|
||||
# Name an element without a selector, and check it is unambiguous.
|
||||
search = {"role": "textbox", "nameContains": "Search"}
|
||||
resolved = client.agent_resolve_locator(PROFILE_ID, locator=search)
|
||||
assert resolved["matchCount"] == 1
|
||||
|
||||
client.agent_type(PROFILE_ID, locator=search, text="donut browser")
|
||||
client.agent_click(PROFILE_ID, locator={"role": "button", "name": "Search"})
|
||||
|
||||
# Pull a table out of whatever came back.
|
||||
rows = client.agent_extract(
|
||||
PROFILE_ID,
|
||||
container={"role": "listitem"},
|
||||
field_map=[
|
||||
{"key": "title", "locator": {"role": "heading"}, "source": "text"},
|
||||
{"key": "link", "locator": {"role": "link"}, "source": "link"},
|
||||
],
|
||||
max_pages=3,
|
||||
)
|
||||
for row in rows["rows"]:
|
||||
print(row["values"])
|
||||
# The browser is stopped here.
|
||||
```
|
||||
|
||||
Errors are classes, not status codes:
|
||||
|
||||
```python
|
||||
try:
|
||||
client.run_profile(PROFILE_ID)
|
||||
except Conflict as busy:
|
||||
print("someone else has it:", busy.code) # PROFILE_LOCKED_BY_MEMBER, ...
|
||||
except RateLimited as limited:
|
||||
print("wait", limited.retry_after, "seconds")
|
||||
except NotFound:
|
||||
print("no such profile")
|
||||
```
|
||||
|
||||
### Tests
|
||||
|
||||
```bash
|
||||
cd sdk/python
|
||||
pip install -e ".[dev]"
|
||||
pytest
|
||||
```
|
||||
|
||||
## Node
|
||||
|
||||
Requires Node 22 or newer, for the built-in `fetch`. **No runtime
|
||||
dependencies**; `typescript` is a development dependency and is needed only to
|
||||
build `dist/` for publishing. The tests run straight from the TypeScript
|
||||
sources through Node's own type stripping, so `npm test` works with nothing
|
||||
installed at all.
|
||||
|
||||
```bash
|
||||
cd sdk/node
|
||||
npm install # only needed for `npm run build`
|
||||
npm run build
|
||||
```
|
||||
|
||||
The convenience helper is `withProfile(profileId, options, work)`, a callback
|
||||
rather than `await using`. `await using` is not yet syntax any released V8
|
||||
understands, so TypeScript has to down-level it — which would stop the sources
|
||||
running under Node's type stripping, and with it `npm test` on a clean
|
||||
checkout. The callback form works on every Node 22. A `RunSession` does also
|
||||
implement `Symbol.asyncDispose`, so `await using` is there for anyone whose
|
||||
toolchain already handles it.
|
||||
|
||||
```ts
|
||||
import { Conflict, DonutClient, NotFound, RateLimited } from "@donutbrowser/sdk";
|
||||
|
||||
const PROFILE_ID = "your-profile-id";
|
||||
const client = new DonutClient({ token: "..." });
|
||||
|
||||
// The browser starts before `work` runs and is stopped after it, even when it
|
||||
// throws. `session.cdpUrl` is the DevTools endpoint the launch returned.
|
||||
const titles = await client.withProfile(
|
||||
PROFILE_ID,
|
||||
{ url: "https://example.com", headless: true },
|
||||
async (session) => {
|
||||
console.log("CDP:", session.cdpUrl);
|
||||
|
||||
const page = await client.agentPerceive(PROFILE_ID, { viewport_only: true });
|
||||
console.log(page.stats.returnedNodes, "nodes,", page.text.length, "characters");
|
||||
|
||||
const search = { role: "textbox", nameContains: "Search" };
|
||||
const resolved = await client.agentResolveLocator(PROFILE_ID, { locator: search });
|
||||
if (resolved.matchCount !== 1) {
|
||||
throw new Error("the search box is ambiguous");
|
||||
}
|
||||
|
||||
await client.agentType(PROFILE_ID, { locator: search, text: "donut browser" });
|
||||
await client.agentClick(PROFILE_ID, {
|
||||
locator: { role: "button", name: "Search" },
|
||||
});
|
||||
|
||||
const extraction = await client.agentExtract(PROFILE_ID, {
|
||||
container: { role: "listitem" },
|
||||
field_map: [
|
||||
{ key: "title", locator: { role: "heading" }, source: "text" },
|
||||
{ key: "link", locator: { role: "link" }, source: "link" },
|
||||
],
|
||||
max_pages: 3,
|
||||
});
|
||||
return extraction.rows.map((row) => row.values.title);
|
||||
},
|
||||
);
|
||||
// The browser is stopped here.
|
||||
|
||||
try {
|
||||
await client.runProfile(PROFILE_ID);
|
||||
} catch (error) {
|
||||
if (error instanceof Conflict) {
|
||||
console.log("someone else has it:", error.code);
|
||||
} else if (error instanceof RateLimited) {
|
||||
console.log("wait", error.retryAfter, "seconds");
|
||||
} else if (error instanceof NotFound) {
|
||||
console.log("no such profile");
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Tests
|
||||
|
||||
```bash
|
||||
cd sdk/node
|
||||
npm test
|
||||
```
|
||||
|
||||
`npm test` runs the TypeScript sources directly, which needs Node 22.18 or
|
||||
newer (type stripping is unflagged from that release). The published package
|
||||
ships compiled `.mjs`, so consumers only need Node 22.
|
||||
|
||||
## Errors
|
||||
|
||||
Both packages map the app's documented statuses onto the same set of classes.
|
||||
The 5xx classes share one base, so a single `ServerError` branch catches every
|
||||
server-side failure.
|
||||
|
||||
| Status | Python | Node | Meaning |
|
||||
| ---: | --- | --- | --- |
|
||||
| 400 | `ValidationError` | `ValidationError` | Malformed request, duplicate name, unsupported input |
|
||||
| 401 | `Unauthorized` | `Unauthorized` | Missing or wrong bearer token |
|
||||
| 402 | `PaymentRequired` | `PaymentRequired` | Automation needs an active paid plan |
|
||||
| 403 | `Forbidden` | `Forbidden` | Wayfern terms not accepted, or not signed in |
|
||||
| 404 | `NotFound` | `NotFound` | No entity with that id |
|
||||
| 408 | `RequestTimeout` | `RequestTimeout` | `agent/pick` waited and nothing was picked |
|
||||
| 409 | `Conflict` | `Conflict` | A browser, a teammate or a remote session holds the profile |
|
||||
| 429 | `RateLimited` | `RateLimited` | Automation quota spent; `retry_after` / `retryAfter` |
|
||||
| 500 | `ServerError` | `ServerError` | Internal failure |
|
||||
| 502 | `BadGateway` | `BadGateway` | The browser or the relay answered wrongly |
|
||||
| 503 | `ServiceUnavailable` | `ServiceUnavailable` | Cloud, fleet or lock service unreachable |
|
||||
|
||||
Anything else becomes `DonutAPIError` / `DonutApiError` (a `ServerError` for an
|
||||
unrecognised 5xx), so a status added to the app later still arrives as
|
||||
something a caller can catch. A transport failure — the app not running, the
|
||||
API switched off, the wrong port — is `DonutConnectionError`, never an API
|
||||
error, so "Donut is not there" is never confused with "Donut said no".
|
||||
|
||||
Every error carries `status`, `body`, `method` and `path`. When the body is one
|
||||
of the app's structured `{"code": ..., "params": {...}}` strings, `code` and
|
||||
`params` are filled in too.
|
||||
|
||||
A `503` from stopping something means the fleet could not be reached and the
|
||||
remote browser is **still running**, not that it stopped.
|
||||
|
||||
## Staying in step with the app
|
||||
|
||||
`api-paths.json` in this directory lists every operation the app publishes. It
|
||||
is generated from the `#[utoipa::path]` annotations and the `ApiDoc` `paths(...)`
|
||||
list in `src-tauri/src/api_server.rs` — the two things the served
|
||||
`/openapi.json` is actually built from — and the generator fails if a handler is
|
||||
annotated but missing from `ApiDoc`, which is exactly how an endpoint silently
|
||||
disappears from the spec.
|
||||
|
||||
```bash
|
||||
python3 sdk/tools/extract-api-paths.py
|
||||
```
|
||||
|
||||
Each SDK keeps its own table of operation to method (`donutbrowser.coverage` and
|
||||
`OPERATIONS` in the Node package), and both test suites hold that table against
|
||||
the snapshot in **both** directions:
|
||||
|
||||
- an operation in the snapshot that the SDK neither wraps nor lists as omitted
|
||||
fails the suite, so a new endpoint cannot slip past unnoticed;
|
||||
- an entry the app no longer publishes fails too, so a removed endpoint cannot
|
||||
linger as a dead method;
|
||||
- every wrapped operation must name a method that really exists, no two
|
||||
operations may claim the same method, and every omission must carry a reason.
|
||||
|
||||
On top of that, one parameterised test per method drives it against a fake
|
||||
server and asserts the exact verb, path, query string and JSON body it sends.
|
||||
That is what ties the table to reality rather than to a comment.
|
||||
|
||||
Of the 71 published operations, 70 are wrapped. The one omission:
|
||||
|
||||
- `GET /v1/remote-sessions/{id}/cdp` is a WebSocket upgrade, not a request an
|
||||
HTTP client can make, and bundling a websocket implementation would end the
|
||||
zero-dependency promise for one endpoint. `remote_session_cdp_url()` /
|
||||
`remoteSessionCdpUrl()` builds the `ws://` address instead, so a websocket
|
||||
library of your choosing can connect — send the same `Authorization: Bearer`
|
||||
header on the handshake.
|
||||
|
||||
## Tests
|
||||
|
||||
Both suites run offline against a fake HTTP server on an ephemeral loopback
|
||||
port. Neither needs the desktop app, a browser, a network, or credentials.
|
||||
@@ -0,0 +1,363 @@
|
||||
{
|
||||
"source": "src-tauri/src/api_server.rs",
|
||||
"regenerate_with": "python3 sdk/tools/extract-api-paths.py",
|
||||
"description": "Every operation the desktop app publishes in its /openapi.json. The SDK test suites assert this list and their own coverage tables match exactly, so an endpoint added to the app fails the SDK tests until it is either wrapped or deliberately listed as omitted.",
|
||||
"operation_count": 71,
|
||||
"operations": [
|
||||
{
|
||||
"operation_id": "download_browser_api",
|
||||
"method": "POST",
|
||||
"path": "/v1/browsers/download"
|
||||
},
|
||||
{
|
||||
"operation_id": "get_browser_versions",
|
||||
"method": "GET",
|
||||
"path": "/v1/browsers/{browser}/versions"
|
||||
},
|
||||
{
|
||||
"operation_id": "check_browser_downloaded",
|
||||
"method": "GET",
|
||||
"path": "/v1/browsers/{browser}/versions/{version}/downloaded"
|
||||
},
|
||||
{
|
||||
"operation_id": "get_cookie_bot_conflicts",
|
||||
"method": "GET",
|
||||
"path": "/v1/cookie-bot/conflicts"
|
||||
},
|
||||
{
|
||||
"operation_id": "list_cookie_bot_presets",
|
||||
"method": "GET",
|
||||
"path": "/v1/cookie-bot/presets"
|
||||
},
|
||||
{
|
||||
"operation_id": "list_cookie_bot_runs",
|
||||
"method": "GET",
|
||||
"path": "/v1/cookie-bot/runs"
|
||||
},
|
||||
{
|
||||
"operation_id": "start_cookie_bot_run",
|
||||
"method": "POST",
|
||||
"path": "/v1/cookie-bot/runs"
|
||||
},
|
||||
{
|
||||
"operation_id": "cancel_cookie_bot_run",
|
||||
"method": "DELETE",
|
||||
"path": "/v1/cookie-bot/runs/{run_id}"
|
||||
},
|
||||
{
|
||||
"operation_id": "list_cookie_bot_schedules",
|
||||
"method": "GET",
|
||||
"path": "/v1/cookie-bot/schedules"
|
||||
},
|
||||
{
|
||||
"operation_id": "delete_cookie_bot_schedule",
|
||||
"method": "DELETE",
|
||||
"path": "/v1/cookie-bot/schedules/{profile_id}"
|
||||
},
|
||||
{
|
||||
"operation_id": "get_cookie_bot_schedule",
|
||||
"method": "GET",
|
||||
"path": "/v1/cookie-bot/schedules/{profile_id}"
|
||||
},
|
||||
{
|
||||
"operation_id": "set_cookie_bot_schedule",
|
||||
"method": "PUT",
|
||||
"path": "/v1/cookie-bot/schedules/{profile_id}"
|
||||
},
|
||||
{
|
||||
"operation_id": "get_cookie_bot_usage",
|
||||
"method": "GET",
|
||||
"path": "/v1/cookie-bot/usage"
|
||||
},
|
||||
{
|
||||
"operation_id": "get_extension_groups",
|
||||
"method": "GET",
|
||||
"path": "/v1/extension-groups"
|
||||
},
|
||||
{
|
||||
"operation_id": "create_extension_group_api",
|
||||
"method": "POST",
|
||||
"path": "/v1/extension-groups"
|
||||
},
|
||||
{
|
||||
"operation_id": "delete_extension_group_api",
|
||||
"method": "DELETE",
|
||||
"path": "/v1/extension-groups/{id}"
|
||||
},
|
||||
{
|
||||
"operation_id": "get_extension_group_api",
|
||||
"method": "GET",
|
||||
"path": "/v1/extension-groups/{id}"
|
||||
},
|
||||
{
|
||||
"operation_id": "update_extension_group_api",
|
||||
"method": "PUT",
|
||||
"path": "/v1/extension-groups/{id}"
|
||||
},
|
||||
{
|
||||
"operation_id": "remove_extension_from_group_api",
|
||||
"method": "DELETE",
|
||||
"path": "/v1/extension-groups/{id}/extensions/{extension_id}"
|
||||
},
|
||||
{
|
||||
"operation_id": "add_extension_to_group_api",
|
||||
"method": "POST",
|
||||
"path": "/v1/extension-groups/{id}/extensions/{extension_id}"
|
||||
},
|
||||
{
|
||||
"operation_id": "get_extensions",
|
||||
"method": "GET",
|
||||
"path": "/v1/extensions"
|
||||
},
|
||||
{
|
||||
"operation_id": "create_extension_api",
|
||||
"method": "POST",
|
||||
"path": "/v1/extensions"
|
||||
},
|
||||
{
|
||||
"operation_id": "delete_extension_api",
|
||||
"method": "DELETE",
|
||||
"path": "/v1/extensions/{id}"
|
||||
},
|
||||
{
|
||||
"operation_id": "get_extension_api",
|
||||
"method": "GET",
|
||||
"path": "/v1/extensions/{id}"
|
||||
},
|
||||
{
|
||||
"operation_id": "update_extension_api",
|
||||
"method": "PUT",
|
||||
"path": "/v1/extensions/{id}"
|
||||
},
|
||||
{
|
||||
"operation_id": "get_groups",
|
||||
"method": "GET",
|
||||
"path": "/v1/groups"
|
||||
},
|
||||
{
|
||||
"operation_id": "create_group",
|
||||
"method": "POST",
|
||||
"path": "/v1/groups"
|
||||
},
|
||||
{
|
||||
"operation_id": "delete_group",
|
||||
"method": "DELETE",
|
||||
"path": "/v1/groups/{id}"
|
||||
},
|
||||
{
|
||||
"operation_id": "get_group",
|
||||
"method": "GET",
|
||||
"path": "/v1/groups/{id}"
|
||||
},
|
||||
{
|
||||
"operation_id": "update_group",
|
||||
"method": "PUT",
|
||||
"path": "/v1/groups/{id}"
|
||||
},
|
||||
{
|
||||
"operation_id": "get_profiles",
|
||||
"method": "GET",
|
||||
"path": "/v1/profiles"
|
||||
},
|
||||
{
|
||||
"operation_id": "create_profile",
|
||||
"method": "POST",
|
||||
"path": "/v1/profiles"
|
||||
},
|
||||
{
|
||||
"operation_id": "batch_run_profiles",
|
||||
"method": "POST",
|
||||
"path": "/v1/profiles/batch/run"
|
||||
},
|
||||
{
|
||||
"operation_id": "batch_stop_profiles",
|
||||
"method": "POST",
|
||||
"path": "/v1/profiles/batch/stop"
|
||||
},
|
||||
{
|
||||
"operation_id": "distribute_proxies",
|
||||
"method": "POST",
|
||||
"path": "/v1/profiles/distribute-proxies"
|
||||
},
|
||||
{
|
||||
"operation_id": "import_profiles_api",
|
||||
"method": "POST",
|
||||
"path": "/v1/profiles/import"
|
||||
},
|
||||
{
|
||||
"operation_id": "detect_import_profiles",
|
||||
"method": "GET",
|
||||
"path": "/v1/profiles/import/detect"
|
||||
},
|
||||
{
|
||||
"operation_id": "delete_profile",
|
||||
"method": "DELETE",
|
||||
"path": "/v1/profiles/{id}"
|
||||
},
|
||||
{
|
||||
"operation_id": "get_profile",
|
||||
"method": "GET",
|
||||
"path": "/v1/profiles/{id}"
|
||||
},
|
||||
{
|
||||
"operation_id": "update_profile",
|
||||
"method": "PUT",
|
||||
"path": "/v1/profiles/{id}"
|
||||
},
|
||||
{
|
||||
"operation_id": "agent_click_api",
|
||||
"method": "POST",
|
||||
"path": "/v1/profiles/{id}/agent/click"
|
||||
},
|
||||
{
|
||||
"operation_id": "agent_extract_api",
|
||||
"method": "POST",
|
||||
"path": "/v1/profiles/{id}/agent/extract"
|
||||
},
|
||||
{
|
||||
"operation_id": "agent_perceive_api",
|
||||
"method": "POST",
|
||||
"path": "/v1/profiles/{id}/agent/perceive"
|
||||
},
|
||||
{
|
||||
"operation_id": "agent_pick_api",
|
||||
"method": "POST",
|
||||
"path": "/v1/profiles/{id}/agent/pick"
|
||||
},
|
||||
{
|
||||
"operation_id": "agent_resolve_locator_api",
|
||||
"method": "POST",
|
||||
"path": "/v1/profiles/{id}/agent/resolve-locator"
|
||||
},
|
||||
{
|
||||
"operation_id": "agent_type_api",
|
||||
"method": "POST",
|
||||
"path": "/v1/profiles/{id}/agent/type"
|
||||
},
|
||||
{
|
||||
"operation_id": "set_profile_cloud_sync",
|
||||
"method": "POST",
|
||||
"path": "/v1/profiles/{id}/cloud-sync"
|
||||
},
|
||||
{
|
||||
"operation_id": "import_profile_cookies",
|
||||
"method": "POST",
|
||||
"path": "/v1/profiles/{id}/cookies/import"
|
||||
},
|
||||
{
|
||||
"operation_id": "kill_profile",
|
||||
"method": "POST",
|
||||
"path": "/v1/profiles/{id}/kill"
|
||||
},
|
||||
{
|
||||
"operation_id": "open_url_in_profile",
|
||||
"method": "POST",
|
||||
"path": "/v1/profiles/{id}/open-url"
|
||||
},
|
||||
{
|
||||
"operation_id": "run_profile",
|
||||
"method": "POST",
|
||||
"path": "/v1/profiles/{id}/run"
|
||||
},
|
||||
{
|
||||
"operation_id": "run_profile_remote",
|
||||
"method": "POST",
|
||||
"path": "/v1/profiles/{id}/run-remote"
|
||||
},
|
||||
{
|
||||
"operation_id": "get_proxies",
|
||||
"method": "GET",
|
||||
"path": "/v1/proxies"
|
||||
},
|
||||
{
|
||||
"operation_id": "create_proxy",
|
||||
"method": "POST",
|
||||
"path": "/v1/proxies"
|
||||
},
|
||||
{
|
||||
"operation_id": "import_proxies_api",
|
||||
"method": "POST",
|
||||
"path": "/v1/proxies/import"
|
||||
},
|
||||
{
|
||||
"operation_id": "delete_proxy",
|
||||
"method": "DELETE",
|
||||
"path": "/v1/proxies/{id}"
|
||||
},
|
||||
{
|
||||
"operation_id": "get_proxy",
|
||||
"method": "GET",
|
||||
"path": "/v1/proxies/{id}"
|
||||
},
|
||||
{
|
||||
"operation_id": "update_proxy",
|
||||
"method": "PUT",
|
||||
"path": "/v1/proxies/{id}"
|
||||
},
|
||||
{
|
||||
"operation_id": "get_remote_hours",
|
||||
"method": "GET",
|
||||
"path": "/v1/remote-hours"
|
||||
},
|
||||
{
|
||||
"operation_id": "list_remote_sessions_api",
|
||||
"method": "GET",
|
||||
"path": "/v1/remote-sessions"
|
||||
},
|
||||
{
|
||||
"operation_id": "stop_remote_session",
|
||||
"method": "DELETE",
|
||||
"path": "/v1/remote-sessions/{id}"
|
||||
},
|
||||
{
|
||||
"operation_id": "get_remote_session_api",
|
||||
"method": "GET",
|
||||
"path": "/v1/remote-sessions/{id}"
|
||||
},
|
||||
{
|
||||
"operation_id": "remote_session_cdp",
|
||||
"method": "GET",
|
||||
"path": "/v1/remote-sessions/{id}/cdp"
|
||||
},
|
||||
{
|
||||
"operation_id": "get_tags",
|
||||
"method": "GET",
|
||||
"path": "/v1/tags"
|
||||
},
|
||||
{
|
||||
"operation_id": "get_vpns",
|
||||
"method": "GET",
|
||||
"path": "/v1/vpns"
|
||||
},
|
||||
{
|
||||
"operation_id": "create_vpn",
|
||||
"method": "POST",
|
||||
"path": "/v1/vpns"
|
||||
},
|
||||
{
|
||||
"operation_id": "import_vpn",
|
||||
"method": "POST",
|
||||
"path": "/v1/vpns/import"
|
||||
},
|
||||
{
|
||||
"operation_id": "delete_vpn",
|
||||
"method": "DELETE",
|
||||
"path": "/v1/vpns/{id}"
|
||||
},
|
||||
{
|
||||
"operation_id": "get_vpn",
|
||||
"method": "GET",
|
||||
"path": "/v1/vpns/{id}"
|
||||
},
|
||||
{
|
||||
"operation_id": "update_vpn",
|
||||
"method": "PUT",
|
||||
"path": "/v1/vpns/{id}"
|
||||
},
|
||||
{
|
||||
"operation_id": "export_vpn",
|
||||
"method": "GET",
|
||||
"path": "/v1/vpns/{id}/export"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"name": "@donutbrowser/sdk",
|
||||
"version": "0.1.0",
|
||||
"description": "Thin client for the Donut Browser local REST API",
|
||||
"license": "AGPL-3.0",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.mts",
|
||||
"default": "./dist/index.mjs"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"README.md"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "node --test test/*.test.mts",
|
||||
"build": "tsc",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"prepublishOnly": "npm run build"
|
||||
},
|
||||
"keywords": [
|
||||
"donut-browser",
|
||||
"browser-automation",
|
||||
"anti-detect",
|
||||
"cdp"
|
||||
],
|
||||
"homepage": "https://donutbrowser.com",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/zhom/donutbrowser.git",
|
||||
"directory": "sdk/node"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.9.0"
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* Which app operation each client method wraps.
|
||||
*
|
||||
* This table is the SDK's half of a two-sided check. `sdk/api-paths.json` holds
|
||||
* every operation the desktop app publishes, generated from
|
||||
* `src-tauri/src/api_server.rs`. The test suite asserts the two agree exactly
|
||||
* in both directions, so:
|
||||
*
|
||||
* - an endpoint added to the app fails the SDK tests until it is wrapped here,
|
||||
* or listed in `OMITTED` with a reason, and
|
||||
* - an entry here that the app no longer publishes fails too.
|
||||
*
|
||||
* The same table is mirrored in the Python package, and the same snapshot
|
||||
* proves it.
|
||||
*/
|
||||
|
||||
/** `"<VERB> <path template>"`, exactly as the app publishes it. */
|
||||
export type OperationKey = string;
|
||||
|
||||
/** Operation to the name of the `DonutClient` method that calls it. */
|
||||
export const OPERATIONS: ReadonlyMap<OperationKey, string> = new Map([
|
||||
["POST /v1/browsers/download", "downloadBrowser"],
|
||||
["GET /v1/browsers/{browser}/versions", "listBrowserVersions"],
|
||||
["GET /v1/browsers/{browser}/versions/{version}/downloaded", "isBrowserDownloaded"],
|
||||
["GET /v1/cookie-bot/conflicts", "getCookieBotConflicts"],
|
||||
["GET /v1/cookie-bot/presets", "listCookieBotPresets"],
|
||||
["GET /v1/cookie-bot/runs", "listCookieBotRuns"],
|
||||
["POST /v1/cookie-bot/runs", "startCookieBotRun"],
|
||||
["DELETE /v1/cookie-bot/runs/{run_id}", "cancelCookieBotRun"],
|
||||
["GET /v1/cookie-bot/schedules", "listCookieBotSchedules"],
|
||||
["DELETE /v1/cookie-bot/schedules/{profile_id}", "deleteCookieBotSchedule"],
|
||||
["GET /v1/cookie-bot/schedules/{profile_id}", "getCookieBotSchedule"],
|
||||
["PUT /v1/cookie-bot/schedules/{profile_id}", "setCookieBotSchedule"],
|
||||
["GET /v1/cookie-bot/usage", "getCookieBotUsage"],
|
||||
["GET /v1/extension-groups", "listExtensionGroups"],
|
||||
["POST /v1/extension-groups", "createExtensionGroup"],
|
||||
["DELETE /v1/extension-groups/{id}", "deleteExtensionGroup"],
|
||||
["GET /v1/extension-groups/{id}", "getExtensionGroup"],
|
||||
["PUT /v1/extension-groups/{id}", "updateExtensionGroup"],
|
||||
["DELETE /v1/extension-groups/{id}/extensions/{extension_id}", "removeExtensionFromGroup"],
|
||||
["POST /v1/extension-groups/{id}/extensions/{extension_id}", "addExtensionToGroup"],
|
||||
["GET /v1/extensions", "listExtensions"],
|
||||
["POST /v1/extensions", "createExtension"],
|
||||
["DELETE /v1/extensions/{id}", "deleteExtension"],
|
||||
["GET /v1/extensions/{id}", "getExtension"],
|
||||
["PUT /v1/extensions/{id}", "updateExtension"],
|
||||
["GET /v1/groups", "listGroups"],
|
||||
["POST /v1/groups", "createGroup"],
|
||||
["DELETE /v1/groups/{id}", "deleteGroup"],
|
||||
["GET /v1/groups/{id}", "getGroup"],
|
||||
["PUT /v1/groups/{id}", "updateGroup"],
|
||||
["GET /v1/profiles", "listProfiles"],
|
||||
["POST /v1/profiles", "createProfile"],
|
||||
["POST /v1/profiles/batch/run", "batchRunProfiles"],
|
||||
["POST /v1/profiles/batch/stop", "batchStopProfiles"],
|
||||
["POST /v1/profiles/distribute-proxies", "distributeProxies"],
|
||||
["POST /v1/profiles/import", "importProfiles"],
|
||||
["GET /v1/profiles/import/detect", "detectImportProfiles"],
|
||||
["DELETE /v1/profiles/{id}", "deleteProfile"],
|
||||
["GET /v1/profiles/{id}", "getProfile"],
|
||||
["PUT /v1/profiles/{id}", "updateProfile"],
|
||||
["POST /v1/profiles/{id}/agent/click", "agentClick"],
|
||||
["POST /v1/profiles/{id}/agent/extract", "agentExtract"],
|
||||
["POST /v1/profiles/{id}/agent/perceive", "agentPerceive"],
|
||||
["POST /v1/profiles/{id}/agent/pick", "agentPick"],
|
||||
["POST /v1/profiles/{id}/agent/resolve-locator", "agentResolveLocator"],
|
||||
["POST /v1/profiles/{id}/agent/type", "agentType"],
|
||||
["POST /v1/profiles/{id}/cloud-sync", "setProfileCloudSync"],
|
||||
["POST /v1/profiles/{id}/cookies/import", "importProfileCookies"],
|
||||
["POST /v1/profiles/{id}/kill", "killProfile"],
|
||||
["POST /v1/profiles/{id}/open-url", "openUrl"],
|
||||
["POST /v1/profiles/{id}/run", "runProfile"],
|
||||
["POST /v1/profiles/{id}/run-remote", "runProfileRemote"],
|
||||
["GET /v1/proxies", "listProxies"],
|
||||
["POST /v1/proxies", "createProxy"],
|
||||
["POST /v1/proxies/import", "importProxies"],
|
||||
["DELETE /v1/proxies/{id}", "deleteProxy"],
|
||||
["GET /v1/proxies/{id}", "getProxy"],
|
||||
["PUT /v1/proxies/{id}", "updateProxy"],
|
||||
["GET /v1/remote-hours", "getRemoteHours"],
|
||||
["GET /v1/remote-sessions", "listRemoteSessions"],
|
||||
["DELETE /v1/remote-sessions/{id}", "stopRemoteSession"],
|
||||
["GET /v1/remote-sessions/{id}", "getRemoteSession"],
|
||||
["GET /v1/tags", "listTags"],
|
||||
["GET /v1/vpns", "listVpns"],
|
||||
["POST /v1/vpns", "createVpn"],
|
||||
["POST /v1/vpns/import", "importVpn"],
|
||||
["DELETE /v1/vpns/{id}", "deleteVpn"],
|
||||
["GET /v1/vpns/{id}", "getVpn"],
|
||||
["PUT /v1/vpns/{id}", "updateVpn"],
|
||||
["GET /v1/vpns/{id}/export", "exportVpn"],
|
||||
]);
|
||||
|
||||
/** Operations this SDK deliberately does not call, and why. */
|
||||
export const OMITTED: ReadonlyMap<OperationKey, string> = new Map([
|
||||
[
|
||||
"GET /v1/remote-sessions/{id}/cdp",
|
||||
"A WebSocket upgrade, not a request. fetch() cannot speak it, and bundling a " +
|
||||
"websocket implementation would end this package's zero-dependency promise for " +
|
||||
"one endpoint. DonutClient.remoteSessionCdpUrl() builds the ws:// address so a " +
|
||||
"websocket library of the caller's choosing can connect, sending the same " +
|
||||
"Authorization: Bearer header on the handshake.",
|
||||
],
|
||||
]);
|
||||
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* Exceptions thrown by the Donut Browser SDK.
|
||||
*
|
||||
* The local REST API answers with a plain-text body and one of a small set of
|
||||
* statuses. Each status means one thing, so each gets its own class and a
|
||||
* caller can branch on `instanceof` instead of on a number:
|
||||
*
|
||||
* | Status | Class | Meaning |
|
||||
* | -----: | --------------------- | ----------------------------------------- |
|
||||
* | 400 | `ValidationError` | Malformed request, duplicate name |
|
||||
* | 401 | `Unauthorized` | Missing or wrong bearer token |
|
||||
* | 402 | `PaymentRequired` | Automation needs an active paid plan |
|
||||
* | 403 | `Forbidden` | Terms not accepted, or not signed in |
|
||||
* | 404 | `NotFound` | No such profile, group, proxy, ... |
|
||||
* | 408 | `RequestTimeout` | `agent/pick` waited and nothing was picked |
|
||||
* | 409 | `Conflict` | Something else holds the profile |
|
||||
* | 429 | `RateLimited` | Quota spent; see `retryAfter` |
|
||||
* | 500 | `ServerError` | Internal failure |
|
||||
* | 502 | `BadGateway` | The browser or relay answered wrongly |
|
||||
* | 503 | `ServiceUnavailable` | Cloud, fleet or lock service unreachable |
|
||||
*
|
||||
* Some bodies are the structured `{"code": ..., "params": {...}}` strings the
|
||||
* desktop app shares with its own frontend. When one arrives, `code` and
|
||||
* `params` are filled in; otherwise `code` is `null` and `body` holds the
|
||||
* diagnostic text as sent.
|
||||
*/
|
||||
|
||||
/** Base class for everything this package throws. */
|
||||
export class DonutError extends Error {
|
||||
constructor(message: string, options?: ErrorOptions) {
|
||||
super(message, options);
|
||||
this.name = new.target.name;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The app could not be reached at all.
|
||||
*
|
||||
* Usually means the local API is switched off, is listening on another port,
|
||||
* or the desktop app is not running.
|
||||
*/
|
||||
export class DonutConnectionError extends DonutError {}
|
||||
|
||||
export interface DonutApiErrorInit {
|
||||
method?: string;
|
||||
path?: string;
|
||||
headers?: Headers | Record<string, string>;
|
||||
}
|
||||
|
||||
/** The app answered, and the answer was an error status. */
|
||||
export class DonutApiError extends DonutError {
|
||||
status: number;
|
||||
body: string;
|
||||
method: string;
|
||||
path: string;
|
||||
headers: Record<string, string>;
|
||||
/** The `code` of a structured `{"code": ...}` body, else `null`. */
|
||||
code: string | null;
|
||||
/** The `params` of a structured body, else an empty object. */
|
||||
params: Record<string, unknown>;
|
||||
|
||||
constructor(status: number, body: string, init: DonutApiErrorInit = {}) {
|
||||
const method = init.method ?? "";
|
||||
const path = init.path ?? "";
|
||||
const headers = normaliseHeaders(init.headers);
|
||||
|
||||
let code: string | null = null;
|
||||
let params: Record<string, unknown> = {};
|
||||
const trimmed = body.trim();
|
||||
if (trimmed.startsWith("{")) {
|
||||
try {
|
||||
const decoded: unknown = JSON.parse(trimmed);
|
||||
if (decoded !== null && typeof decoded === "object") {
|
||||
const record = decoded as Record<string, unknown>;
|
||||
if (typeof record.code === "string") {
|
||||
code = record.code;
|
||||
if (record.params !== null && typeof record.params === "object") {
|
||||
params = record.params as Record<string, unknown>;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Not JSON after all; the plain text below is the whole story.
|
||||
}
|
||||
}
|
||||
|
||||
const where = `${method} ${path}`.trim();
|
||||
const detail = code ?? (trimmed || "(empty body)");
|
||||
super(where ? `${status} on ${where}: ${detail}` : `${status}: ${detail}`);
|
||||
|
||||
this.status = status;
|
||||
this.body = body;
|
||||
this.method = method;
|
||||
this.path = path;
|
||||
this.headers = headers;
|
||||
this.code = code;
|
||||
this.params = params;
|
||||
}
|
||||
}
|
||||
|
||||
/** 400: the request was malformed, duplicated a name, or named something unsupported. */
|
||||
export class ValidationError extends DonutApiError {}
|
||||
|
||||
/** 401: no bearer token, the wrong one, or the local API has no token stored. */
|
||||
export class Unauthorized extends DonutApiError {}
|
||||
|
||||
/** 402: this action needs an active paid plan, or the proxy behind it lapsed. */
|
||||
export class PaymentRequired extends DonutApiError {}
|
||||
|
||||
/** 403: the Wayfern terms are not accepted, or this desktop is not signed in. */
|
||||
export class Forbidden extends DonutApiError {}
|
||||
|
||||
/** 404: no entity with that id. */
|
||||
export class NotFound extends DonutApiError {}
|
||||
|
||||
/** 408: `agentPick` waited its whole timeout and nothing was picked. */
|
||||
export class RequestTimeout extends DonutApiError {}
|
||||
|
||||
/** 409: something else holds the profile — a browser, a teammate, a remote session. */
|
||||
export class Conflict extends DonutApiError {}
|
||||
|
||||
/**
|
||||
* 500 and the other 5xx: the app, the fleet or an upstream failed.
|
||||
*
|
||||
* `BadGateway` and `ServiceUnavailable` extend this, so one
|
||||
* `instanceof ServerError` covers every server-side failure.
|
||||
*/
|
||||
export class ServerError extends DonutApiError {}
|
||||
|
||||
/** 502: the browser or the relay did not answer the way it documents. */
|
||||
export class BadGateway extends ServerError {}
|
||||
|
||||
/**
|
||||
* 503: Donut cloud, the remote fleet, or the profile lock service is unreachable.
|
||||
*
|
||||
* Whatever was running keeps running: a 503 from `killProfile` or from stopping
|
||||
* a remote session means the browser is still up, not that it stopped.
|
||||
*/
|
||||
export class ServiceUnavailable extends ServerError {}
|
||||
|
||||
/**
|
||||
* 429: the shared automation quota is spent.
|
||||
*
|
||||
* `retryAfter` is the number of seconds the server asked the caller to wait,
|
||||
* taken from the `Retry-After` response header. It is `null` only when the
|
||||
* header is missing or unreadable.
|
||||
*/
|
||||
export class RateLimited extends DonutApiError {
|
||||
retryAfter: number | null;
|
||||
|
||||
constructor(status: number, body: string, init: DonutApiErrorInit = {}) {
|
||||
super(status, body, init);
|
||||
const raw = this.headers["retry-after"];
|
||||
const seconds = raw === undefined ? Number.NaN : Number.parseInt(raw.trim(), 10);
|
||||
this.retryAfter = Number.isFinite(seconds) ? seconds : null;
|
||||
}
|
||||
}
|
||||
|
||||
function normaliseHeaders(
|
||||
headers: Headers | Record<string, string> | undefined,
|
||||
): Record<string, string> {
|
||||
const result: Record<string, string> = {};
|
||||
if (headers === undefined) {
|
||||
return result;
|
||||
}
|
||||
if (typeof (headers as Headers).forEach === "function" && !Array.isArray(headers)) {
|
||||
(headers as Headers).forEach((value, key) => {
|
||||
result[key.toLowerCase()] = value;
|
||||
});
|
||||
return result;
|
||||
}
|
||||
for (const [key, value] of Object.entries(headers as Record<string, string>)) {
|
||||
result[key.toLowerCase()] = value;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const BY_STATUS = new Map<number, typeof DonutApiError>([
|
||||
[400, ValidationError],
|
||||
[401, Unauthorized],
|
||||
[402, PaymentRequired],
|
||||
[403, Forbidden],
|
||||
[404, NotFound],
|
||||
[408, RequestTimeout],
|
||||
[409, Conflict],
|
||||
[429, RateLimited],
|
||||
[500, ServerError],
|
||||
[502, BadGateway],
|
||||
[503, ServiceUnavailable],
|
||||
]);
|
||||
|
||||
/**
|
||||
* Build the error that belongs to `status`.
|
||||
*
|
||||
* A status with no class of its own becomes a plain `DonutApiError`, so a
|
||||
* future status added to the app still throws something a caller can catch
|
||||
* rather than escaping as a decode failure.
|
||||
*/
|
||||
export function errorForStatus(
|
||||
status: number,
|
||||
body: string,
|
||||
init: DonutApiErrorInit = {},
|
||||
): DonutApiError {
|
||||
const known = BY_STATUS.get(status);
|
||||
if (known !== undefined) {
|
||||
return new known(status, body, init);
|
||||
}
|
||||
return status >= 500
|
||||
? new ServerError(status, body, init)
|
||||
: new DonutApiError(status, body, init);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Donut Browser SDK: a thin client for the app's local REST API.
|
||||
*
|
||||
* The local API is off by default. Switch it on in the app under **Settings,
|
||||
* Integrations, Local API, "Enable Local API Server"**, and copy the port and
|
||||
* the authentication token from that screen.
|
||||
*
|
||||
* ```ts
|
||||
* import { DonutClient } from "@donutbrowser/sdk";
|
||||
*
|
||||
* const client = new DonutClient({ token: "..." });
|
||||
* await client.withProfile(profileId, { url: "https://example.com" }, async (session) => {
|
||||
* console.log(session.cdpUrl);
|
||||
* await client.agentClick(profileId, { locator: { role: "button", name: "Sign in" } });
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
|
||||
export { DEFAULT_HOST, DEFAULT_PORT, DonutClient, RunSession } from "./client.mts";
|
||||
export type { DonutClientOptions, RunProfileOptions } from "./client.mts";
|
||||
export { OMITTED, OPERATIONS } from "./coverage.mts";
|
||||
export type { OperationKey } from "./coverage.mts";
|
||||
export {
|
||||
BadGateway,
|
||||
Conflict,
|
||||
DonutApiError,
|
||||
DonutConnectionError,
|
||||
DonutError,
|
||||
errorForStatus,
|
||||
Forbidden,
|
||||
NotFound,
|
||||
PaymentRequired,
|
||||
RateLimited,
|
||||
RequestTimeout,
|
||||
ServerError,
|
||||
ServiceUnavailable,
|
||||
Unauthorized,
|
||||
ValidationError,
|
||||
} from "./errors.mts";
|
||||
export type { DonutApiErrorInit } from "./errors.mts";
|
||||
export type * from "./types.mts";
|
||||
@@ -0,0 +1,634 @@
|
||||
/**
|
||||
* Response shapes, spelled exactly the way the local API sends them.
|
||||
*
|
||||
* Every interface here mirrors a `ToSchema` struct in `src-tauri` field for
|
||||
* field. A Rust `Option<T>` becomes an optional property.
|
||||
*
|
||||
* Two spellings live side by side because the app sends both. Most bodies are
|
||||
* snake_case; the browser-facing agent types (`LocatorDescription`,
|
||||
* `LocatorCandidate`, `PerceptionPage` and friends) carry the browser's own
|
||||
* camelCase, because they are handed through from the browser rather than
|
||||
* restated. `AgentClick` and `AgentTyping` are the exceptions inside the agent
|
||||
* surface: they are snake_case with a single `match` key. These types follow
|
||||
* the wire rather than tidying it, so a value read from one call can be passed
|
||||
* straight into the next.
|
||||
*/
|
||||
|
||||
/** The app's own JSON for a proxy's settings, declared `Object` in the spec. */
|
||||
export type ProxySettings = Record<string, unknown>;
|
||||
|
||||
/** A Wayfern fingerprint/config blob, also declared `Object` in the spec. */
|
||||
export type WayfernConfig = Record<string, unknown>;
|
||||
|
||||
/** Which implementation answered: the browser's native domains, or the fallback. */
|
||||
export type Engine = "wayfern" | "fallback";
|
||||
|
||||
export interface ApiProfile {
|
||||
id: string;
|
||||
name: string;
|
||||
browser: string;
|
||||
version: string;
|
||||
proxy_id?: string | null;
|
||||
launch_hook?: string | null;
|
||||
process_id?: number | null;
|
||||
last_launch?: number | null;
|
||||
release_type: string;
|
||||
group_id?: string | null;
|
||||
tags: string[];
|
||||
is_running: boolean;
|
||||
proxy_bypass_rules: string[];
|
||||
vpn_id?: string | null;
|
||||
extension_group_id?: string | null;
|
||||
ephemeral: boolean;
|
||||
temporary: boolean;
|
||||
clear_on_close: boolean;
|
||||
/** `"Disabled"`, `"Regular"` or `"Encrypted"`. */
|
||||
sync_mode: string;
|
||||
cloud_sync_enabled: boolean;
|
||||
host_os?: string | null;
|
||||
/** A profile from another OS can only ever run on a remote host of that OS. */
|
||||
is_cross_os: boolean;
|
||||
fingerprint_os?: string | null;
|
||||
}
|
||||
|
||||
export interface ApiProfilesResponse {
|
||||
profiles: ApiProfile[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface ApiProfileResponse {
|
||||
profile: ApiProfile;
|
||||
}
|
||||
|
||||
export interface ApiGroupResponse {
|
||||
id: string;
|
||||
name: string;
|
||||
profile_count: number;
|
||||
}
|
||||
|
||||
export interface ApiProxyResponse {
|
||||
id: string;
|
||||
name: string;
|
||||
proxy_settings: ProxySettings;
|
||||
}
|
||||
|
||||
export interface ApiVpnResponse {
|
||||
id: string;
|
||||
name: string;
|
||||
/** Always `"WireGuard"`. */
|
||||
vpn_type: string;
|
||||
created_at: number;
|
||||
last_used?: number | null;
|
||||
}
|
||||
|
||||
export interface ApiVpnExportResponse {
|
||||
id: string;
|
||||
name: string;
|
||||
vpn_type: string;
|
||||
/** Raw, decrypted `.conf` content. Treat it as a secret. */
|
||||
config_data: string;
|
||||
}
|
||||
|
||||
export interface DownloadBrowserResponse {
|
||||
browser: string;
|
||||
version: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface RunProfileResponse {
|
||||
profile_id: string;
|
||||
remote_debugging_port: number;
|
||||
headless: boolean;
|
||||
}
|
||||
|
||||
export interface RunRemoteResponse {
|
||||
profile_id: string;
|
||||
session_id: string;
|
||||
/** Always the profile's own operating system. */
|
||||
platform: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface StopRemoteResponse {
|
||||
session_id: string;
|
||||
status: string;
|
||||
billed_seconds: number;
|
||||
}
|
||||
|
||||
export interface SetCloudSyncResponse {
|
||||
profile_id: string;
|
||||
mode: string;
|
||||
remote_launchable: boolean;
|
||||
remote_blocked_reason?: string | null;
|
||||
}
|
||||
|
||||
export interface RemoteSessionState {
|
||||
session_id: string;
|
||||
profile_id?: string | null;
|
||||
platform?: string | null;
|
||||
/** `provisioning` | `ready` | `live` | `closed` | `error`. */
|
||||
state: string;
|
||||
cdp_ready?: boolean;
|
||||
/** `interactive` or `cookie_bot`. */
|
||||
kind?: string | null;
|
||||
run_id?: string | null;
|
||||
team_id?: string | null;
|
||||
started_at?: string | null;
|
||||
ended_at?: string | null;
|
||||
close_reason?: string | null;
|
||||
billed_seconds?: number | null;
|
||||
}
|
||||
|
||||
export interface ApiRemoteSessionsResponse {
|
||||
sessions: RemoteSessionState[];
|
||||
}
|
||||
|
||||
export interface RemoteHoursBreakdown {
|
||||
interactive_hours?: number;
|
||||
bot_hours?: number;
|
||||
}
|
||||
|
||||
export interface RemoteHoursMember {
|
||||
user_id: string;
|
||||
email: string;
|
||||
role?: string | null;
|
||||
used_hours?: number;
|
||||
interactive_hours?: number;
|
||||
bot_hours?: number;
|
||||
}
|
||||
|
||||
export interface RemoteHoursQuota {
|
||||
granted_hours: number;
|
||||
remaining_hours: number;
|
||||
used_hours?: number;
|
||||
period_start?: string | null;
|
||||
period_end?: string | null;
|
||||
/** `user` or `team`. */
|
||||
scope?: string | null;
|
||||
team_id?: string | null;
|
||||
seats?: number;
|
||||
per_seat_hours?: number;
|
||||
breakdown?: RemoteHoursBreakdown | null;
|
||||
members?: RemoteHoursMember[];
|
||||
}
|
||||
|
||||
export interface CookieBotSlot {
|
||||
run_at_minute?: number;
|
||||
days_mask?: number;
|
||||
}
|
||||
|
||||
export interface CookieBotSchedule {
|
||||
profile_id: string;
|
||||
profile_name: string;
|
||||
platform: string;
|
||||
enabled: boolean;
|
||||
run_at_minute: number;
|
||||
days_mask: number;
|
||||
/**
|
||||
* Every time-of-day this enrolment fires. An older server sends only the
|
||||
* mirrored `run_at_minute`/`days_mask` pair above, so an empty list means
|
||||
* "fall back to the pair", never "fires at no time".
|
||||
*/
|
||||
slots?: CookieBotSlot[];
|
||||
timezone: string;
|
||||
preset: string;
|
||||
template_id?: string | null;
|
||||
max_minutes: number;
|
||||
sites?: string[];
|
||||
jitter_seconds?: number;
|
||||
sync_enabled?: boolean;
|
||||
encrypted_sync?: boolean;
|
||||
has_proxy?: boolean;
|
||||
proxy_remote_reachable?: boolean;
|
||||
touch_fingerprint?: boolean;
|
||||
sticky_exit?: boolean;
|
||||
profile_state_at?: string | null;
|
||||
/** Why tonight would be refused, or absent. */
|
||||
blocked_by?: string | null;
|
||||
next_run_at?: string | null;
|
||||
last_run_at?: string | null;
|
||||
last_run_id?: string | null;
|
||||
owner_user_id?: string | null;
|
||||
owner_email?: string | null;
|
||||
updated_at?: string | null;
|
||||
}
|
||||
|
||||
export interface CookieBotScheduleList {
|
||||
schedules?: CookieBotSchedule[];
|
||||
team_id?: string | null;
|
||||
scope?: string | null;
|
||||
}
|
||||
|
||||
export interface CookieBotConflict {
|
||||
user_id: string;
|
||||
email: string;
|
||||
run_at_minute: number;
|
||||
timezone: string;
|
||||
days_mask: number;
|
||||
enabled: boolean;
|
||||
overlaps?: boolean;
|
||||
}
|
||||
|
||||
export interface CookieBotScheduleSaved {
|
||||
schedule: CookieBotSchedule;
|
||||
conflicts?: CookieBotConflict[];
|
||||
}
|
||||
|
||||
export interface CookieBotConflictCheck {
|
||||
profile_id: string;
|
||||
conflicts?: CookieBotConflict[];
|
||||
}
|
||||
|
||||
export interface CookieBotScheduleDeleted {
|
||||
profile_id: string;
|
||||
deleted: boolean;
|
||||
}
|
||||
|
||||
export interface CookieBotRun {
|
||||
id: string;
|
||||
profile_id: string;
|
||||
profile_name?: string | null;
|
||||
user_id?: string | null;
|
||||
email?: string | null;
|
||||
team_id?: string | null;
|
||||
/** `schedule` or `manual`. */
|
||||
trigger: string;
|
||||
/** `pending` | `running` | `succeeded` | `partial` | `failed` | `skipped` | `cancelled`. */
|
||||
status: string;
|
||||
scheduled_for: string;
|
||||
dispatch_after?: string | null;
|
||||
started_at?: string | null;
|
||||
ended_at?: string | null;
|
||||
max_minutes?: number;
|
||||
chunks_total?: number;
|
||||
chunk_index?: number;
|
||||
sites_total?: number;
|
||||
sites_visited?: number;
|
||||
sites_failed?: number;
|
||||
consent_dismissed?: number;
|
||||
billed_seconds?: number;
|
||||
outcome_code?: string | null;
|
||||
session_id?: string | null;
|
||||
}
|
||||
|
||||
export interface CookieBotRunPage {
|
||||
runs?: CookieBotRun[];
|
||||
/** Keyset cursor; absent on the last page. */
|
||||
next_before?: string | null;
|
||||
}
|
||||
|
||||
export interface CookieBotRunStarted {
|
||||
run: CookieBotRun;
|
||||
session_id?: string | null;
|
||||
}
|
||||
|
||||
export interface CookieBotPreset {
|
||||
id: string;
|
||||
typical_minutes?: number | null;
|
||||
recommended?: boolean;
|
||||
name?: string | null;
|
||||
description?: string | null;
|
||||
}
|
||||
|
||||
export interface CookieBotPresetList {
|
||||
presets?: CookieBotPreset[];
|
||||
default_preset?: string | null;
|
||||
/** Whatever the server publishes; the app forwards it without narrowing. */
|
||||
templates?: Record<string, unknown>[];
|
||||
limits?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export interface CookieBotUsageMember {
|
||||
user_id: string;
|
||||
email: string;
|
||||
role?: string | null;
|
||||
interactive_hours?: number;
|
||||
bot_hours?: number;
|
||||
used_hours?: number;
|
||||
sessions?: number;
|
||||
bot_runs?: number;
|
||||
bot_runs_failed?: number;
|
||||
}
|
||||
|
||||
export interface CookieBotUsageProfile {
|
||||
profile_id: string;
|
||||
profile_name?: string | null;
|
||||
owner_email?: string | null;
|
||||
bot_hours?: number;
|
||||
runs?: number;
|
||||
runs_failed?: number;
|
||||
last_run_at?: string | null;
|
||||
last_status?: string | null;
|
||||
}
|
||||
|
||||
export interface CookieBotUsage {
|
||||
period: string;
|
||||
period_start?: string | null;
|
||||
period_end?: string | null;
|
||||
team_id?: string | null;
|
||||
seats?: number;
|
||||
granted_hours?: number;
|
||||
used_hours?: number;
|
||||
remaining_hours?: number;
|
||||
members?: CookieBotUsageMember[];
|
||||
profiles?: CookieBotUsageProfile[];
|
||||
}
|
||||
|
||||
export interface BatchRunResult {
|
||||
profile_id: string;
|
||||
ok: boolean;
|
||||
remote_debugging_port?: number | null;
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
export interface BatchRunResponse {
|
||||
results: BatchRunResult[];
|
||||
}
|
||||
|
||||
export interface BatchStopResult {
|
||||
profile_id: string;
|
||||
ok: boolean;
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
export interface BatchStopResponse {
|
||||
results: BatchStopResult[];
|
||||
}
|
||||
|
||||
/** One profile, one proxy. The distribution applies exactly these pairs. */
|
||||
export interface ProxyPair {
|
||||
profile_id: string;
|
||||
proxy_id: string;
|
||||
}
|
||||
|
||||
export interface ProxyAssignmentResult {
|
||||
profile_id: string;
|
||||
proxy_id: string;
|
||||
ok: boolean;
|
||||
/** A `{"code": ...}` payload when `ok` is false, otherwise null. */
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
export interface DistributeProxiesResponse {
|
||||
results: ProxyAssignmentResult[];
|
||||
}
|
||||
|
||||
export interface ImportCookiesResponse {
|
||||
cookies_imported: number;
|
||||
cookies_replaced: number;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
export interface ImportProxiesResponse {
|
||||
imported_count: number;
|
||||
skipped_count: number;
|
||||
errors: string[];
|
||||
proxies: ApiProxyResponse[];
|
||||
}
|
||||
|
||||
export interface DetectedProfile {
|
||||
browser: string;
|
||||
mapped_browser: string;
|
||||
name: string;
|
||||
path: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface DetectedProfilesResponse {
|
||||
profiles: DetectedProfile[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface ImportProfileItem {
|
||||
source_path: string;
|
||||
/**
|
||||
* The source browser family (`chromium`, `brave`, `edge`, ...). Load-bearing:
|
||||
* it picks which keychain entry unlocks the source's cookies and passwords.
|
||||
*/
|
||||
browser_type?: string;
|
||||
new_profile_name: string;
|
||||
proxy_id?: string | null;
|
||||
vpn_id?: string | null;
|
||||
allow_running?: boolean | null;
|
||||
}
|
||||
|
||||
export interface ProfileImportItemResult {
|
||||
name: string;
|
||||
source_path: string;
|
||||
/** `"imported"` | `"skipped"` | `"failed"`. */
|
||||
status: string;
|
||||
profile_id?: string | null;
|
||||
error?: string | null;
|
||||
report?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export interface ProfileImportBatchResult {
|
||||
imported_count: number;
|
||||
skipped_count: number;
|
||||
failed_count: number;
|
||||
results: ProfileImportItemResult[];
|
||||
}
|
||||
|
||||
export interface Extension {
|
||||
id: string;
|
||||
name: string;
|
||||
manifest_name?: string | null;
|
||||
file_name: string;
|
||||
file_type: string;
|
||||
browser_compatibility: string[];
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
sync_enabled?: boolean;
|
||||
last_sync?: number | null;
|
||||
version?: string | null;
|
||||
description?: string | null;
|
||||
author?: string | null;
|
||||
homepage_url?: string | null;
|
||||
/** `archive` or `unpacked`. */
|
||||
source_kind: string;
|
||||
/** Set when the extension is loaded from a folder in place. Never synced. */
|
||||
linked_path?: string | null;
|
||||
}
|
||||
|
||||
export interface ExtensionGroup {
|
||||
id: string;
|
||||
name: string;
|
||||
extension_ids: string[];
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
sync_enabled?: boolean;
|
||||
last_sync?: number | null;
|
||||
}
|
||||
|
||||
export interface LocatorAttribute {
|
||||
name: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* How an element is named without a CSS selector.
|
||||
*
|
||||
* At least one property must be set. Keys are the browser's own camelCase; the
|
||||
* app also accepts `name_contains` and `text_contains` on input, but a locator
|
||||
* handed back by `agentPick` uses the spellings below, so reusing one verbatim
|
||||
* is the reliable path.
|
||||
*/
|
||||
export interface LocatorDescription {
|
||||
/** AX role token, matched case- and separator-insensitively. */
|
||||
role?: string;
|
||||
/** Computed accessible name, exact after whitespace collapse. */
|
||||
name?: string;
|
||||
nameContains?: string;
|
||||
/** Visible text content, from the live layout. */
|
||||
text?: string;
|
||||
textContains?: string;
|
||||
attributes?: LocatorAttribute[];
|
||||
}
|
||||
|
||||
export interface LocatorBounds {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface LocatorCandidate {
|
||||
/** Absent on the fallback engine, which has no DOM agent behind it. */
|
||||
backendNodeId?: number;
|
||||
role: string;
|
||||
name: string;
|
||||
text: string;
|
||||
/** Omitted, never blanked, for a control the page marked protected. */
|
||||
value?: string;
|
||||
url?: string;
|
||||
/** Per-profile deterministic identifier for the node's structural position. */
|
||||
signature: string;
|
||||
attributes?: LocatorAttribute[];
|
||||
bounds: LocatorBounds;
|
||||
}
|
||||
|
||||
export interface LocatorResolution {
|
||||
backendNodeId?: number;
|
||||
/** Always 1: present so a caller can assert it rather than infer it. */
|
||||
matchCount: number;
|
||||
match: LocatorCandidate;
|
||||
locator: LocatorDescription;
|
||||
engine: Engine;
|
||||
}
|
||||
|
||||
export interface PerceptionNode {
|
||||
/** Short, stable, frame-qualified handle. */
|
||||
id: string;
|
||||
frameId: string;
|
||||
role: string;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
inViewport: boolean;
|
||||
visible: boolean;
|
||||
focused: boolean;
|
||||
disabled: boolean;
|
||||
parentId?: string;
|
||||
name?: string;
|
||||
text?: string;
|
||||
value?: string;
|
||||
/** `"true"`, `"false"` or `"mixed"`; absent for anything not checkable. */
|
||||
checked?: string;
|
||||
expanded?: boolean;
|
||||
scrollable?: boolean;
|
||||
scrollContainerId?: string;
|
||||
}
|
||||
|
||||
export interface PerceptionFrame {
|
||||
frameId: string;
|
||||
url: string;
|
||||
crossOrigin: boolean;
|
||||
parentFrameId?: string;
|
||||
}
|
||||
|
||||
export interface PerceptionStats {
|
||||
totalNodes: number;
|
||||
returnedNodes: number;
|
||||
bytes: number;
|
||||
elapsedMs: number;
|
||||
framesVisited: number;
|
||||
/** Frames whose renderer did not answer within the budget. */
|
||||
framesFailed: number;
|
||||
}
|
||||
|
||||
export interface PerceptionPage {
|
||||
snapshotId: string;
|
||||
nodes: PerceptionNode[];
|
||||
frames: PerceptionFrame[];
|
||||
/** Readable text for exactly the nodes returned. */
|
||||
text: string;
|
||||
truncated: boolean;
|
||||
stats: PerceptionStats;
|
||||
/** Present when `truncated`: pass it back to continue. */
|
||||
cursor?: string;
|
||||
engine: Engine;
|
||||
}
|
||||
|
||||
export interface ExtractionField {
|
||||
/** The key this column appears under in each row's values. */
|
||||
key: string;
|
||||
/** Evaluated inside each container; the first match wins. */
|
||||
locator: LocatorDescription;
|
||||
/** `"text"`, `"attribute"` or `"link"`. */
|
||||
source: string;
|
||||
/** Required when `source` is `"attribute"`. */
|
||||
attribute?: string;
|
||||
}
|
||||
|
||||
export interface ExtractionRow {
|
||||
/** Global across pages. */
|
||||
index: number;
|
||||
/** Zero-based page this row came from. */
|
||||
page: number;
|
||||
values: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface Extraction {
|
||||
rows: ExtractionRow[];
|
||||
rowCount: number;
|
||||
pageCount: number;
|
||||
byteSize: number;
|
||||
truncated: boolean;
|
||||
/**
|
||||
* `complete` | `no-container` | `no-next` | `page-cap` | `row-cap` |
|
||||
* `byte-cap` | `time-budget`. A missing container is `no-container`, not an
|
||||
* error.
|
||||
*/
|
||||
stopReason: string;
|
||||
engine: Engine;
|
||||
}
|
||||
|
||||
export interface PickedElement {
|
||||
backendNodeId: number;
|
||||
/** The smallest description that still resolves to this node. */
|
||||
locator: LocatorDescription;
|
||||
matchCount: number;
|
||||
node: LocatorCandidate;
|
||||
engine: Engine;
|
||||
}
|
||||
|
||||
/** What a click did. Note the snake_case body and the `match` key. */
|
||||
export interface AgentClick {
|
||||
clicked: boolean;
|
||||
match: LocatorCandidate;
|
||||
engine: Engine;
|
||||
/** Whether a page load followed the click. */
|
||||
navigated: boolean;
|
||||
}
|
||||
|
||||
/** What a typing call did. */
|
||||
export interface AgentTyping {
|
||||
typed: boolean;
|
||||
characters: number;
|
||||
/** Absent on the fallback engine, which does not count its own mistypes. */
|
||||
corrections?: number;
|
||||
duration_ms: number;
|
||||
engine: Engine;
|
||||
match: LocatorCandidate;
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
/** Where the token and the port come from, and in what order. */
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
|
||||
import { DEFAULT_HOST, DEFAULT_PORT, DonutClient, DonutError } from "../src/index.mts";
|
||||
import { FakeDonut } from "./fake-donut.mts";
|
||||
|
||||
test("arguments are used as given", () => {
|
||||
const client = new DonutClient({ token: "from-argument", port: 12345, env: {} });
|
||||
assert.equal(client.token, "from-argument");
|
||||
assert.equal(client.port, 12345);
|
||||
assert.equal(client.host, DEFAULT_HOST);
|
||||
assert.equal(client.baseUrl, "http://127.0.0.1:12345");
|
||||
});
|
||||
|
||||
test("the environment fills in what was not passed", () => {
|
||||
const client = new DonutClient({
|
||||
env: { DONUT_API_TOKEN: "from-env", DONUT_API_PORT: "13579" },
|
||||
});
|
||||
assert.equal(client.token, "from-env");
|
||||
assert.equal(client.port, 13579);
|
||||
});
|
||||
|
||||
test("arguments win over the environment", () => {
|
||||
const client = new DonutClient({
|
||||
token: "from-argument",
|
||||
port: 111,
|
||||
env: { DONUT_API_TOKEN: "from-env", DONUT_API_PORT: "222" },
|
||||
});
|
||||
assert.equal(client.token, "from-argument");
|
||||
assert.equal(client.port, 111);
|
||||
});
|
||||
|
||||
test("the port falls back to the app default", () => {
|
||||
const client = new DonutClient({ env: { DONUT_API_TOKEN: "t" } });
|
||||
assert.equal(client.port, DEFAULT_PORT);
|
||||
assert.equal(DEFAULT_PORT, 10108);
|
||||
});
|
||||
|
||||
test("a baseUrl overrides host and port", () => {
|
||||
const client = new DonutClient({
|
||||
baseUrl: "http://127.0.0.1:9999/donut",
|
||||
token: "t",
|
||||
env: { DONUT_API_PORT: "222" },
|
||||
});
|
||||
assert.equal(client.port, 9999);
|
||||
assert.equal(client.baseUrl, "http://127.0.0.1:9999/donut");
|
||||
});
|
||||
|
||||
test("a baseUrl prefix is kept on every path", async () => {
|
||||
const fake = await new FakeDonut().start();
|
||||
try {
|
||||
const client = new DonutClient({
|
||||
baseUrl: `http://127.0.0.1:${fake.port}/donut`,
|
||||
token: "t",
|
||||
timeoutMs: 5_000,
|
||||
env: {},
|
||||
});
|
||||
await client.listProfiles();
|
||||
assert.equal(fake.last.path, "/donut/v1/profiles");
|
||||
} finally {
|
||||
await fake.stop();
|
||||
}
|
||||
});
|
||||
|
||||
test("an unusable port in the environment is reported", () => {
|
||||
assert.throws(
|
||||
() => new DonutClient({ env: { DONUT_API_TOKEN: "t", DONUT_API_PORT: "not-a-number" } }),
|
||||
/DONUT_API_PORT/,
|
||||
);
|
||||
});
|
||||
|
||||
test("an unsupported scheme is refused", () => {
|
||||
assert.throws(
|
||||
() => new DonutClient({ baseUrl: "ftp://127.0.0.1:9999", token: "t", env: {} }),
|
||||
DonutError,
|
||||
);
|
||||
});
|
||||
|
||||
test("the websocket address is built from the same base", () => {
|
||||
const client = new DonutClient({ token: "t", port: 10108, env: {} });
|
||||
assert.equal(
|
||||
client.remoteSessionCdpUrl("s 1"),
|
||||
"ws://127.0.0.1:10108/v1/remote-sessions/s%201/cdp",
|
||||
);
|
||||
});
|
||||
|
||||
test("an https base gives a wss websocket address", () => {
|
||||
const client = new DonutClient({ baseUrl: "https://127.0.0.1:8443", token: "t", env: {} });
|
||||
assert.equal(
|
||||
client.remoteSessionCdpUrl("s1"),
|
||||
"wss://127.0.0.1:8443/v1/remote-sessions/s1/cdp",
|
||||
);
|
||||
});
|
||||
|
||||
test("a supplied fetch is the one that is used", async () => {
|
||||
const seen: string[] = [];
|
||||
const client = new DonutClient({
|
||||
token: "t",
|
||||
env: {},
|
||||
fetch: async (input) => {
|
||||
seen.push(String(input));
|
||||
return new Response("[]", { status: 200, headers: { "Content-Type": "application/json" } });
|
||||
},
|
||||
});
|
||||
assert.deepEqual(await client.listTags(), []);
|
||||
assert.deepEqual(seen, ["http://127.0.0.1:10108/v1/tags"]);
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* The SDK cannot silently drift from the app's API.
|
||||
*
|
||||
* `sdk/api-paths.json` is generated from `src-tauri/src/api_server.rs` and
|
||||
* lists every operation the desktop app publishes. These tests hold it against
|
||||
* the SDK's own table in both directions, so a new endpoint in the app fails
|
||||
* here until it is wrapped or deliberately omitted with a reason.
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { test } from "node:test";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { DonutClient, OMITTED, OPERATIONS } from "../src/index.mts";
|
||||
|
||||
const SNAPSHOT = fileURLToPath(new URL("../../api-paths.json", import.meta.url));
|
||||
|
||||
interface Snapshot {
|
||||
source: string;
|
||||
operation_count: number;
|
||||
operations: { operation_id: string; method: string; path: string }[];
|
||||
}
|
||||
|
||||
function snapshot(): Snapshot {
|
||||
return JSON.parse(readFileSync(SNAPSHOT, "utf8")) as Snapshot;
|
||||
}
|
||||
|
||||
function published(): Set<string> {
|
||||
return new Set(snapshot().operations.map((entry) => `${entry.method} ${entry.path}`));
|
||||
}
|
||||
|
||||
test("the snapshot is readable and not empty", () => {
|
||||
const document = snapshot();
|
||||
assert.equal(document.source, "src-tauri/src/api_server.rs");
|
||||
assert.equal(document.operation_count, document.operations.length);
|
||||
assert.ok(document.operation_count > 0);
|
||||
assert.equal(
|
||||
published().size,
|
||||
document.operation_count,
|
||||
"the app has two identical operations",
|
||||
);
|
||||
});
|
||||
|
||||
test("every published operation is wrapped or omitted", () => {
|
||||
const known = new Set([...OPERATIONS.keys(), ...OMITTED.keys()]);
|
||||
const missing = [...published()].filter((key) => !known.has(key)).sort();
|
||||
assert.deepEqual(
|
||||
missing,
|
||||
[],
|
||||
`the app publishes operations this SDK does not handle: ${missing.join(", ")}. ` +
|
||||
"Wrap each one, or add it to OMITTED with a reason.",
|
||||
);
|
||||
});
|
||||
|
||||
test("the SDK claims nothing the app does not publish", () => {
|
||||
const live = published();
|
||||
const stale = [...OPERATIONS.keys(), ...OMITTED.keys()].filter((key) => !live.has(key)).sort();
|
||||
assert.deepEqual(
|
||||
stale,
|
||||
[],
|
||||
`this SDK handles operations the app no longer publishes: ${stale.join(", ")}. ` +
|
||||
"Regenerate the snapshot with sdk/tools/extract-api-paths.py, then drop or fix each entry.",
|
||||
);
|
||||
});
|
||||
|
||||
test("an operation is either wrapped or omitted but not both", () => {
|
||||
const both = [...OPERATIONS.keys()].filter((key) => OMITTED.has(key)).sort();
|
||||
assert.deepEqual(both, [], `listed twice: ${both.join(", ")}`);
|
||||
});
|
||||
|
||||
test("every omission gives a reason", () => {
|
||||
for (const [operation, reason] of OMITTED) {
|
||||
assert.ok(reason.trim().length > 40, `${operation} is omitted without a real reason`);
|
||||
}
|
||||
});
|
||||
|
||||
test("every wrapped operation names a real method", () => {
|
||||
const prototype = DonutClient.prototype as unknown as Record<string, unknown>;
|
||||
for (const [operation, name] of OPERATIONS) {
|
||||
assert.equal(
|
||||
typeof prototype[name],
|
||||
"function",
|
||||
`${operation} names ${name}, which is not a method`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("no two operations share a method", () => {
|
||||
const names = [...OPERATIONS.values()];
|
||||
const duplicates = [...new Set(names.filter((name, index) => names.indexOf(name) !== index))];
|
||||
assert.deepEqual(
|
||||
duplicates,
|
||||
[],
|
||||
`one method is claimed by several operations: ${duplicates.join(", ")}`,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,219 @@
|
||||
/** Each status the app documents throws its own error. */
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
|
||||
import {
|
||||
BadGateway,
|
||||
Conflict,
|
||||
DonutApiError,
|
||||
DonutClient,
|
||||
DonutConnectionError,
|
||||
DonutError,
|
||||
Forbidden,
|
||||
NotFound,
|
||||
PaymentRequired,
|
||||
RateLimited,
|
||||
RequestTimeout,
|
||||
ServerError,
|
||||
ServiceUnavailable,
|
||||
Unauthorized,
|
||||
ValidationError,
|
||||
} from "../src/index.mts";
|
||||
import { FakeDonut } from "./fake-donut.mts";
|
||||
import { withClient } from "./support.mts";
|
||||
|
||||
const STATUS_TO_ERROR: [number, new (...args: never[]) => DonutApiError][] = [
|
||||
[400, ValidationError],
|
||||
[401, Unauthorized],
|
||||
[402, PaymentRequired],
|
||||
[403, Forbidden],
|
||||
[404, NotFound],
|
||||
[408, RequestTimeout],
|
||||
[409, Conflict],
|
||||
[429, RateLimited],
|
||||
[500, ServerError],
|
||||
[502, BadGateway],
|
||||
[503, ServiceUnavailable],
|
||||
];
|
||||
|
||||
for (const [status, expected] of STATUS_TO_ERROR) {
|
||||
test(`${status} maps to ${expected.name}`, async () => {
|
||||
await withClient(async (client, fake) => {
|
||||
fake.enqueueError(status, "something went wrong");
|
||||
const thrown = await client.listProfiles().then(
|
||||
() => null,
|
||||
(error: unknown) => error,
|
||||
);
|
||||
assert.ok(thrown instanceof expected, `expected ${expected.name}, got ${String(thrown)}`);
|
||||
assert.equal(thrown.status, status);
|
||||
assert.equal(thrown.body, "something went wrong");
|
||||
assert.equal(thrown.method, "GET");
|
||||
assert.equal(thrown.path, "/v1/profiles");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test("every error is a DonutError", async () => {
|
||||
await withClient(async (client, fake) => {
|
||||
fake.enqueueError(404, "PROFILE_NOT_FOUND");
|
||||
await assert.rejects(client.getProfile("nope"), DonutError);
|
||||
});
|
||||
});
|
||||
|
||||
test("the five hundreds share one base", async () => {
|
||||
await withClient(async (client, fake) => {
|
||||
for (const status of [500, 502, 503]) {
|
||||
fake.enqueueError(status, "upstream");
|
||||
await assert.rejects(client.listProfiles(), ServerError);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test("rate limited carries retryAfter", async () => {
|
||||
await withClient(async (client, fake) => {
|
||||
fake.enqueueError(429, "automation request rate limit exceeded", { "Retry-After": "42" });
|
||||
const thrown = await client.runProfile("p1").then(
|
||||
() => null,
|
||||
(error: unknown) => error,
|
||||
);
|
||||
assert.ok(thrown instanceof RateLimited);
|
||||
assert.equal(thrown.retryAfter, 42);
|
||||
});
|
||||
});
|
||||
|
||||
test("rate limited without the header is still thrown", async () => {
|
||||
await withClient(async (client, fake) => {
|
||||
fake.enqueueError(429, "slow down");
|
||||
const thrown = await client.runProfile("p1").then(
|
||||
() => null,
|
||||
(error: unknown) => error,
|
||||
);
|
||||
assert.ok(thrown instanceof RateLimited);
|
||||
assert.equal(thrown.retryAfter, null);
|
||||
});
|
||||
});
|
||||
|
||||
test("an unreadable Retry-After does not break the error", async () => {
|
||||
await withClient(async (client, fake) => {
|
||||
fake.enqueueError(429, "slow down", { "Retry-After": "Wed, 21 Oct 2026 07:28:00 GMT" });
|
||||
const thrown = await client.runProfile("p1").then(
|
||||
() => null,
|
||||
(error: unknown) => error,
|
||||
);
|
||||
assert.ok(thrown instanceof RateLimited);
|
||||
assert.equal(thrown.retryAfter, null);
|
||||
});
|
||||
});
|
||||
|
||||
test("a structured code body is decoded", async () => {
|
||||
// The app shares `{"code": ...}` strings with its own frontend.
|
||||
await withClient(async (client, fake) => {
|
||||
fake.enqueueError(400, JSON.stringify({ code: "NAME_CANNOT_BE_EMPTY" }));
|
||||
const thrown = await client.createGroup("").then(
|
||||
() => null,
|
||||
(error: unknown) => error,
|
||||
);
|
||||
assert.ok(thrown instanceof ValidationError);
|
||||
assert.equal(thrown.code, "NAME_CANNOT_BE_EMPTY");
|
||||
assert.deepEqual(thrown.params, {});
|
||||
});
|
||||
});
|
||||
|
||||
test("a structured code body keeps its params", async () => {
|
||||
await withClient(async (client, fake) => {
|
||||
fake.enqueueError(
|
||||
409,
|
||||
JSON.stringify({ code: "PROFILE_LOCKED_BY_MEMBER", params: { n: "5" } }),
|
||||
);
|
||||
const thrown = await client.runProfile("p1").then(
|
||||
() => null,
|
||||
(error: unknown) => error,
|
||||
);
|
||||
assert.ok(thrown instanceof Conflict);
|
||||
assert.equal(thrown.code, "PROFILE_LOCKED_BY_MEMBER");
|
||||
assert.deepEqual(thrown.params, { n: "5" });
|
||||
});
|
||||
});
|
||||
|
||||
test("a plain text body leaves code unset", async () => {
|
||||
await withClient(async (client, fake) => {
|
||||
fake.enqueueError(400, "invalid browser");
|
||||
const thrown = await client.createProfile({ name: "x", browser: "chromium" }).then(
|
||||
() => null,
|
||||
(error: unknown) => error,
|
||||
);
|
||||
assert.ok(thrown instanceof ValidationError);
|
||||
assert.equal(thrown.code, null);
|
||||
assert.equal(thrown.body, "invalid browser");
|
||||
});
|
||||
});
|
||||
|
||||
test("an undocumented status still throws something catchable", async () => {
|
||||
await withClient(async (client, fake) => {
|
||||
fake.enqueueError(418, "teapot");
|
||||
const thrown = await client.listProfiles().then(
|
||||
() => null,
|
||||
(error: unknown) => error,
|
||||
);
|
||||
assert.ok(thrown instanceof DonutApiError);
|
||||
assert.equal(thrown.status, 418);
|
||||
});
|
||||
});
|
||||
|
||||
test("an undocumented server status is a ServerError", async () => {
|
||||
await withClient(async (client, fake) => {
|
||||
fake.enqueueError(504, "gateway timeout");
|
||||
await assert.rejects(client.listProfiles(), ServerError);
|
||||
});
|
||||
});
|
||||
|
||||
test("the message names the call", async () => {
|
||||
await withClient(async (client, fake) => {
|
||||
fake.enqueueError(404, "Profile not found");
|
||||
const thrown = await client.getProfile("missing").then(
|
||||
() => null,
|
||||
(error: unknown) => error,
|
||||
);
|
||||
assert.ok(thrown instanceof NotFound);
|
||||
assert.match(thrown.message, /404/);
|
||||
assert.match(thrown.message, /GET \/v1\/profiles\/missing/);
|
||||
});
|
||||
});
|
||||
|
||||
test("errors keep their class name", async () => {
|
||||
await withClient(async (client, fake) => {
|
||||
fake.enqueueError(404, "gone");
|
||||
const thrown = await client.listProfiles().then(
|
||||
() => null,
|
||||
(error: unknown) => error,
|
||||
);
|
||||
assert.ok(thrown instanceof NotFound);
|
||||
assert.equal(thrown.name, "NotFound");
|
||||
});
|
||||
});
|
||||
|
||||
test("an unreachable app is not an API error", async () => {
|
||||
const fake = await new FakeDonut().start();
|
||||
const port = fake.port;
|
||||
await fake.stop();
|
||||
|
||||
const client = new DonutClient({ token: "t", port, timeoutMs: 2_000, env: {} });
|
||||
const thrown = await client.listProfiles().then(
|
||||
() => null,
|
||||
(error: unknown) => error,
|
||||
);
|
||||
assert.ok(thrown instanceof DonutConnectionError);
|
||||
assert.match(thrown.message, /Local API/);
|
||||
});
|
||||
|
||||
test("a missing token fails before any request", () => {
|
||||
assert.throws(() => new DonutClient({ env: {} }), /DONUT_API_TOKEN/);
|
||||
});
|
||||
|
||||
test("a non-JSON answer is reported as such", async () => {
|
||||
await withClient(async (client, fake) => {
|
||||
fake.enqueueRaw(200, "<html>nope</html>");
|
||||
await assert.rejects(client.listProfiles(), /not\s+JSON/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* A stand-in for the desktop app's local REST API.
|
||||
*
|
||||
* It records what the client sent, byte for byte, and answers with whatever
|
||||
* the test queued. Nothing here reaches the network: it binds an ephemeral
|
||||
* loopback port and is torn down with the test.
|
||||
*/
|
||||
|
||||
import { createServer } from "node:http";
|
||||
import type { IncomingMessage, Server, ServerResponse } from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
|
||||
export interface RecordedRequest {
|
||||
method: string;
|
||||
target: string;
|
||||
path: string;
|
||||
query: Record<string, string>;
|
||||
headers: Record<string, string>;
|
||||
rawBody: string;
|
||||
json: unknown;
|
||||
}
|
||||
|
||||
export interface QueuedResponse {
|
||||
status: number;
|
||||
body: string;
|
||||
headers: Record<string, string>;
|
||||
contentType: string;
|
||||
}
|
||||
|
||||
export class FakeDonut {
|
||||
requests: RecordedRequest[] = [];
|
||||
responses: QueuedResponse[] = [];
|
||||
#server: Server | undefined = undefined;
|
||||
|
||||
enqueueJson(payload: unknown, status = 200): void {
|
||||
this.responses.push({
|
||||
status,
|
||||
body: JSON.stringify(payload),
|
||||
headers: {},
|
||||
contentType: "application/json",
|
||||
});
|
||||
}
|
||||
|
||||
enqueueEmpty(status = 204): void {
|
||||
this.responses.push({ status, body: "", headers: {}, contentType: "application/json" });
|
||||
}
|
||||
|
||||
enqueueError(status: number, body = "", headers: Record<string, string> = {}): void {
|
||||
this.responses.push({ status, body, headers, contentType: "text/plain" });
|
||||
}
|
||||
|
||||
enqueueRaw(status: number, body: string, contentType = "text/html"): void {
|
||||
this.responses.push({ status, body, headers: {}, contentType });
|
||||
}
|
||||
|
||||
get port(): number {
|
||||
if (this.#server === undefined) {
|
||||
throw new Error("the fake server is not running");
|
||||
}
|
||||
return (this.#server.address() as AddressInfo).port;
|
||||
}
|
||||
|
||||
get last(): RecordedRequest {
|
||||
const request = this.requests.at(-1);
|
||||
if (request === undefined) {
|
||||
throw new Error("the client sent nothing");
|
||||
}
|
||||
return request;
|
||||
}
|
||||
|
||||
async start(): Promise<this> {
|
||||
const server = createServer((incoming: IncomingMessage, outgoing: ServerResponse) => {
|
||||
const chunks: Buffer[] = [];
|
||||
incoming.on("data", (chunk: Buffer) => chunks.push(chunk));
|
||||
incoming.on("end", () => {
|
||||
const rawBody = Buffer.concat(chunks).toString("utf8");
|
||||
const url = new URL(incoming.url ?? "/", "http://127.0.0.1");
|
||||
const headers: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(incoming.headers)) {
|
||||
headers[key.toLowerCase()] = Array.isArray(value) ? value.join(", ") : (value ?? "");
|
||||
}
|
||||
|
||||
this.requests.push({
|
||||
method: incoming.method ?? "",
|
||||
target: incoming.url ?? "",
|
||||
path: url.pathname,
|
||||
query: Object.fromEntries(url.searchParams.entries()),
|
||||
headers,
|
||||
rawBody,
|
||||
json: rawBody === "" ? null : JSON.parse(rawBody),
|
||||
});
|
||||
|
||||
const queued = this.responses.shift() ?? {
|
||||
status: 200,
|
||||
body: "{}",
|
||||
headers: {},
|
||||
contentType: "application/json",
|
||||
};
|
||||
for (const [name, value] of Object.entries(queued.headers)) {
|
||||
outgoing.setHeader(name, value);
|
||||
}
|
||||
if (queued.body !== "") {
|
||||
outgoing.setHeader("Content-Type", queued.contentType);
|
||||
}
|
||||
outgoing.writeHead(queued.status);
|
||||
outgoing.end(queued.body);
|
||||
});
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
this.#server = server;
|
||||
return this;
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
const server = this.#server;
|
||||
if (server === undefined) {
|
||||
return;
|
||||
}
|
||||
this.#server = undefined;
|
||||
server.closeAllConnections();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Start a fake server, hand it to `work`, and always shut it down again. */
|
||||
export async function withFakeDonut<T>(work: (fake: FakeDonut) => Promise<T>): Promise<T> {
|
||||
const fake = await new FakeDonut().start();
|
||||
try {
|
||||
return await work(fake);
|
||||
} finally {
|
||||
await fake.stop();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,768 @@
|
||||
/**
|
||||
* Every client method sends exactly the request the app documents.
|
||||
*
|
||||
* The table below is the whole public surface. Each row names a method, the
|
||||
* arguments to call it with, and the request that must appear on the wire: the
|
||||
* verb, the concrete path, the query string and the JSON body. `operation` is
|
||||
* the path template the app publishes, which ties this file to
|
||||
* `OPERATIONS` and, through it, to `sdk/api-paths.json`.
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
|
||||
import { OPERATIONS } from "../src/index.mts";
|
||||
import { withClient } from "./support.mts";
|
||||
|
||||
interface Case {
|
||||
method: string;
|
||||
args: unknown[];
|
||||
verb: string;
|
||||
path: string;
|
||||
body: unknown;
|
||||
query?: Record<string, string>;
|
||||
operation: string;
|
||||
}
|
||||
|
||||
const LOCATOR = { role: "button", name: "Sign in" };
|
||||
|
||||
const CASES: Case[] = [
|
||||
// -- profiles ------------------------------------------------------------
|
||||
{
|
||||
method: "listProfiles",
|
||||
args: [],
|
||||
verb: "GET",
|
||||
path: "/v1/profiles",
|
||||
body: null,
|
||||
operation: "GET /v1/profiles",
|
||||
},
|
||||
{
|
||||
method: "getProfile",
|
||||
args: ["p1"],
|
||||
verb: "GET",
|
||||
path: "/v1/profiles/p1",
|
||||
body: null,
|
||||
operation: "GET /v1/profiles/{id}",
|
||||
},
|
||||
{
|
||||
method: "createProfile",
|
||||
args: [{ name: "Shopper", browser: "wayfern", tags: ["eu"], ephemeral: true }],
|
||||
verb: "POST",
|
||||
path: "/v1/profiles",
|
||||
body: { name: "Shopper", browser: "wayfern", tags: ["eu"], ephemeral: true },
|
||||
operation: "POST /v1/profiles",
|
||||
},
|
||||
{
|
||||
method: "createProfile",
|
||||
args: [{ name: "Bare", browser: "wayfern", version: undefined }],
|
||||
verb: "POST",
|
||||
path: "/v1/profiles",
|
||||
body: { name: "Bare", browser: "wayfern" },
|
||||
operation: "POST /v1/profiles",
|
||||
},
|
||||
{
|
||||
method: "updateProfile",
|
||||
args: ["p1", { name: "Renamed", proxy_id: "", clear_on_close: false }],
|
||||
verb: "PUT",
|
||||
path: "/v1/profiles/p1",
|
||||
body: { name: "Renamed", proxy_id: "", clear_on_close: false },
|
||||
operation: "PUT /v1/profiles/{id}",
|
||||
},
|
||||
{
|
||||
method: "deleteProfile",
|
||||
args: ["p1"],
|
||||
verb: "DELETE",
|
||||
path: "/v1/profiles/p1",
|
||||
body: null,
|
||||
operation: "DELETE /v1/profiles/{id}",
|
||||
},
|
||||
{
|
||||
method: "runProfile",
|
||||
args: ["p1", { url: "https://example.com", headless: true }],
|
||||
verb: "POST",
|
||||
path: "/v1/profiles/p1/run",
|
||||
body: { url: "https://example.com", headless: true },
|
||||
operation: "POST /v1/profiles/{id}/run",
|
||||
},
|
||||
{
|
||||
method: "runProfileRemote",
|
||||
args: ["p1", { url: "https://example.com" }],
|
||||
verb: "POST",
|
||||
path: "/v1/profiles/p1/run-remote",
|
||||
body: { url: "https://example.com" },
|
||||
operation: "POST /v1/profiles/{id}/run-remote",
|
||||
},
|
||||
{
|
||||
method: "setProfileCloudSync",
|
||||
args: ["p1", "Regular"],
|
||||
verb: "POST",
|
||||
path: "/v1/profiles/p1/cloud-sync",
|
||||
body: { mode: "Regular" },
|
||||
operation: "POST /v1/profiles/{id}/cloud-sync",
|
||||
},
|
||||
{
|
||||
method: "openUrl",
|
||||
args: ["p1", "https://example.com/page"],
|
||||
verb: "POST",
|
||||
path: "/v1/profiles/p1/open-url",
|
||||
body: { url: "https://example.com/page" },
|
||||
operation: "POST /v1/profiles/{id}/open-url",
|
||||
},
|
||||
{
|
||||
method: "killProfile",
|
||||
args: ["p1"],
|
||||
verb: "POST",
|
||||
path: "/v1/profiles/p1/kill",
|
||||
body: null,
|
||||
operation: "POST /v1/profiles/{id}/kill",
|
||||
},
|
||||
{
|
||||
method: "batchRunProfiles",
|
||||
args: [["p1", "p2"], { headless: false }],
|
||||
verb: "POST",
|
||||
path: "/v1/profiles/batch/run",
|
||||
body: { profile_ids: ["p1", "p2"], headless: false },
|
||||
operation: "POST /v1/profiles/batch/run",
|
||||
},
|
||||
{
|
||||
method: "batchStopProfiles",
|
||||
args: [["p1", "p2"]],
|
||||
verb: "POST",
|
||||
path: "/v1/profiles/batch/stop",
|
||||
body: { profile_ids: ["p1", "p2"] },
|
||||
operation: "POST /v1/profiles/batch/stop",
|
||||
},
|
||||
{
|
||||
method: "distributeProxies",
|
||||
args: [
|
||||
[
|
||||
{ profile_id: "p1", proxy_id: "x1" },
|
||||
{ profile_id: "p2", proxy_id: "x2" },
|
||||
],
|
||||
],
|
||||
verb: "POST",
|
||||
path: "/v1/profiles/distribute-proxies",
|
||||
body: {
|
||||
pairs: [
|
||||
{ profile_id: "p1", proxy_id: "x1" },
|
||||
{ profile_id: "p2", proxy_id: "x2" },
|
||||
],
|
||||
},
|
||||
operation: "POST /v1/profiles/distribute-proxies",
|
||||
},
|
||||
{
|
||||
method: "detectImportProfiles",
|
||||
args: [{ folder: "/Users/x/Chrome" }],
|
||||
verb: "GET",
|
||||
path: "/v1/profiles/import/detect",
|
||||
body: null,
|
||||
query: { folder: "/Users/x/Chrome" },
|
||||
operation: "GET /v1/profiles/import/detect",
|
||||
},
|
||||
{
|
||||
method: "detectImportProfiles",
|
||||
args: [],
|
||||
verb: "GET",
|
||||
path: "/v1/profiles/import/detect",
|
||||
body: null,
|
||||
operation: "GET /v1/profiles/import/detect",
|
||||
},
|
||||
{
|
||||
method: "importProfiles",
|
||||
args: [
|
||||
[{ source_path: "/tmp/src", new_profile_name: "Imported" }],
|
||||
{ duplicate_strategy: "skip" },
|
||||
],
|
||||
verb: "POST",
|
||||
path: "/v1/profiles/import",
|
||||
body: {
|
||||
items: [{ source_path: "/tmp/src", new_profile_name: "Imported" }],
|
||||
duplicate_strategy: "skip",
|
||||
},
|
||||
operation: "POST /v1/profiles/import",
|
||||
},
|
||||
{
|
||||
method: "importProfileCookies",
|
||||
args: ["p1", "[]"],
|
||||
verb: "POST",
|
||||
path: "/v1/profiles/p1/cookies/import",
|
||||
body: { content: "[]" },
|
||||
operation: "POST /v1/profiles/{id}/cookies/import",
|
||||
},
|
||||
// -- agent ---------------------------------------------------------------
|
||||
{
|
||||
method: "agentPerceive",
|
||||
args: ["p1", { viewport_only: true, max_bytes: 2048 }],
|
||||
verb: "POST",
|
||||
path: "/v1/profiles/p1/agent/perceive",
|
||||
body: { viewport_only: true, max_bytes: 2048 },
|
||||
operation: "POST /v1/profiles/{id}/agent/perceive",
|
||||
},
|
||||
{
|
||||
method: "agentPerceive",
|
||||
args: ["p1"],
|
||||
verb: "POST",
|
||||
path: "/v1/profiles/p1/agent/perceive",
|
||||
body: {},
|
||||
operation: "POST /v1/profiles/{id}/agent/perceive",
|
||||
},
|
||||
{
|
||||
method: "agentResolveLocator",
|
||||
args: ["p1", { locator: LOCATOR, candidate_limit: 5 }],
|
||||
verb: "POST",
|
||||
path: "/v1/profiles/p1/agent/resolve-locator",
|
||||
body: { locator: LOCATOR, candidate_limit: 5 },
|
||||
operation: "POST /v1/profiles/{id}/agent/resolve-locator",
|
||||
},
|
||||
{
|
||||
method: "agentClick",
|
||||
args: ["p1", { locator: LOCATOR, button: "right", click_count: 2 }],
|
||||
verb: "POST",
|
||||
path: "/v1/profiles/p1/agent/click",
|
||||
body: { locator: LOCATOR, button: "right", click_count: 2 },
|
||||
operation: "POST /v1/profiles/{id}/agent/click",
|
||||
},
|
||||
{
|
||||
method: "agentType",
|
||||
args: ["p1", { locator: LOCATOR, text: "hello", clear_first: false, wpm: 55 }],
|
||||
verb: "POST",
|
||||
path: "/v1/profiles/p1/agent/type",
|
||||
body: { locator: LOCATOR, text: "hello", clear_first: false, wpm: 55 },
|
||||
operation: "POST /v1/profiles/{id}/agent/type",
|
||||
},
|
||||
{
|
||||
method: "agentExtract",
|
||||
args: [
|
||||
"p1",
|
||||
{
|
||||
container: { role: "listitem" },
|
||||
field_map: [{ key: "title", locator: { role: "heading" }, source: "text" }],
|
||||
max_pages: 3,
|
||||
},
|
||||
],
|
||||
verb: "POST",
|
||||
path: "/v1/profiles/p1/agent/extract",
|
||||
body: {
|
||||
container: { role: "listitem" },
|
||||
field_map: [{ key: "title", locator: { role: "heading" }, source: "text" }],
|
||||
max_pages: 3,
|
||||
},
|
||||
operation: "POST /v1/profiles/{id}/agent/extract",
|
||||
},
|
||||
{
|
||||
method: "agentPick",
|
||||
args: ["p1", { timeout_ms: 15000 }],
|
||||
verb: "POST",
|
||||
path: "/v1/profiles/p1/agent/pick",
|
||||
body: { timeout_ms: 15000 },
|
||||
operation: "POST /v1/profiles/{id}/agent/pick",
|
||||
},
|
||||
// -- remote sessions -----------------------------------------------------
|
||||
{
|
||||
method: "listRemoteSessions",
|
||||
args: [],
|
||||
verb: "GET",
|
||||
path: "/v1/remote-sessions",
|
||||
body: null,
|
||||
operation: "GET /v1/remote-sessions",
|
||||
},
|
||||
{
|
||||
method: "getRemoteSession",
|
||||
args: ["s1"],
|
||||
verb: "GET",
|
||||
path: "/v1/remote-sessions/s1",
|
||||
body: null,
|
||||
operation: "GET /v1/remote-sessions/{id}",
|
||||
},
|
||||
{
|
||||
method: "stopRemoteSession",
|
||||
args: ["s1"],
|
||||
verb: "DELETE",
|
||||
path: "/v1/remote-sessions/s1",
|
||||
body: null,
|
||||
operation: "DELETE /v1/remote-sessions/{id}",
|
||||
},
|
||||
{
|
||||
method: "getRemoteHours",
|
||||
args: [],
|
||||
verb: "GET",
|
||||
path: "/v1/remote-hours",
|
||||
body: null,
|
||||
operation: "GET /v1/remote-hours",
|
||||
},
|
||||
// -- cookie bot ----------------------------------------------------------
|
||||
{
|
||||
method: "listCookieBotSchedules",
|
||||
args: [{ scope: "team" }],
|
||||
verb: "GET",
|
||||
path: "/v1/cookie-bot/schedules",
|
||||
body: null,
|
||||
query: { scope: "team" },
|
||||
operation: "GET /v1/cookie-bot/schedules",
|
||||
},
|
||||
{
|
||||
method: "getCookieBotSchedule",
|
||||
args: ["p1"],
|
||||
verb: "GET",
|
||||
path: "/v1/cookie-bot/schedules/p1",
|
||||
body: null,
|
||||
operation: "GET /v1/cookie-bot/schedules/{profile_id}",
|
||||
},
|
||||
{
|
||||
method: "setCookieBotSchedule",
|
||||
args: [
|
||||
"p1",
|
||||
{
|
||||
enabled: true,
|
||||
run_at_minute: 120,
|
||||
days_mask: 31,
|
||||
timezone: "Europe/Berlin",
|
||||
preset: "steady",
|
||||
max_minutes: 45,
|
||||
sites: ["https://example.com"],
|
||||
acknowledge_conflict: true,
|
||||
},
|
||||
],
|
||||
verb: "PUT",
|
||||
path: "/v1/cookie-bot/schedules/p1",
|
||||
body: {
|
||||
enabled: true,
|
||||
run_at_minute: 120,
|
||||
days_mask: 31,
|
||||
timezone: "Europe/Berlin",
|
||||
preset: "steady",
|
||||
max_minutes: 45,
|
||||
sites: ["https://example.com"],
|
||||
acknowledge_conflict: true,
|
||||
},
|
||||
operation: "PUT /v1/cookie-bot/schedules/{profile_id}",
|
||||
},
|
||||
{
|
||||
method: "deleteCookieBotSchedule",
|
||||
args: ["p1"],
|
||||
verb: "DELETE",
|
||||
path: "/v1/cookie-bot/schedules/p1",
|
||||
body: null,
|
||||
operation: "DELETE /v1/cookie-bot/schedules/{profile_id}",
|
||||
},
|
||||
{
|
||||
method: "getCookieBotConflicts",
|
||||
args: ["p1", { run_at_minute: 90, timezone: "UTC", days_mask: 7 }],
|
||||
verb: "GET",
|
||||
path: "/v1/cookie-bot/conflicts",
|
||||
body: null,
|
||||
query: { profile_id: "p1", run_at_minute: "90", timezone: "UTC", days_mask: "7" },
|
||||
operation: "GET /v1/cookie-bot/conflicts",
|
||||
},
|
||||
{
|
||||
method: "listCookieBotRuns",
|
||||
args: [{ profile_id: "p1", limit: 10, before: "cursor-1" }],
|
||||
verb: "GET",
|
||||
path: "/v1/cookie-bot/runs",
|
||||
body: null,
|
||||
query: { profile_id: "p1", limit: "10", before: "cursor-1" },
|
||||
operation: "GET /v1/cookie-bot/runs",
|
||||
},
|
||||
{
|
||||
method: "startCookieBotRun",
|
||||
args: [{ profile_id: "p1", max_minutes: 30 }],
|
||||
verb: "POST",
|
||||
path: "/v1/cookie-bot/runs",
|
||||
body: { profile_id: "p1", max_minutes: 30 },
|
||||
operation: "POST /v1/cookie-bot/runs",
|
||||
},
|
||||
{
|
||||
method: "cancelCookieBotRun",
|
||||
args: ["r1"],
|
||||
verb: "DELETE",
|
||||
path: "/v1/cookie-bot/runs/r1",
|
||||
body: null,
|
||||
operation: "DELETE /v1/cookie-bot/runs/{run_id}",
|
||||
},
|
||||
{
|
||||
method: "listCookieBotPresets",
|
||||
args: [],
|
||||
verb: "GET",
|
||||
path: "/v1/cookie-bot/presets",
|
||||
body: null,
|
||||
operation: "GET /v1/cookie-bot/presets",
|
||||
},
|
||||
{
|
||||
method: "getCookieBotUsage",
|
||||
args: [{ period: "2026-08" }],
|
||||
verb: "GET",
|
||||
path: "/v1/cookie-bot/usage",
|
||||
body: null,
|
||||
query: { period: "2026-08" },
|
||||
operation: "GET /v1/cookie-bot/usage",
|
||||
},
|
||||
// -- groups and tags -----------------------------------------------------
|
||||
{
|
||||
method: "listGroups",
|
||||
args: [],
|
||||
verb: "GET",
|
||||
path: "/v1/groups",
|
||||
body: null,
|
||||
operation: "GET /v1/groups",
|
||||
},
|
||||
{
|
||||
method: "getGroup",
|
||||
args: ["g1"],
|
||||
verb: "GET",
|
||||
path: "/v1/groups/g1",
|
||||
body: null,
|
||||
operation: "GET /v1/groups/{id}",
|
||||
},
|
||||
{
|
||||
method: "createGroup",
|
||||
args: ["Retail"],
|
||||
verb: "POST",
|
||||
path: "/v1/groups",
|
||||
body: { name: "Retail" },
|
||||
operation: "POST /v1/groups",
|
||||
},
|
||||
{
|
||||
method: "updateGroup",
|
||||
args: ["g1", "Retail EU"],
|
||||
verb: "PUT",
|
||||
path: "/v1/groups/g1",
|
||||
body: { name: "Retail EU" },
|
||||
operation: "PUT /v1/groups/{id}",
|
||||
},
|
||||
{
|
||||
method: "deleteGroup",
|
||||
args: ["g1"],
|
||||
verb: "DELETE",
|
||||
path: "/v1/groups/g1",
|
||||
body: null,
|
||||
operation: "DELETE /v1/groups/{id}",
|
||||
},
|
||||
{
|
||||
method: "listTags",
|
||||
args: [],
|
||||
verb: "GET",
|
||||
path: "/v1/tags",
|
||||
body: null,
|
||||
operation: "GET /v1/tags",
|
||||
},
|
||||
// -- proxies -------------------------------------------------------------
|
||||
{
|
||||
method: "listProxies",
|
||||
args: [],
|
||||
verb: "GET",
|
||||
path: "/v1/proxies",
|
||||
body: null,
|
||||
operation: "GET /v1/proxies",
|
||||
},
|
||||
{
|
||||
method: "getProxy",
|
||||
args: ["x1"],
|
||||
verb: "GET",
|
||||
path: "/v1/proxies/x1",
|
||||
body: null,
|
||||
operation: "GET /v1/proxies/{id}",
|
||||
},
|
||||
{
|
||||
method: "createProxy",
|
||||
args: [{ name: "EU", proxy_settings: { proxy_type: "http", host: "h", port: 8080 } }],
|
||||
verb: "POST",
|
||||
path: "/v1/proxies",
|
||||
body: { name: "EU", proxy_settings: { proxy_type: "http", host: "h", port: 8080 } },
|
||||
operation: "POST /v1/proxies",
|
||||
},
|
||||
{
|
||||
method: "updateProxy",
|
||||
args: ["x1", { name: "EU 2" }],
|
||||
verb: "PUT",
|
||||
path: "/v1/proxies/x1",
|
||||
body: { name: "EU 2" },
|
||||
operation: "PUT /v1/proxies/{id}",
|
||||
},
|
||||
{
|
||||
method: "deleteProxy",
|
||||
args: ["x1"],
|
||||
verb: "DELETE",
|
||||
path: "/v1/proxies/x1",
|
||||
body: null,
|
||||
operation: "DELETE /v1/proxies/{id}",
|
||||
},
|
||||
{
|
||||
method: "importProxies",
|
||||
args: [{ format: "txt", content: "h:1:u:p", name_prefix: "EU" }],
|
||||
verb: "POST",
|
||||
path: "/v1/proxies/import",
|
||||
body: { format: "txt", content: "h:1:u:p", name_prefix: "EU" },
|
||||
operation: "POST /v1/proxies/import",
|
||||
},
|
||||
// -- vpns ----------------------------------------------------------------
|
||||
{
|
||||
method: "listVpns",
|
||||
args: [],
|
||||
verb: "GET",
|
||||
path: "/v1/vpns",
|
||||
body: null,
|
||||
operation: "GET /v1/vpns",
|
||||
},
|
||||
{
|
||||
method: "getVpn",
|
||||
args: ["v1"],
|
||||
verb: "GET",
|
||||
path: "/v1/vpns/v1",
|
||||
body: null,
|
||||
operation: "GET /v1/vpns/{id}",
|
||||
},
|
||||
{
|
||||
method: "exportVpn",
|
||||
args: ["v1"],
|
||||
verb: "GET",
|
||||
path: "/v1/vpns/v1/export",
|
||||
body: null,
|
||||
operation: "GET /v1/vpns/{id}/export",
|
||||
},
|
||||
{
|
||||
method: "importVpn",
|
||||
args: [{ content: "[Interface]", filename: "eu.conf" }],
|
||||
verb: "POST",
|
||||
path: "/v1/vpns/import",
|
||||
body: { content: "[Interface]", filename: "eu.conf" },
|
||||
operation: "POST /v1/vpns/import",
|
||||
},
|
||||
{
|
||||
method: "createVpn",
|
||||
args: [{ name: "EU", vpn_type: "WireGuard", config_data: "[Interface]" }],
|
||||
verb: "POST",
|
||||
path: "/v1/vpns",
|
||||
body: { name: "EU", vpn_type: "WireGuard", config_data: "[Interface]" },
|
||||
operation: "POST /v1/vpns",
|
||||
},
|
||||
{
|
||||
method: "updateVpn",
|
||||
args: ["v1", "EU 2"],
|
||||
verb: "PUT",
|
||||
path: "/v1/vpns/v1",
|
||||
body: { name: "EU 2" },
|
||||
operation: "PUT /v1/vpns/{id}",
|
||||
},
|
||||
{
|
||||
method: "deleteVpn",
|
||||
args: ["v1"],
|
||||
verb: "DELETE",
|
||||
path: "/v1/vpns/v1",
|
||||
body: null,
|
||||
operation: "DELETE /v1/vpns/{id}",
|
||||
},
|
||||
// -- extensions ----------------------------------------------------------
|
||||
{
|
||||
method: "listExtensions",
|
||||
args: [],
|
||||
verb: "GET",
|
||||
path: "/v1/extensions",
|
||||
body: null,
|
||||
operation: "GET /v1/extensions",
|
||||
},
|
||||
{
|
||||
method: "getExtension",
|
||||
args: ["e1"],
|
||||
verb: "GET",
|
||||
path: "/v1/extensions/e1",
|
||||
body: null,
|
||||
operation: "GET /v1/extensions/{id}",
|
||||
},
|
||||
{
|
||||
method: "createExtension",
|
||||
args: [{ name: "Blocker", file_name: "b.crx", file_data_base64: "AAAA" }],
|
||||
verb: "POST",
|
||||
path: "/v1/extensions",
|
||||
body: { name: "Blocker", file_name: "b.crx", file_data_base64: "AAAA" },
|
||||
operation: "POST /v1/extensions",
|
||||
},
|
||||
{
|
||||
method: "updateExtension",
|
||||
args: ["e1", { name: "Blocker 2", link: true }],
|
||||
verb: "PUT",
|
||||
path: "/v1/extensions/e1",
|
||||
body: { name: "Blocker 2", link: true },
|
||||
operation: "PUT /v1/extensions/{id}",
|
||||
},
|
||||
{
|
||||
method: "deleteExtension",
|
||||
args: ["e1"],
|
||||
verb: "DELETE",
|
||||
path: "/v1/extensions/e1",
|
||||
body: null,
|
||||
operation: "DELETE /v1/extensions/{id}",
|
||||
},
|
||||
{
|
||||
method: "listExtensionGroups",
|
||||
args: [],
|
||||
verb: "GET",
|
||||
path: "/v1/extension-groups",
|
||||
body: null,
|
||||
operation: "GET /v1/extension-groups",
|
||||
},
|
||||
{
|
||||
method: "getExtensionGroup",
|
||||
args: ["eg1"],
|
||||
verb: "GET",
|
||||
path: "/v1/extension-groups/eg1",
|
||||
body: null,
|
||||
operation: "GET /v1/extension-groups/{id}",
|
||||
},
|
||||
{
|
||||
method: "createExtensionGroup",
|
||||
args: ["Adblock set"],
|
||||
verb: "POST",
|
||||
path: "/v1/extension-groups",
|
||||
body: { name: "Adblock set" },
|
||||
operation: "POST /v1/extension-groups",
|
||||
},
|
||||
{
|
||||
method: "updateExtensionGroup",
|
||||
args: ["eg1", { extension_ids: ["e1", "e2"] }],
|
||||
verb: "PUT",
|
||||
path: "/v1/extension-groups/eg1",
|
||||
body: { extension_ids: ["e1", "e2"] },
|
||||
operation: "PUT /v1/extension-groups/{id}",
|
||||
},
|
||||
{
|
||||
method: "deleteExtensionGroup",
|
||||
args: ["eg1"],
|
||||
verb: "DELETE",
|
||||
path: "/v1/extension-groups/eg1",
|
||||
body: null,
|
||||
operation: "DELETE /v1/extension-groups/{id}",
|
||||
},
|
||||
{
|
||||
method: "addExtensionToGroup",
|
||||
args: ["eg1", "e1"],
|
||||
verb: "POST",
|
||||
path: "/v1/extension-groups/eg1/extensions/e1",
|
||||
body: null,
|
||||
operation: "POST /v1/extension-groups/{id}/extensions/{extension_id}",
|
||||
},
|
||||
{
|
||||
method: "removeExtensionFromGroup",
|
||||
args: ["eg1", "e1"],
|
||||
verb: "DELETE",
|
||||
path: "/v1/extension-groups/eg1/extensions/e1",
|
||||
body: null,
|
||||
operation: "DELETE /v1/extension-groups/{id}/extensions/{extension_id}",
|
||||
},
|
||||
// -- browsers ------------------------------------------------------------
|
||||
{
|
||||
method: "downloadBrowser",
|
||||
args: [{ browser: "wayfern", version: "152.0.1" }],
|
||||
verb: "POST",
|
||||
path: "/v1/browsers/download",
|
||||
body: { browser: "wayfern", version: "152.0.1" },
|
||||
operation: "POST /v1/browsers/download",
|
||||
},
|
||||
{
|
||||
method: "listBrowserVersions",
|
||||
args: ["wayfern"],
|
||||
verb: "GET",
|
||||
path: "/v1/browsers/wayfern/versions",
|
||||
body: null,
|
||||
operation: "GET /v1/browsers/{browser}/versions",
|
||||
},
|
||||
{
|
||||
method: "isBrowserDownloaded",
|
||||
args: ["wayfern", "152.0.1"],
|
||||
verb: "GET",
|
||||
path: "/v1/browsers/wayfern/versions/152.0.1/downloaded",
|
||||
body: null,
|
||||
operation: "GET /v1/browsers/{browser}/versions/{version}/downloaded",
|
||||
},
|
||||
];
|
||||
|
||||
for (const [index, expected] of CASES.entries()) {
|
||||
test(`${expected.method} sends the documented request [${index}]`, async () => {
|
||||
await withClient(async (client, fake) => {
|
||||
const callable = (client as unknown as Record<string, (...args: unknown[]) => Promise<unknown>>)[
|
||||
expected.method
|
||||
];
|
||||
assert.equal(typeof callable, "function", `${expected.method} is not a method`);
|
||||
await callable.call(client, ...expected.args);
|
||||
|
||||
const sent = fake.last;
|
||||
assert.equal(sent.method, expected.verb);
|
||||
assert.equal(sent.path, expected.path);
|
||||
assert.deepEqual(sent.query, expected.query ?? {});
|
||||
assert.deepEqual(sent.json, expected.body);
|
||||
assert.equal(OPERATIONS.get(expected.operation), expected.method);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test("every wrapped operation has a request test", () => {
|
||||
const covered = new Set(CASES.map((entry) => entry.method));
|
||||
const missing = [...OPERATIONS.values()].filter((name) => !covered.has(name)).sort();
|
||||
assert.deepEqual(missing, [], `these wrapped operations have no request test: ${missing}`);
|
||||
});
|
||||
|
||||
test("the token travels as a bearer header", async () => {
|
||||
await withClient(async (client, fake) => {
|
||||
await client.listProfiles();
|
||||
assert.equal(fake.last.headers.authorization, "Bearer test-token-abc123");
|
||||
assert.equal(fake.last.headers.accept, "application/json");
|
||||
assert.equal(
|
||||
fake.last.headers["content-type"],
|
||||
undefined,
|
||||
"a GET must not claim to carry JSON",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("a body is sent as JSON", async () => {
|
||||
await withClient(async (client, fake) => {
|
||||
await client.createGroup("Retail");
|
||||
assert.equal(fake.last.headers["content-type"], "application/json");
|
||||
assert.equal(fake.last.rawBody, '{"name":"Retail"}');
|
||||
});
|
||||
});
|
||||
|
||||
test("path ids are escaped", async () => {
|
||||
await withClient(async (client, fake) => {
|
||||
await client.getProfile("a/b c?d");
|
||||
assert.equal(fake.last.path, "/v1/profiles/a%2Fb%20c%3Fd");
|
||||
});
|
||||
});
|
||||
|
||||
test("undefined arguments are left out of the body", async () => {
|
||||
await withClient(async (client, fake) => {
|
||||
await client.updateProfile("p1", { name: "Only this", version: undefined });
|
||||
assert.deepEqual(fake.last.json, { name: "Only this" });
|
||||
});
|
||||
});
|
||||
|
||||
test("an empty string still reaches the app", async () => {
|
||||
// `proxy_id: ""` is how the app is told to detach a proxy, so it must survive.
|
||||
await withClient(async (client, fake) => {
|
||||
await client.updateProfile("p1", { proxy_id: "" });
|
||||
assert.deepEqual(fake.last.json, { proxy_id: "" });
|
||||
});
|
||||
});
|
||||
|
||||
test("a no-content answer becomes undefined", async () => {
|
||||
await withClient(async (client, fake) => {
|
||||
fake.enqueueEmpty(204);
|
||||
assert.equal(await client.deleteProfile("p1"), undefined);
|
||||
});
|
||||
});
|
||||
|
||||
test("a JSON answer is returned as sent", async () => {
|
||||
await withClient(async (client, fake) => {
|
||||
fake.enqueueJson({ profiles: [{ id: "p1", name: "Shopper" }], total: 1 });
|
||||
assert.deepEqual(await client.listProfiles(), {
|
||||
profiles: [{ id: "p1", name: "Shopper" }],
|
||||
total: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("a bare boolean answer is returned", async () => {
|
||||
await withClient(async (client, fake) => {
|
||||
fake.enqueueJson(true);
|
||||
assert.equal(await client.isBrowserDownloaded("wayfern", "152.0.1"), true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
/** `withProfile` launches, hands over the CDP endpoint, and stops. */
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
|
||||
import { Conflict, DonutError, RunSession } from "../src/index.mts";
|
||||
import { withClient } from "./support.mts";
|
||||
|
||||
const RUN_BODY = { profile_id: "p1", remote_debugging_port: 9222, headless: true };
|
||||
|
||||
test("the callback gets the CDP endpoint", async () => {
|
||||
await withClient(async (client, fake) => {
|
||||
fake.enqueueJson(RUN_BODY);
|
||||
fake.enqueueEmpty(204);
|
||||
|
||||
const seen = await client.withProfile(
|
||||
"p1",
|
||||
{ url: "https://example.com", headless: true },
|
||||
(session) => {
|
||||
assert.ok(session instanceof RunSession);
|
||||
assert.equal(session.remoteDebuggingPort, 9222);
|
||||
assert.equal(session.headless, true);
|
||||
assert.equal(session.cdpUrl, "http://127.0.0.1:9222");
|
||||
assert.deepEqual(session.response, RUN_BODY);
|
||||
return session.cdpUrl;
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(seen, "http://127.0.0.1:9222");
|
||||
assert.deepEqual(
|
||||
fake.requests.map((sent) => `${sent.method} ${sent.path}`),
|
||||
["POST /v1/profiles/p1/run", "POST /v1/profiles/p1/kill"],
|
||||
);
|
||||
assert.deepEqual(fake.requests[0]?.json, { url: "https://example.com", headless: true });
|
||||
});
|
||||
});
|
||||
|
||||
test("the browser is stopped when the callback throws", async () => {
|
||||
await withClient(async (client, fake) => {
|
||||
fake.enqueueJson(RUN_BODY);
|
||||
fake.enqueueEmpty(204);
|
||||
|
||||
await assert.rejects(
|
||||
client.withProfile("p1", {}, () => {
|
||||
throw new RangeError("the body failed");
|
||||
}),
|
||||
RangeError,
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
fake.requests.map((sent) => sent.path),
|
||||
["/v1/profiles/p1/run", "/v1/profiles/p1/kill"],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("a failed stop never hides why the callback failed", async () => {
|
||||
await withClient(async (client, fake) => {
|
||||
fake.enqueueJson(RUN_BODY);
|
||||
fake.enqueueError(409, "PROFILE_LOCKED_ELSEWHERE");
|
||||
|
||||
let captured: RunSession | undefined;
|
||||
await assert.rejects(
|
||||
client.withProfile("p1", {}, (session) => {
|
||||
captured = session;
|
||||
throw new RangeError("the body failed");
|
||||
}),
|
||||
RangeError,
|
||||
);
|
||||
|
||||
assert.ok(captured?.cleanupError instanceof Conflict);
|
||||
});
|
||||
});
|
||||
|
||||
test("a failed stop is thrown when the callback was fine", async () => {
|
||||
await withClient(async (client, fake) => {
|
||||
fake.enqueueJson(RUN_BODY);
|
||||
fake.enqueueError(503, "the fleet could not be reached");
|
||||
|
||||
await assert.rejects(
|
||||
client.withProfile("p1", {}, () => "done"),
|
||||
DonutError,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("a failed launch never runs the callback and stops nothing", async () => {
|
||||
await withClient(async (client, fake) => {
|
||||
fake.enqueueError(409, "PROFILE_RUNNING");
|
||||
|
||||
await assert.rejects(
|
||||
client.withProfile("p1", {}, () => {
|
||||
throw new Error("the callback must not run when the launch failed");
|
||||
}),
|
||||
Conflict,
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
fake.requests.map((sent) => sent.path),
|
||||
["/v1/profiles/p1/run"],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("an async callback is awaited before the browser is stopped", async () => {
|
||||
await withClient(async (client, fake) => {
|
||||
fake.enqueueJson(RUN_BODY);
|
||||
fake.enqueueJson({ profiles: [], total: 0 });
|
||||
fake.enqueueEmpty(204);
|
||||
|
||||
await client.withProfile("p1", {}, async () => {
|
||||
await client.listProfiles();
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
fake.requests.map((sent) => sent.path),
|
||||
["/v1/profiles/p1/run", "/v1/profiles", "/v1/profiles/p1/kill"],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("a session also disposes itself", async () => {
|
||||
// `withProfile` is the portable form, but a runtime with `await using` can
|
||||
// hold a RunSession directly.
|
||||
await withClient(async (client, fake) => {
|
||||
fake.enqueueEmpty(204);
|
||||
const session = new RunSession(client, "p1", RUN_BODY);
|
||||
await session[Symbol.asyncDispose]();
|
||||
assert.deepEqual(
|
||||
fake.requests.map((sent) => sent.path),
|
||||
["/v1/profiles/p1/kill"],
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import { DonutClient } from "../src/index.mts";
|
||||
import { FakeDonut } from "./fake-donut.mts";
|
||||
|
||||
export const TOKEN = "test-token-abc123";
|
||||
|
||||
/** Start a fake app, point a client at it, and always shut the server down. */
|
||||
export async function withClient<T>(
|
||||
work: (client: DonutClient, fake: FakeDonut) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const fake = await new FakeDonut().start();
|
||||
try {
|
||||
const client = new DonutClient({
|
||||
token: TOKEN,
|
||||
port: fake.port,
|
||||
timeoutMs: 5_000,
|
||||
env: {},
|
||||
});
|
||||
return await work(client, fake);
|
||||
} finally {
|
||||
await fake.stop();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023", "DOM", "ESNext.Disposable"],
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"strict": true,
|
||||
"exactOptionalPropertyTypes": false,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"declaration": true,
|
||||
"noEmitOnError": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"removeComments": false,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"types": [],
|
||||
"allowImportingTsExtensions": true,
|
||||
"rewriteRelativeImportExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src/**/*.mts"]
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
# donutbrowser
|
||||
|
||||
A thin Python client for the [Donut Browser](https://donutbrowser.com) local
|
||||
REST API. Every method wraps exactly one documented endpoint; nothing is
|
||||
invented, cached or retried.
|
||||
|
||||
The local API is off by default. Switch it on in the app under **Settings →
|
||||
Integrations → Local API → "Enable Local API Server"**, then copy the port and
|
||||
the authentication token from that screen.
|
||||
|
||||
```bash
|
||||
pip install -e . # from this directory
|
||||
```
|
||||
|
||||
```python
|
||||
from donutbrowser import DonutClient
|
||||
|
||||
with DonutClient(token="...") as client:
|
||||
with client.run(profile_id, url="https://example.com", headless=True) as session:
|
||||
print(session.cdp_url)
|
||||
```
|
||||
|
||||
The client reads `DONUT_API_TOKEN` and `DONUT_API_PORT` when the token and port
|
||||
are not passed as arguments.
|
||||
|
||||
Full documentation, including the Node package and a worked agent example, is in
|
||||
[`sdk/README.md`](../README.md).
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
pip install -e ".[dev]"
|
||||
pytest
|
||||
```
|
||||
|
||||
The suite runs entirely against a fake HTTP server on loopback. It never reaches
|
||||
the network and never needs the desktop app.
|
||||
@@ -0,0 +1,40 @@
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "donutbrowser"
|
||||
version = "0.1.0"
|
||||
description = "Thin client for the Donut Browser local REST API"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
license = { text = "AGPL-3.0" }
|
||||
keywords = ["donut-browser", "browser-automation", "anti-detect", "cdp"]
|
||||
classifiers = [
|
||||
"Development Status :: 4 - Beta",
|
||||
"Intended Audience :: Developers",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Programming Language :: Python :: 3.13",
|
||||
"Topic :: Internet :: WWW/HTTP",
|
||||
"Typing :: Typed",
|
||||
]
|
||||
# No runtime dependencies on purpose: this client talks to a loopback server on
|
||||
# the same machine, so the standard library is enough and installing the SDK can
|
||||
# never drag a transitive dependency into an automation environment.
|
||||
dependencies = []
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = ["pytest>=7"]
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://donutbrowser.com"
|
||||
Source = "https://github.com/zhom/donutbrowser"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["src/donutbrowser"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Donut Browser SDK: a thin client for the app's local REST API.
|
||||
|
||||
The local API is off by default. Switch it on in the app under **Settings,
|
||||
Integrations, Local API, "Enable Local API Server"**, and copy the port and the
|
||||
authentication token from that screen.
|
||||
|
||||
::
|
||||
|
||||
from donutbrowser import DonutClient
|
||||
|
||||
with DonutClient(token="...") as client:
|
||||
with client.run(profile_id, url="https://example.com") as session:
|
||||
client.agent_click(profile_id, locator={"role": "button", "name": "Sign in"})
|
||||
"""
|
||||
|
||||
from .client import DEFAULT_HOST, DEFAULT_PORT, DonutClient, RunSession
|
||||
from .coverage import OMITTED, OPERATIONS
|
||||
from .errors import (
|
||||
BadGateway,
|
||||
Conflict,
|
||||
DonutAPIError,
|
||||
DonutConnectionError,
|
||||
DonutError,
|
||||
Forbidden,
|
||||
NotFound,
|
||||
PaymentRequired,
|
||||
RateLimited,
|
||||
RequestTimeout,
|
||||
ServerError,
|
||||
ServiceUnavailable,
|
||||
Unauthorized,
|
||||
ValidationError,
|
||||
)
|
||||
|
||||
__version__ = "0.1.0"
|
||||
|
||||
__all__ = [
|
||||
"DonutClient",
|
||||
"RunSession",
|
||||
"DEFAULT_HOST",
|
||||
"DEFAULT_PORT",
|
||||
"OPERATIONS",
|
||||
"OMITTED",
|
||||
"DonutError",
|
||||
"DonutConnectionError",
|
||||
"DonutAPIError",
|
||||
"ValidationError",
|
||||
"Unauthorized",
|
||||
"PaymentRequired",
|
||||
"Forbidden",
|
||||
"NotFound",
|
||||
"RequestTimeout",
|
||||
"Conflict",
|
||||
"RateLimited",
|
||||
"ServerError",
|
||||
"BadGateway",
|
||||
"ServiceUnavailable",
|
||||
"__version__",
|
||||
]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,113 @@
|
||||
"""Which app operation each client method wraps.
|
||||
|
||||
This table is the SDK's half of a two-sided check. ``sdk/api-paths.json`` holds
|
||||
every operation the desktop app publishes, generated from
|
||||
``src-tauri/src/api_server.rs``. The test suite asserts the two agree exactly in
|
||||
both directions, so:
|
||||
|
||||
* an endpoint added to the app fails the SDK tests until it is wrapped here, or
|
||||
listed in :data:`OMITTED` with a reason, and
|
||||
* an entry here that the app no longer publishes fails too.
|
||||
|
||||
The same table is mirrored in the Node package, and the same snapshot proves it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Dict, Tuple
|
||||
|
||||
__all__ = ["OPERATIONS", "OMITTED"]
|
||||
|
||||
Operation = Tuple[str, str]
|
||||
|
||||
#: ``(method, path template)`` to the name of the :class:`~donutbrowser.DonutClient`
|
||||
#: method that calls it.
|
||||
OPERATIONS: Dict[Operation, str] = {
|
||||
("POST", "/v1/browsers/download"): "download_browser",
|
||||
("GET", "/v1/browsers/{browser}/versions"): "list_browser_versions",
|
||||
("GET", "/v1/browsers/{browser}/versions/{version}/downloaded"): "is_browser_downloaded",
|
||||
("GET", "/v1/cookie-bot/conflicts"): "get_cookie_bot_conflicts",
|
||||
("GET", "/v1/cookie-bot/presets"): "list_cookie_bot_presets",
|
||||
("GET", "/v1/cookie-bot/runs"): "list_cookie_bot_runs",
|
||||
("POST", "/v1/cookie-bot/runs"): "start_cookie_bot_run",
|
||||
("DELETE", "/v1/cookie-bot/runs/{run_id}"): "cancel_cookie_bot_run",
|
||||
("GET", "/v1/cookie-bot/schedules"): "list_cookie_bot_schedules",
|
||||
("DELETE", "/v1/cookie-bot/schedules/{profile_id}"): "delete_cookie_bot_schedule",
|
||||
("GET", "/v1/cookie-bot/schedules/{profile_id}"): "get_cookie_bot_schedule",
|
||||
("PUT", "/v1/cookie-bot/schedules/{profile_id}"): "set_cookie_bot_schedule",
|
||||
("GET", "/v1/cookie-bot/usage"): "get_cookie_bot_usage",
|
||||
("GET", "/v1/extension-groups"): "list_extension_groups",
|
||||
("POST", "/v1/extension-groups"): "create_extension_group",
|
||||
("DELETE", "/v1/extension-groups/{id}"): "delete_extension_group",
|
||||
("GET", "/v1/extension-groups/{id}"): "get_extension_group",
|
||||
("PUT", "/v1/extension-groups/{id}"): "update_extension_group",
|
||||
(
|
||||
"DELETE",
|
||||
"/v1/extension-groups/{id}/extensions/{extension_id}",
|
||||
): "remove_extension_from_group",
|
||||
("POST", "/v1/extension-groups/{id}/extensions/{extension_id}"): "add_extension_to_group",
|
||||
("GET", "/v1/extensions"): "list_extensions",
|
||||
("POST", "/v1/extensions"): "create_extension",
|
||||
("DELETE", "/v1/extensions/{id}"): "delete_extension",
|
||||
("GET", "/v1/extensions/{id}"): "get_extension",
|
||||
("PUT", "/v1/extensions/{id}"): "update_extension",
|
||||
("GET", "/v1/groups"): "list_groups",
|
||||
("POST", "/v1/groups"): "create_group",
|
||||
("DELETE", "/v1/groups/{id}"): "delete_group",
|
||||
("GET", "/v1/groups/{id}"): "get_group",
|
||||
("PUT", "/v1/groups/{id}"): "update_group",
|
||||
("GET", "/v1/profiles"): "list_profiles",
|
||||
("POST", "/v1/profiles"): "create_profile",
|
||||
("POST", "/v1/profiles/batch/run"): "batch_run_profiles",
|
||||
("POST", "/v1/profiles/batch/stop"): "batch_stop_profiles",
|
||||
("POST", "/v1/profiles/distribute-proxies"): "distribute_proxies",
|
||||
("POST", "/v1/profiles/import"): "import_profiles",
|
||||
("GET", "/v1/profiles/import/detect"): "detect_import_profiles",
|
||||
("DELETE", "/v1/profiles/{id}"): "delete_profile",
|
||||
("GET", "/v1/profiles/{id}"): "get_profile",
|
||||
("PUT", "/v1/profiles/{id}"): "update_profile",
|
||||
("POST", "/v1/profiles/{id}/agent/click"): "agent_click",
|
||||
("POST", "/v1/profiles/{id}/agent/extract"): "agent_extract",
|
||||
("POST", "/v1/profiles/{id}/agent/perceive"): "agent_perceive",
|
||||
("POST", "/v1/profiles/{id}/agent/pick"): "agent_pick",
|
||||
("POST", "/v1/profiles/{id}/agent/resolve-locator"): "agent_resolve_locator",
|
||||
("POST", "/v1/profiles/{id}/agent/type"): "agent_type",
|
||||
("POST", "/v1/profiles/{id}/cloud-sync"): "set_profile_cloud_sync",
|
||||
("POST", "/v1/profiles/{id}/cookies/import"): "import_profile_cookies",
|
||||
("POST", "/v1/profiles/{id}/kill"): "kill_profile",
|
||||
("POST", "/v1/profiles/{id}/open-url"): "open_url",
|
||||
("POST", "/v1/profiles/{id}/run"): "run_profile",
|
||||
("POST", "/v1/profiles/{id}/run-remote"): "run_profile_remote",
|
||||
("GET", "/v1/proxies"): "list_proxies",
|
||||
("POST", "/v1/proxies"): "create_proxy",
|
||||
("POST", "/v1/proxies/import"): "import_proxies",
|
||||
("DELETE", "/v1/proxies/{id}"): "delete_proxy",
|
||||
("GET", "/v1/proxies/{id}"): "get_proxy",
|
||||
("PUT", "/v1/proxies/{id}"): "update_proxy",
|
||||
("GET", "/v1/remote-hours"): "get_remote_hours",
|
||||
("GET", "/v1/remote-sessions"): "list_remote_sessions",
|
||||
("DELETE", "/v1/remote-sessions/{id}"): "stop_remote_session",
|
||||
("GET", "/v1/remote-sessions/{id}"): "get_remote_session",
|
||||
("GET", "/v1/tags"): "list_tags",
|
||||
("GET", "/v1/vpns"): "list_vpns",
|
||||
("POST", "/v1/vpns"): "create_vpn",
|
||||
("POST", "/v1/vpns/import"): "import_vpn",
|
||||
("DELETE", "/v1/vpns/{id}"): "delete_vpn",
|
||||
("GET", "/v1/vpns/{id}"): "get_vpn",
|
||||
("PUT", "/v1/vpns/{id}"): "update_vpn",
|
||||
("GET", "/v1/vpns/{id}/export"): "export_vpn",
|
||||
}
|
||||
|
||||
#: Operations this SDK deliberately does not call, and why.
|
||||
OMITTED: Dict[Operation, str] = {
|
||||
(
|
||||
"GET",
|
||||
"/v1/remote-sessions/{id}/cdp",
|
||||
): (
|
||||
"A WebSocket upgrade, not a request. An HTTP client cannot speak it, and "
|
||||
"bundling a websocket implementation would end this package's zero-dependency "
|
||||
"promise for one endpoint. DonutClient.remote_session_cdp_url() builds the "
|
||||
"ws:// address so a websocket library of the caller's choosing can connect, "
|
||||
"sending the same Authorization: Bearer header on the handshake."
|
||||
),
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
"""Exceptions raised by the Donut Browser SDK.
|
||||
|
||||
The local REST API answers with a plain-text body and one of a small set of
|
||||
statuses. Each status means one thing, so each gets its own exception and a
|
||||
caller can branch on the class instead of on a number:
|
||||
|
||||
=== ========================== ==================================
|
||||
403 ``Forbidden`` Terms not accepted, or not signed in
|
||||
400 ``ValidationError`` Malformed request, duplicate name
|
||||
401 ``Unauthorized`` Missing or wrong bearer token
|
||||
402 ``PaymentRequired`` Automation needs an active paid plan
|
||||
404 ``NotFound`` No such profile, group, proxy, ...
|
||||
408 ``RequestTimeout`` ``agent/pick`` waited and nothing was picked
|
||||
409 ``Conflict`` Something else holds the profile right now
|
||||
429 ``RateLimited`` Automation quota spent; see ``retry_after``
|
||||
500 ``ServerError`` Internal failure
|
||||
502 ``BadGateway`` The browser or relay answered wrongly
|
||||
503 ``ServiceUnavailable`` Cloud, fleet or lock service unreachable
|
||||
=== ========================== ==================================
|
||||
|
||||
Some bodies are the structured ``{"code": ..., "params": {...}}`` strings the
|
||||
desktop app shares with its own frontend. When one arrives, ``code`` and
|
||||
``params`` are filled in; otherwise ``code`` is ``None`` and ``body`` holds the
|
||||
diagnostic text as sent.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any, Mapping, Optional
|
||||
|
||||
__all__ = [
|
||||
"DonutError",
|
||||
"DonutConnectionError",
|
||||
"DonutAPIError",
|
||||
"ValidationError",
|
||||
"Unauthorized",
|
||||
"PaymentRequired",
|
||||
"Forbidden",
|
||||
"NotFound",
|
||||
"RequestTimeout",
|
||||
"Conflict",
|
||||
"RateLimited",
|
||||
"ServerError",
|
||||
"BadGateway",
|
||||
"ServiceUnavailable",
|
||||
"error_for_status",
|
||||
]
|
||||
|
||||
|
||||
class DonutError(Exception):
|
||||
"""Base class for everything this package raises."""
|
||||
|
||||
|
||||
class DonutConnectionError(DonutError):
|
||||
"""The app could not be reached at all.
|
||||
|
||||
Usually means the local API is switched off, is listening on another port,
|
||||
or the desktop app is not running.
|
||||
"""
|
||||
|
||||
|
||||
class DonutAPIError(DonutError):
|
||||
"""The app answered, and the answer was an error status."""
|
||||
|
||||
#: HTTP status this class is raised for. ``None`` on the base class, which
|
||||
#: catches every status without a more specific subclass.
|
||||
status: Optional[int] = None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
status: int,
|
||||
body: str,
|
||||
*,
|
||||
method: str = "",
|
||||
path: str = "",
|
||||
headers: Optional[Mapping[str, str]] = None,
|
||||
) -> None:
|
||||
self.status = status
|
||||
self.body = body
|
||||
self.method = method
|
||||
self.path = path
|
||||
self.headers = dict(headers or {})
|
||||
self.code: Optional[str] = None
|
||||
self.params: dict[str, Any] = {}
|
||||
|
||||
stripped = body.strip()
|
||||
if stripped.startswith("{"):
|
||||
try:
|
||||
decoded = json.loads(stripped)
|
||||
except ValueError:
|
||||
decoded = None
|
||||
if isinstance(decoded, dict) and isinstance(decoded.get("code"), str):
|
||||
self.code = decoded["code"]
|
||||
params = decoded.get("params")
|
||||
if isinstance(params, dict):
|
||||
self.params = params
|
||||
|
||||
where = f"{method} {path}".strip()
|
||||
detail = self.code or stripped or "(empty body)"
|
||||
super().__init__(f"{status} on {where}: {detail}" if where else f"{status}: {detail}")
|
||||
|
||||
|
||||
class ValidationError(DonutAPIError):
|
||||
"""400: the request was malformed, duplicated a name, or named something unsupported."""
|
||||
|
||||
status = 400
|
||||
|
||||
|
||||
class Unauthorized(DonutAPIError):
|
||||
"""401: no bearer token, the wrong one, or the local API has no token stored."""
|
||||
|
||||
status = 401
|
||||
|
||||
|
||||
class PaymentRequired(DonutAPIError):
|
||||
"""402: this action needs an active paid plan, or the proxy behind it lapsed."""
|
||||
|
||||
status = 402
|
||||
|
||||
|
||||
class Forbidden(DonutAPIError):
|
||||
"""403: the Wayfern terms are not accepted, or this desktop is not signed in."""
|
||||
|
||||
status = 403
|
||||
|
||||
|
||||
class NotFound(DonutAPIError):
|
||||
"""404: no entity with that id."""
|
||||
|
||||
status = 404
|
||||
|
||||
|
||||
class RequestTimeout(DonutAPIError):
|
||||
"""408: ``agent/pick`` waited its whole timeout and nothing was picked."""
|
||||
|
||||
status = 408
|
||||
|
||||
|
||||
class Conflict(DonutAPIError):
|
||||
"""409: something else holds the profile — a browser, a teammate, a remote session."""
|
||||
|
||||
status = 409
|
||||
|
||||
|
||||
class RateLimited(DonutAPIError):
|
||||
"""429: the shared automation quota is spent.
|
||||
|
||||
``retry_after`` is the number of seconds the server asked the caller to
|
||||
wait, taken from the ``Retry-After`` response header. It is ``None`` only
|
||||
when the header is missing or unreadable.
|
||||
"""
|
||||
|
||||
status = 429
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
status: int,
|
||||
body: str,
|
||||
*,
|
||||
method: str = "",
|
||||
path: str = "",
|
||||
headers: Optional[Mapping[str, str]] = None,
|
||||
) -> None:
|
||||
super().__init__(status, body, method=method, path=path, headers=headers)
|
||||
self.retry_after: Optional[int] = None
|
||||
raw = next(
|
||||
(value for key, value in self.headers.items() if key.lower() == "retry-after"),
|
||||
None,
|
||||
)
|
||||
if raw is not None:
|
||||
try:
|
||||
self.retry_after = int(str(raw).strip())
|
||||
except ValueError:
|
||||
self.retry_after = None
|
||||
|
||||
|
||||
class ServerError(DonutAPIError):
|
||||
"""500 and the other 5xx: the app, the fleet or an upstream failed.
|
||||
|
||||
``BadGateway`` and ``ServiceUnavailable`` derive from this, so one
|
||||
``except ServerError`` catches every server-side failure.
|
||||
"""
|
||||
|
||||
status = 500
|
||||
|
||||
|
||||
class BadGateway(ServerError):
|
||||
"""502: the browser or the relay did not answer the way it documents."""
|
||||
|
||||
status = 502
|
||||
|
||||
|
||||
class ServiceUnavailable(ServerError):
|
||||
"""503: Donut cloud, the remote fleet, or the profile lock service is unreachable.
|
||||
|
||||
Whatever was running keeps running: a 503 from ``kill`` or from stopping a
|
||||
remote session means the browser is still up, not that it stopped.
|
||||
"""
|
||||
|
||||
status = 503
|
||||
|
||||
|
||||
_BY_STATUS: dict[int, type[DonutAPIError]] = {
|
||||
cls.status: cls
|
||||
for cls in (
|
||||
ValidationError,
|
||||
Unauthorized,
|
||||
PaymentRequired,
|
||||
Forbidden,
|
||||
NotFound,
|
||||
RequestTimeout,
|
||||
Conflict,
|
||||
RateLimited,
|
||||
ServerError,
|
||||
BadGateway,
|
||||
ServiceUnavailable,
|
||||
)
|
||||
if cls.status is not None
|
||||
}
|
||||
|
||||
|
||||
def error_for_status(
|
||||
status: int,
|
||||
body: str,
|
||||
*,
|
||||
method: str = "",
|
||||
path: str = "",
|
||||
headers: Optional[Mapping[str, str]] = None,
|
||||
) -> DonutAPIError:
|
||||
"""Build the exception that belongs to ``status``.
|
||||
|
||||
A status with no class of its own becomes a plain :class:`DonutAPIError`,
|
||||
so a future status added to the app still raises something a caller can
|
||||
catch rather than escaping as a decode failure.
|
||||
"""
|
||||
cls = _BY_STATUS.get(status)
|
||||
if cls is None:
|
||||
cls = ServerError if status >= 500 else DonutAPIError
|
||||
return cls(status, body, method=method, path=path, headers=headers)
|
||||
@@ -0,0 +1,763 @@
|
||||
"""Response shapes, spelled exactly the way the local API sends them.
|
||||
|
||||
Every entry here mirrors a ``ToSchema`` struct in ``src-tauri`` field for field.
|
||||
A Rust ``Option<T>`` becomes a key that may be absent, expressed with the
|
||||
``total=False`` half of each pair of classes, so ``dict.get`` is the honest way
|
||||
to read one.
|
||||
|
||||
Two spellings live side by side because the app sends both. Most bodies are
|
||||
snake_case; the browser-facing agent types (``LocatorDescription``,
|
||||
``LocatorCandidate``, ``PerceptionPage`` and friends) carry the browser's own
|
||||
camelCase, because they are handed through from the browser rather than
|
||||
restated. ``AgentClick`` and ``AgentTyping`` are the exceptions inside the
|
||||
agent surface: they are snake_case with a single ``match`` key. The types below
|
||||
follow the wire rather than tidying it, so a value read from one call can be
|
||||
passed straight into the next.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, TypedDict
|
||||
|
||||
__all__ = [
|
||||
"ApiProfile",
|
||||
"ApiProfilesResponse",
|
||||
"ApiProfileResponse",
|
||||
"ApiGroupResponse",
|
||||
"ApiProxyResponse",
|
||||
"ApiVpnResponse",
|
||||
"ApiVpnExportResponse",
|
||||
"DownloadBrowserResponse",
|
||||
"RunProfileResponse",
|
||||
"RunRemoteResponse",
|
||||
"StopRemoteResponse",
|
||||
"SetCloudSyncResponse",
|
||||
"RemoteSessionState",
|
||||
"ApiRemoteSessionsResponse",
|
||||
"RemoteHoursBreakdown",
|
||||
"RemoteHoursMember",
|
||||
"RemoteHoursQuota",
|
||||
"CookieBotSlot",
|
||||
"CookieBotSchedule",
|
||||
"CookieBotScheduleList",
|
||||
"CookieBotConflict",
|
||||
"CookieBotScheduleSaved",
|
||||
"CookieBotConflictCheck",
|
||||
"CookieBotScheduleDeleted",
|
||||
"CookieBotRun",
|
||||
"CookieBotRunPage",
|
||||
"CookieBotRunStarted",
|
||||
"CookieBotPreset",
|
||||
"CookieBotPresetList",
|
||||
"CookieBotUsageMember",
|
||||
"CookieBotUsageProfile",
|
||||
"CookieBotUsage",
|
||||
"BatchRunResult",
|
||||
"BatchRunResponse",
|
||||
"BatchStopResult",
|
||||
"BatchStopResponse",
|
||||
"ProxyPair",
|
||||
"ProxyAssignmentResult",
|
||||
"DistributeProxiesResponse",
|
||||
"ImportCookiesResponse",
|
||||
"ImportProxiesResponse",
|
||||
"DetectedProfile",
|
||||
"DetectedProfilesResponse",
|
||||
"ImportProfileItem",
|
||||
"ProfileImportItemResult",
|
||||
"ProfileImportBatchResult",
|
||||
"Extension",
|
||||
"ExtensionGroup",
|
||||
"LocatorAttribute",
|
||||
"LocatorDescription",
|
||||
"LocatorBounds",
|
||||
"LocatorCandidate",
|
||||
"LocatorResolution",
|
||||
"PerceptionNode",
|
||||
"PerceptionFrame",
|
||||
"PerceptionStats",
|
||||
"PerceptionPage",
|
||||
"ExtractionField",
|
||||
"ExtractionRow",
|
||||
"Extraction",
|
||||
"PickedElement",
|
||||
"AgentClick",
|
||||
"AgentTyping",
|
||||
]
|
||||
|
||||
# The app's own JSON for a proxy's settings. Declared `Object` in the OpenAPI
|
||||
# document rather than a struct, so it is passed through untouched.
|
||||
ProxySettings = Dict[str, Any]
|
||||
|
||||
# A Wayfern fingerprint/config blob. Also declared `Object` in the document.
|
||||
WayfernConfig = Dict[str, Any]
|
||||
|
||||
|
||||
class _ApiProfileRequired(TypedDict):
|
||||
id: str
|
||||
name: str
|
||||
browser: str
|
||||
version: str
|
||||
release_type: str
|
||||
tags: List[str]
|
||||
is_running: bool
|
||||
proxy_bypass_rules: List[str]
|
||||
ephemeral: bool
|
||||
temporary: bool
|
||||
clear_on_close: bool
|
||||
sync_mode: str
|
||||
cloud_sync_enabled: bool
|
||||
is_cross_os: bool
|
||||
|
||||
|
||||
class ApiProfile(_ApiProfileRequired, total=False):
|
||||
proxy_id: str
|
||||
launch_hook: str
|
||||
process_id: int
|
||||
last_launch: int
|
||||
group_id: str
|
||||
vpn_id: str
|
||||
extension_group_id: str
|
||||
host_os: str
|
||||
fingerprint_os: str
|
||||
|
||||
|
||||
class ApiProfilesResponse(TypedDict):
|
||||
profiles: List[ApiProfile]
|
||||
total: int
|
||||
|
||||
|
||||
class ApiProfileResponse(TypedDict):
|
||||
profile: ApiProfile
|
||||
|
||||
|
||||
class ApiGroupResponse(TypedDict):
|
||||
id: str
|
||||
name: str
|
||||
profile_count: int
|
||||
|
||||
|
||||
class ApiProxyResponse(TypedDict):
|
||||
id: str
|
||||
name: str
|
||||
proxy_settings: ProxySettings
|
||||
|
||||
|
||||
class _ApiVpnRequired(TypedDict):
|
||||
id: str
|
||||
name: str
|
||||
vpn_type: str
|
||||
created_at: int
|
||||
|
||||
|
||||
class ApiVpnResponse(_ApiVpnRequired, total=False):
|
||||
last_used: int
|
||||
|
||||
|
||||
class ApiVpnExportResponse(TypedDict):
|
||||
id: str
|
||||
name: str
|
||||
vpn_type: str
|
||||
config_data: str
|
||||
|
||||
|
||||
class DownloadBrowserResponse(TypedDict):
|
||||
browser: str
|
||||
version: str
|
||||
status: str
|
||||
|
||||
|
||||
class RunProfileResponse(TypedDict):
|
||||
profile_id: str
|
||||
remote_debugging_port: int
|
||||
headless: bool
|
||||
|
||||
|
||||
class RunRemoteResponse(TypedDict):
|
||||
profile_id: str
|
||||
session_id: str
|
||||
platform: str
|
||||
status: str
|
||||
|
||||
|
||||
class StopRemoteResponse(TypedDict):
|
||||
session_id: str
|
||||
status: str
|
||||
billed_seconds: int
|
||||
|
||||
|
||||
class _SetCloudSyncRequired(TypedDict):
|
||||
profile_id: str
|
||||
mode: str
|
||||
remote_launchable: bool
|
||||
|
||||
|
||||
class SetCloudSyncResponse(_SetCloudSyncRequired, total=False):
|
||||
remote_blocked_reason: str
|
||||
|
||||
|
||||
class _RemoteSessionStateRequired(TypedDict):
|
||||
session_id: str
|
||||
state: str
|
||||
|
||||
|
||||
class RemoteSessionState(_RemoteSessionStateRequired, total=False):
|
||||
profile_id: str
|
||||
platform: str
|
||||
cdp_ready: bool
|
||||
kind: str
|
||||
run_id: str
|
||||
team_id: str
|
||||
started_at: str
|
||||
ended_at: str
|
||||
close_reason: str
|
||||
billed_seconds: int
|
||||
|
||||
|
||||
class ApiRemoteSessionsResponse(TypedDict):
|
||||
sessions: List[RemoteSessionState]
|
||||
|
||||
|
||||
class RemoteHoursBreakdown(TypedDict, total=False):
|
||||
interactive_hours: float
|
||||
bot_hours: float
|
||||
|
||||
|
||||
class _RemoteHoursMemberRequired(TypedDict):
|
||||
user_id: str
|
||||
email: str
|
||||
|
||||
|
||||
class RemoteHoursMember(_RemoteHoursMemberRequired, total=False):
|
||||
role: str
|
||||
used_hours: float
|
||||
interactive_hours: float
|
||||
bot_hours: float
|
||||
|
||||
|
||||
class _RemoteHoursQuotaRequired(TypedDict):
|
||||
granted_hours: float
|
||||
remaining_hours: float
|
||||
|
||||
|
||||
class RemoteHoursQuota(_RemoteHoursQuotaRequired, total=False):
|
||||
used_hours: float
|
||||
period_start: str
|
||||
period_end: str
|
||||
scope: str
|
||||
team_id: str
|
||||
seats: int
|
||||
per_seat_hours: float
|
||||
breakdown: RemoteHoursBreakdown
|
||||
members: List[RemoteHoursMember]
|
||||
|
||||
|
||||
class CookieBotSlot(TypedDict, total=False):
|
||||
run_at_minute: int
|
||||
days_mask: int
|
||||
|
||||
|
||||
class _CookieBotScheduleRequired(TypedDict):
|
||||
profile_id: str
|
||||
profile_name: str
|
||||
platform: str
|
||||
enabled: bool
|
||||
run_at_minute: int
|
||||
days_mask: int
|
||||
timezone: str
|
||||
preset: str
|
||||
max_minutes: int
|
||||
|
||||
|
||||
class CookieBotSchedule(_CookieBotScheduleRequired, total=False):
|
||||
slots: List[CookieBotSlot]
|
||||
template_id: str
|
||||
sites: List[str]
|
||||
jitter_seconds: int
|
||||
sync_enabled: bool
|
||||
encrypted_sync: bool
|
||||
has_proxy: bool
|
||||
proxy_remote_reachable: bool
|
||||
touch_fingerprint: bool
|
||||
sticky_exit: bool
|
||||
profile_state_at: str
|
||||
blocked_by: str
|
||||
next_run_at: str
|
||||
last_run_at: str
|
||||
last_run_id: str
|
||||
owner_user_id: str
|
||||
owner_email: str
|
||||
updated_at: str
|
||||
|
||||
|
||||
class CookieBotScheduleList(TypedDict, total=False):
|
||||
schedules: List[CookieBotSchedule]
|
||||
team_id: str
|
||||
scope: str
|
||||
|
||||
|
||||
class _CookieBotConflictRequired(TypedDict):
|
||||
user_id: str
|
||||
email: str
|
||||
run_at_minute: int
|
||||
timezone: str
|
||||
days_mask: int
|
||||
enabled: bool
|
||||
|
||||
|
||||
class CookieBotConflict(_CookieBotConflictRequired, total=False):
|
||||
overlaps: bool
|
||||
|
||||
|
||||
class _CookieBotScheduleSavedRequired(TypedDict):
|
||||
schedule: CookieBotSchedule
|
||||
|
||||
|
||||
class CookieBotScheduleSaved(_CookieBotScheduleSavedRequired, total=False):
|
||||
conflicts: List[CookieBotConflict]
|
||||
|
||||
|
||||
class _CookieBotConflictCheckRequired(TypedDict):
|
||||
profile_id: str
|
||||
|
||||
|
||||
class CookieBotConflictCheck(_CookieBotConflictCheckRequired, total=False):
|
||||
conflicts: List[CookieBotConflict]
|
||||
|
||||
|
||||
class CookieBotScheduleDeleted(TypedDict):
|
||||
profile_id: str
|
||||
deleted: bool
|
||||
|
||||
|
||||
class _CookieBotRunRequired(TypedDict):
|
||||
id: str
|
||||
profile_id: str
|
||||
trigger: str
|
||||
status: str
|
||||
scheduled_for: str
|
||||
|
||||
|
||||
class CookieBotRun(_CookieBotRunRequired, total=False):
|
||||
profile_name: str
|
||||
user_id: str
|
||||
email: str
|
||||
team_id: str
|
||||
dispatch_after: str
|
||||
started_at: str
|
||||
ended_at: str
|
||||
max_minutes: int
|
||||
chunks_total: int
|
||||
chunk_index: int
|
||||
sites_total: int
|
||||
sites_visited: int
|
||||
sites_failed: int
|
||||
consent_dismissed: int
|
||||
billed_seconds: int
|
||||
outcome_code: str
|
||||
session_id: str
|
||||
|
||||
|
||||
class CookieBotRunPage(TypedDict, total=False):
|
||||
runs: List[CookieBotRun]
|
||||
next_before: str
|
||||
|
||||
|
||||
class _CookieBotRunStartedRequired(TypedDict):
|
||||
run: CookieBotRun
|
||||
|
||||
|
||||
class CookieBotRunStarted(_CookieBotRunStartedRequired, total=False):
|
||||
session_id: str
|
||||
|
||||
|
||||
class _CookieBotPresetRequired(TypedDict):
|
||||
id: str
|
||||
|
||||
|
||||
class CookieBotPreset(_CookieBotPresetRequired, total=False):
|
||||
typical_minutes: int
|
||||
recommended: bool
|
||||
name: str
|
||||
description: str
|
||||
|
||||
|
||||
class CookieBotPresetList(TypedDict, total=False):
|
||||
presets: List[CookieBotPreset]
|
||||
default_preset: str
|
||||
# `templates` and `limits` are whatever the server publishes; the app
|
||||
# forwards them without narrowing, so neither is spelled out here.
|
||||
templates: List[Dict[str, Any]]
|
||||
limits: Dict[str, Any]
|
||||
|
||||
|
||||
class _CookieBotUsageMemberRequired(TypedDict):
|
||||
user_id: str
|
||||
email: str
|
||||
|
||||
|
||||
class CookieBotUsageMember(_CookieBotUsageMemberRequired, total=False):
|
||||
role: str
|
||||
interactive_hours: float
|
||||
bot_hours: float
|
||||
used_hours: float
|
||||
sessions: int
|
||||
bot_runs: int
|
||||
bot_runs_failed: int
|
||||
|
||||
|
||||
class _CookieBotUsageProfileRequired(TypedDict):
|
||||
profile_id: str
|
||||
|
||||
|
||||
class CookieBotUsageProfile(_CookieBotUsageProfileRequired, total=False):
|
||||
profile_name: str
|
||||
owner_email: str
|
||||
bot_hours: float
|
||||
runs: int
|
||||
runs_failed: int
|
||||
last_run_at: str
|
||||
last_status: str
|
||||
|
||||
|
||||
class _CookieBotUsageRequired(TypedDict):
|
||||
period: str
|
||||
|
||||
|
||||
class CookieBotUsage(_CookieBotUsageRequired, total=False):
|
||||
period_start: str
|
||||
period_end: str
|
||||
team_id: str
|
||||
seats: int
|
||||
granted_hours: float
|
||||
used_hours: float
|
||||
remaining_hours: float
|
||||
members: List[CookieBotUsageMember]
|
||||
profiles: List[CookieBotUsageProfile]
|
||||
|
||||
|
||||
class _BatchRunResultRequired(TypedDict):
|
||||
profile_id: str
|
||||
ok: bool
|
||||
|
||||
|
||||
class BatchRunResult(_BatchRunResultRequired, total=False):
|
||||
remote_debugging_port: int
|
||||
error: str
|
||||
|
||||
|
||||
class BatchRunResponse(TypedDict):
|
||||
results: List[BatchRunResult]
|
||||
|
||||
|
||||
class _BatchStopResultRequired(TypedDict):
|
||||
profile_id: str
|
||||
ok: bool
|
||||
|
||||
|
||||
class BatchStopResult(_BatchStopResultRequired, total=False):
|
||||
error: str
|
||||
|
||||
|
||||
class BatchStopResponse(TypedDict):
|
||||
results: List[BatchStopResult]
|
||||
|
||||
|
||||
class _ProxyAssignmentResultRequired(TypedDict):
|
||||
profile_id: str
|
||||
proxy_id: str
|
||||
ok: bool
|
||||
|
||||
|
||||
class ProxyAssignmentResult(_ProxyAssignmentResultRequired, total=False):
|
||||
"""``error`` is a ``{"code": ...}`` payload when ``ok`` is false."""
|
||||
|
||||
error: str
|
||||
|
||||
|
||||
class ProxyPair(TypedDict):
|
||||
"""One profile, one proxy. The distribution applies exactly these pairs."""
|
||||
|
||||
profile_id: str
|
||||
proxy_id: str
|
||||
|
||||
|
||||
class DistributeProxiesResponse(TypedDict):
|
||||
results: List[ProxyAssignmentResult]
|
||||
|
||||
|
||||
class ImportCookiesResponse(TypedDict):
|
||||
cookies_imported: int
|
||||
cookies_replaced: int
|
||||
errors: List[str]
|
||||
|
||||
|
||||
class ImportProxiesResponse(TypedDict):
|
||||
imported_count: int
|
||||
skipped_count: int
|
||||
errors: List[str]
|
||||
proxies: List[ApiProxyResponse]
|
||||
|
||||
|
||||
class DetectedProfile(TypedDict):
|
||||
browser: str
|
||||
mapped_browser: str
|
||||
name: str
|
||||
path: str
|
||||
description: str
|
||||
|
||||
|
||||
class DetectedProfilesResponse(TypedDict):
|
||||
profiles: List[DetectedProfile]
|
||||
total: int
|
||||
|
||||
|
||||
class _ImportProfileItemRequired(TypedDict):
|
||||
source_path: str
|
||||
new_profile_name: str
|
||||
|
||||
|
||||
class ImportProfileItem(_ImportProfileItemRequired, total=False):
|
||||
"""One item of ``import_profiles``.
|
||||
|
||||
``browser_type`` defaults to the app's own default when absent, and it is
|
||||
load-bearing: it picks which keychain entry unlocks the source's cookies
|
||||
and passwords.
|
||||
"""
|
||||
|
||||
browser_type: str
|
||||
proxy_id: str
|
||||
vpn_id: str
|
||||
allow_running: bool
|
||||
|
||||
|
||||
class _ProfileImportItemResultRequired(TypedDict):
|
||||
name: str
|
||||
source_path: str
|
||||
status: str
|
||||
|
||||
|
||||
class ProfileImportItemResult(_ProfileImportItemResultRequired, total=False):
|
||||
profile_id: str
|
||||
error: str
|
||||
report: Dict[str, Any]
|
||||
|
||||
|
||||
class ProfileImportBatchResult(TypedDict):
|
||||
imported_count: int
|
||||
skipped_count: int
|
||||
failed_count: int
|
||||
results: List[ProfileImportItemResult]
|
||||
|
||||
|
||||
class _ExtensionRequired(TypedDict):
|
||||
id: str
|
||||
name: str
|
||||
file_name: str
|
||||
file_type: str
|
||||
browser_compatibility: List[str]
|
||||
created_at: int
|
||||
updated_at: int
|
||||
source_kind: str
|
||||
|
||||
|
||||
class Extension(_ExtensionRequired, total=False):
|
||||
manifest_name: str
|
||||
sync_enabled: bool
|
||||
last_sync: int
|
||||
version: str
|
||||
description: str
|
||||
author: str
|
||||
homepage_url: str
|
||||
linked_path: str
|
||||
|
||||
|
||||
class _ExtensionGroupRequired(TypedDict):
|
||||
id: str
|
||||
name: str
|
||||
extension_ids: List[str]
|
||||
created_at: int
|
||||
updated_at: int
|
||||
|
||||
|
||||
class ExtensionGroup(_ExtensionGroupRequired, total=False):
|
||||
sync_enabled: bool
|
||||
last_sync: int
|
||||
|
||||
|
||||
class LocatorAttribute(TypedDict):
|
||||
name: str
|
||||
value: str
|
||||
|
||||
|
||||
class LocatorDescription(TypedDict, total=False):
|
||||
"""How an element is named without a CSS selector.
|
||||
|
||||
At least one key must be set. Keys are the browser's own camelCase; the
|
||||
app also accepts ``name_contains`` and ``text_contains`` on input, but a
|
||||
locator handed back by ``agent_pick`` uses the spellings below, so reusing
|
||||
one verbatim is the reliable path.
|
||||
"""
|
||||
|
||||
role: str
|
||||
name: str
|
||||
nameContains: str
|
||||
text: str
|
||||
textContains: str
|
||||
attributes: List[LocatorAttribute]
|
||||
|
||||
|
||||
class LocatorBounds(TypedDict):
|
||||
x: float
|
||||
y: float
|
||||
width: float
|
||||
height: float
|
||||
|
||||
|
||||
class _LocatorCandidateRequired(TypedDict):
|
||||
role: str
|
||||
name: str
|
||||
text: str
|
||||
signature: str
|
||||
bounds: LocatorBounds
|
||||
|
||||
|
||||
class LocatorCandidate(_LocatorCandidateRequired, total=False):
|
||||
backendNodeId: int
|
||||
value: str
|
||||
url: str
|
||||
attributes: List[LocatorAttribute]
|
||||
|
||||
|
||||
class _LocatorResolutionRequired(TypedDict):
|
||||
matchCount: int
|
||||
# `match` is the key the app sends. It is a soft keyword in Python, so it
|
||||
# is spelled here exactly as it arrives.
|
||||
match: LocatorCandidate
|
||||
locator: LocatorDescription
|
||||
engine: str
|
||||
|
||||
|
||||
class LocatorResolution(_LocatorResolutionRequired, total=False):
|
||||
backendNodeId: int
|
||||
|
||||
|
||||
class _PerceptionNodeRequired(TypedDict):
|
||||
id: str
|
||||
frameId: str
|
||||
role: str
|
||||
x: float
|
||||
y: float
|
||||
width: float
|
||||
height: float
|
||||
inViewport: bool
|
||||
visible: bool
|
||||
focused: bool
|
||||
disabled: bool
|
||||
|
||||
|
||||
class PerceptionNode(_PerceptionNodeRequired, total=False):
|
||||
parentId: str
|
||||
name: str
|
||||
text: str
|
||||
value: str
|
||||
checked: str
|
||||
expanded: bool
|
||||
scrollable: bool
|
||||
scrollContainerId: str
|
||||
|
||||
|
||||
class _PerceptionFrameRequired(TypedDict):
|
||||
frameId: str
|
||||
url: str
|
||||
crossOrigin: bool
|
||||
|
||||
|
||||
class PerceptionFrame(_PerceptionFrameRequired, total=False):
|
||||
parentFrameId: str
|
||||
|
||||
|
||||
class PerceptionStats(TypedDict):
|
||||
totalNodes: int
|
||||
returnedNodes: int
|
||||
bytes: int
|
||||
elapsedMs: int
|
||||
framesVisited: int
|
||||
framesFailed: int
|
||||
|
||||
|
||||
class _PerceptionPageRequired(TypedDict):
|
||||
snapshotId: str
|
||||
nodes: List[PerceptionNode]
|
||||
frames: List[PerceptionFrame]
|
||||
text: str
|
||||
truncated: bool
|
||||
stats: PerceptionStats
|
||||
engine: str
|
||||
|
||||
|
||||
class PerceptionPage(_PerceptionPageRequired, total=False):
|
||||
cursor: str
|
||||
|
||||
|
||||
class _ExtractionFieldRequired(TypedDict):
|
||||
key: str
|
||||
locator: LocatorDescription
|
||||
source: str
|
||||
|
||||
|
||||
class ExtractionField(_ExtractionFieldRequired, total=False):
|
||||
"""One output column. ``attribute`` is required when ``source`` is ``"attribute"``."""
|
||||
|
||||
attribute: str
|
||||
|
||||
|
||||
class ExtractionRow(TypedDict):
|
||||
index: int
|
||||
page: int
|
||||
values: Dict[str, Any]
|
||||
|
||||
|
||||
class Extraction(TypedDict):
|
||||
rows: List[ExtractionRow]
|
||||
rowCount: int
|
||||
pageCount: int
|
||||
byteSize: int
|
||||
truncated: bool
|
||||
stopReason: str
|
||||
engine: str
|
||||
|
||||
|
||||
class PickedElement(TypedDict):
|
||||
backendNodeId: int
|
||||
locator: LocatorDescription
|
||||
matchCount: int
|
||||
node: LocatorCandidate
|
||||
engine: str
|
||||
|
||||
|
||||
class AgentClick(TypedDict):
|
||||
"""What a click did. Note the snake_case body and the ``match`` key."""
|
||||
|
||||
clicked: bool
|
||||
match: LocatorCandidate
|
||||
engine: str
|
||||
navigated: bool
|
||||
|
||||
|
||||
class _AgentTypingRequired(TypedDict):
|
||||
typed: bool
|
||||
characters: int
|
||||
duration_ms: float
|
||||
engine: str
|
||||
match: LocatorCandidate
|
||||
|
||||
|
||||
class AgentTyping(_AgentTypingRequired, total=False):
|
||||
"""What a typing call did.
|
||||
|
||||
``corrections`` is absent on the fallback engine, which does not count its
|
||||
own mistypes.
|
||||
"""
|
||||
|
||||
corrections: int
|
||||
@@ -0,0 +1,31 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Iterator
|
||||
|
||||
import pytest
|
||||
|
||||
# Run against the working tree without an install step, so `pytest` works
|
||||
# straight after a checkout.
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
||||
|
||||
from donutbrowser import DonutClient # noqa: E402
|
||||
from fake_donut import FakeDonut # noqa: E402
|
||||
|
||||
TOKEN = "test-token-abc123"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake() -> Iterator[FakeDonut]:
|
||||
server = FakeDonut().start()
|
||||
try:
|
||||
yield server
|
||||
finally:
|
||||
server.stop()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(fake: FakeDonut) -> Iterator[DonutClient]:
|
||||
with DonutClient(token=TOKEN, port=fake.port, timeout=5.0, env={}) as connected:
|
||||
yield connected
|
||||
@@ -0,0 +1,144 @@
|
||||
"""A stand-in for the desktop app's local REST API.
|
||||
|
||||
It records what the client sent, byte for byte, and answers with whatever the
|
||||
test queued. Nothing here reaches the network: it binds an ephemeral loopback
|
||||
port and is torn down with the test.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import threading
|
||||
from dataclasses import dataclass, field
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
from urllib.parse import parse_qsl, urlsplit
|
||||
|
||||
|
||||
@dataclass
|
||||
class RecordedRequest:
|
||||
method: str
|
||||
target: str
|
||||
headers: Dict[str, str]
|
||||
body: bytes
|
||||
|
||||
@property
|
||||
def path(self) -> str:
|
||||
return urlsplit(self.target).path
|
||||
|
||||
@property
|
||||
def query(self) -> Dict[str, str]:
|
||||
return dict(parse_qsl(urlsplit(self.target).query, keep_blank_values=True))
|
||||
|
||||
@property
|
||||
def json(self) -> Any:
|
||||
if not self.body:
|
||||
return None
|
||||
return json.loads(self.body.decode("utf-8"))
|
||||
|
||||
def header(self, name: str) -> Optional[str]:
|
||||
for key, value in self.headers.items():
|
||||
if key.lower() == name.lower():
|
||||
return value
|
||||
return None
|
||||
|
||||
|
||||
@dataclass
|
||||
class QueuedResponse:
|
||||
status: int = 200
|
||||
body: str = ""
|
||||
headers: Tuple[Tuple[str, str], ...] = ()
|
||||
content_type: str = "application/json"
|
||||
|
||||
|
||||
@dataclass
|
||||
class FakeDonut:
|
||||
"""Queue responses, then read :attr:`requests` back."""
|
||||
|
||||
requests: List[RecordedRequest] = field(default_factory=list)
|
||||
responses: List[QueuedResponse] = field(default_factory=list)
|
||||
_server: Optional[ThreadingHTTPServer] = None
|
||||
_thread: Optional[threading.Thread] = None
|
||||
|
||||
def enqueue_json(self, payload: Any, status: int = 200) -> None:
|
||||
self.responses.append(QueuedResponse(status=status, body=json.dumps(payload)))
|
||||
|
||||
def enqueue_empty(self, status: int = 204) -> None:
|
||||
self.responses.append(QueuedResponse(status=status, body=""))
|
||||
|
||||
def enqueue_error(
|
||||
self,
|
||||
status: int,
|
||||
body: str = "",
|
||||
headers: Tuple[Tuple[str, str], ...] = (),
|
||||
) -> None:
|
||||
self.responses.append(
|
||||
QueuedResponse(status=status, body=body, headers=headers, content_type="text/plain")
|
||||
)
|
||||
|
||||
@property
|
||||
def port(self) -> int:
|
||||
assert self._server is not None, "the fake server is not running"
|
||||
return self._server.server_address[1]
|
||||
|
||||
@property
|
||||
def last(self) -> RecordedRequest:
|
||||
assert self.requests, "the client sent nothing"
|
||||
return self.requests[-1]
|
||||
|
||||
def start(self) -> "FakeDonut":
|
||||
fake = self
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
protocol_version = "HTTP/1.1"
|
||||
|
||||
def log_message(self, *_args: Any) -> None:
|
||||
"""Keep the test output clean."""
|
||||
|
||||
def _handle(self) -> None:
|
||||
length = int(self.headers.get("Content-Length") or 0)
|
||||
body = self.rfile.read(length) if length else b""
|
||||
fake.requests.append(
|
||||
RecordedRequest(
|
||||
method=self.command,
|
||||
target=self.path,
|
||||
headers={key: value for key, value in self.headers.items()},
|
||||
body=body,
|
||||
)
|
||||
)
|
||||
|
||||
queued = fake.responses.pop(0) if fake.responses else QueuedResponse(body="{}")
|
||||
payload = queued.body.encode("utf-8")
|
||||
self.send_response(queued.status)
|
||||
for name, value in queued.headers:
|
||||
self.send_header(name, value)
|
||||
if payload:
|
||||
self.send_header("Content-Type", queued.content_type)
|
||||
self.send_header("Content-Length", str(len(payload)))
|
||||
self.end_headers()
|
||||
if payload:
|
||||
self.wfile.write(payload)
|
||||
|
||||
do_GET = _handle
|
||||
do_POST = _handle
|
||||
do_PUT = _handle
|
||||
do_DELETE = _handle
|
||||
do_PATCH = _handle
|
||||
|
||||
self._server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
||||
# A short poll interval so `shutdown()` returns promptly: the default
|
||||
# 0.5s would add half a second to the teardown of every single test.
|
||||
self._thread = threading.Thread(
|
||||
target=self._server.serve_forever, kwargs={"poll_interval": 0.01}, daemon=True
|
||||
)
|
||||
self._thread.start()
|
||||
return self
|
||||
|
||||
def stop(self) -> None:
|
||||
if self._server is not None:
|
||||
self._server.shutdown()
|
||||
self._server.server_close()
|
||||
self._server = None
|
||||
if self._thread is not None:
|
||||
self._thread.join(timeout=5)
|
||||
self._thread = None
|
||||
@@ -0,0 +1,83 @@
|
||||
"""Where the token and the port come from, and in what order."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from fake_donut import FakeDonut
|
||||
|
||||
from donutbrowser import DEFAULT_HOST, DEFAULT_PORT, DonutClient, DonutError
|
||||
|
||||
|
||||
def test_arguments_are_used_as_given() -> None:
|
||||
client = DonutClient(token="from-argument", port=12345, env={})
|
||||
assert client.token == "from-argument"
|
||||
assert client.port == 12345
|
||||
assert client.host == DEFAULT_HOST
|
||||
assert client.base_url == "http://127.0.0.1:12345"
|
||||
|
||||
|
||||
def test_the_environment_fills_in_what_was_not_passed() -> None:
|
||||
client = DonutClient(env={"DONUT_API_TOKEN": "from-env", "DONUT_API_PORT": "13579"})
|
||||
assert client.token == "from-env"
|
||||
assert client.port == 13579
|
||||
|
||||
|
||||
def test_arguments_win_over_the_environment() -> None:
|
||||
client = DonutClient(
|
||||
token="from-argument",
|
||||
port=111,
|
||||
env={"DONUT_API_TOKEN": "from-env", "DONUT_API_PORT": "222"},
|
||||
)
|
||||
assert client.token == "from-argument"
|
||||
assert client.port == 111
|
||||
|
||||
|
||||
def test_the_port_falls_back_to_the_app_default() -> None:
|
||||
client = DonutClient(env={"DONUT_API_TOKEN": "t"})
|
||||
assert client.port == DEFAULT_PORT == 10108
|
||||
|
||||
|
||||
def test_a_base_url_overrides_host_and_port() -> None:
|
||||
client = DonutClient(
|
||||
base_url="http://127.0.0.1:9999/donut",
|
||||
token="t",
|
||||
env={"DONUT_API_PORT": "222"},
|
||||
)
|
||||
assert client.port == 9999
|
||||
assert client.base_url == "http://127.0.0.1:9999/donut"
|
||||
|
||||
|
||||
def test_a_base_url_prefix_is_kept_on_every_path(fake: FakeDonut) -> None:
|
||||
with DonutClient(
|
||||
base_url=f"http://127.0.0.1:{fake.port}/donut", token="t", timeout=5.0, env={}
|
||||
) as client:
|
||||
client.list_profiles()
|
||||
assert fake.last.path == "/donut/v1/profiles"
|
||||
|
||||
|
||||
def test_an_unusable_port_in_the_environment_is_reported() -> None:
|
||||
with pytest.raises(DonutError) as raised:
|
||||
DonutClient(env={"DONUT_API_TOKEN": "t", "DONUT_API_PORT": "not-a-number"})
|
||||
assert "DONUT_API_PORT" in str(raised.value)
|
||||
|
||||
|
||||
def test_an_unsupported_scheme_is_refused() -> None:
|
||||
with pytest.raises(DonutError):
|
||||
DonutClient(base_url="ftp://127.0.0.1:9999", token="t", env={})
|
||||
|
||||
|
||||
def test_the_websocket_address_is_built_from_the_same_base() -> None:
|
||||
client = DonutClient(token="t", port=10108, env={})
|
||||
assert (
|
||||
client.remote_session_cdp_url("s 1")
|
||||
== "ws://127.0.0.1:10108/v1/remote-sessions/s%201/cdp"
|
||||
)
|
||||
|
||||
|
||||
def test_a_reopened_client_still_works(fake: FakeDonut) -> None:
|
||||
"""`close()` drops the socket; the next call has to open a new one."""
|
||||
with DonutClient(token="t", port=fake.port, timeout=5.0, env={}) as client:
|
||||
client.list_profiles()
|
||||
client.close()
|
||||
client.list_profiles()
|
||||
assert len(fake.requests) == 2
|
||||
@@ -0,0 +1,73 @@
|
||||
"""The SDK cannot silently drift from the app's API.
|
||||
|
||||
``sdk/api-paths.json`` is generated from ``src-tauri/src/api_server.rs`` and
|
||||
lists every operation the desktop app publishes. These tests hold it against
|
||||
the SDK's own table in both directions, so a new endpoint in the app fails here
|
||||
until it is wrapped or deliberately omitted with a reason.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Set, Tuple
|
||||
|
||||
from donutbrowser import DonutClient
|
||||
from donutbrowser.coverage import OMITTED, OPERATIONS
|
||||
|
||||
SNAPSHOT = Path(__file__).resolve().parents[2] / "api-paths.json"
|
||||
|
||||
|
||||
def published() -> Set[Tuple[str, str]]:
|
||||
document: Dict[str, Any] = json.loads(SNAPSHOT.read_text(encoding="utf-8"))
|
||||
return {
|
||||
(operation["method"], operation["path"]) for operation in document["operations"]
|
||||
}
|
||||
|
||||
|
||||
def test_the_snapshot_is_readable_and_not_empty() -> None:
|
||||
document = json.loads(SNAPSHOT.read_text(encoding="utf-8"))
|
||||
assert document["source"] == "src-tauri/src/api_server.rs"
|
||||
assert document["operation_count"] == len(document["operations"])
|
||||
assert document["operation_count"] > 0
|
||||
assert len(published()) == document["operation_count"], "the app has two identical operations"
|
||||
|
||||
|
||||
def test_every_published_operation_is_wrapped_or_omitted() -> None:
|
||||
known = set(OPERATIONS) | set(OMITTED)
|
||||
missing = sorted(published() - known)
|
||||
assert not missing, (
|
||||
"the app publishes operations this SDK does not handle: "
|
||||
f"{missing}. Wrap each one, or add it to coverage.OMITTED with a reason."
|
||||
)
|
||||
|
||||
|
||||
def test_the_sdk_claims_nothing_the_app_does_not_publish() -> None:
|
||||
stale = sorted((set(OPERATIONS) | set(OMITTED)) - published())
|
||||
assert not stale, (
|
||||
"this SDK handles operations the app no longer publishes: "
|
||||
f"{stale}. Regenerate the snapshot with sdk/tools/extract-api-paths.py, "
|
||||
"then drop or fix each entry."
|
||||
)
|
||||
|
||||
|
||||
def test_an_operation_is_either_wrapped_or_omitted_but_not_both() -> None:
|
||||
both = sorted(set(OPERATIONS) & set(OMITTED))
|
||||
assert not both, f"listed twice: {both}"
|
||||
|
||||
|
||||
def test_every_omission_gives_a_reason() -> None:
|
||||
for operation, reason in OMITTED.items():
|
||||
assert len(reason.strip()) > 40, f"{operation} is omitted without a real reason"
|
||||
|
||||
|
||||
def test_every_wrapped_operation_names_a_real_method() -> None:
|
||||
for operation, method_name in OPERATIONS.items():
|
||||
attribute = getattr(DonutClient, method_name, None)
|
||||
assert callable(attribute), f"{operation} names {method_name}, which is not a method"
|
||||
|
||||
|
||||
def test_no_two_operations_share_a_method() -> None:
|
||||
names = list(OPERATIONS.values())
|
||||
duplicates = sorted({name for name in names if names.count(name) > 1})
|
||||
assert not duplicates, f"one method is claimed by several operations: {duplicates}"
|
||||
@@ -0,0 +1,168 @@
|
||||
"""Each status the app documents raises its own exception."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from fake_donut import FakeDonut, QueuedResponse
|
||||
|
||||
from donutbrowser import (
|
||||
BadGateway,
|
||||
Conflict,
|
||||
DonutAPIError,
|
||||
DonutClient,
|
||||
DonutConnectionError,
|
||||
DonutError,
|
||||
Forbidden,
|
||||
NotFound,
|
||||
PaymentRequired,
|
||||
RateLimited,
|
||||
RequestTimeout,
|
||||
ServerError,
|
||||
ServiceUnavailable,
|
||||
Unauthorized,
|
||||
ValidationError,
|
||||
)
|
||||
|
||||
STATUS_TO_ERROR = [
|
||||
(400, ValidationError),
|
||||
(401, Unauthorized),
|
||||
(402, PaymentRequired),
|
||||
(403, Forbidden),
|
||||
(404, NotFound),
|
||||
(408, RequestTimeout),
|
||||
(409, Conflict),
|
||||
(429, RateLimited),
|
||||
(500, ServerError),
|
||||
(502, BadGateway),
|
||||
(503, ServiceUnavailable),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("status,expected", STATUS_TO_ERROR)
|
||||
def test_status_maps_to_its_exception(
|
||||
client: DonutClient, fake: FakeDonut, status: int, expected: type
|
||||
) -> None:
|
||||
fake.enqueue_error(status, "something went wrong")
|
||||
with pytest.raises(expected) as raised:
|
||||
client.list_profiles()
|
||||
assert raised.value.status == status
|
||||
assert raised.value.body == "something went wrong"
|
||||
assert raised.value.method == "GET"
|
||||
assert raised.value.path == "/v1/profiles"
|
||||
|
||||
|
||||
def test_every_error_is_a_donut_error(client: DonutClient, fake: FakeDonut) -> None:
|
||||
fake.enqueue_error(404, "PROFILE_NOT_FOUND")
|
||||
with pytest.raises(DonutError):
|
||||
client.get_profile("nope")
|
||||
|
||||
|
||||
def test_the_five_hundreds_share_one_base(client: DonutClient, fake: FakeDonut) -> None:
|
||||
"""`except ServerError` has to catch 502 and 503 as well as 500."""
|
||||
for status in (500, 502, 503):
|
||||
fake.enqueue_error(status, "upstream")
|
||||
with pytest.raises(ServerError):
|
||||
client.list_profiles()
|
||||
|
||||
|
||||
def test_rate_limited_carries_retry_after(client: DonutClient, fake: FakeDonut) -> None:
|
||||
fake.enqueue_error(
|
||||
429,
|
||||
"automation request rate limit exceeded",
|
||||
headers=(("Retry-After", "42"),),
|
||||
)
|
||||
with pytest.raises(RateLimited) as raised:
|
||||
client.run_profile("p1")
|
||||
assert raised.value.retry_after == 42
|
||||
|
||||
|
||||
def test_rate_limited_without_the_header_is_still_raised(
|
||||
client: DonutClient, fake: FakeDonut
|
||||
) -> None:
|
||||
fake.enqueue_error(429, "slow down")
|
||||
with pytest.raises(RateLimited) as raised:
|
||||
client.run_profile("p1")
|
||||
assert raised.value.retry_after is None
|
||||
|
||||
|
||||
def test_an_unreadable_retry_after_does_not_break_the_error(
|
||||
client: DonutClient, fake: FakeDonut
|
||||
) -> None:
|
||||
fake.enqueue_error(429, "slow down", headers=(("Retry-After", "Wed, 21 Oct 2026 07:28:00 GMT"),))
|
||||
with pytest.raises(RateLimited) as raised:
|
||||
client.run_profile("p1")
|
||||
assert raised.value.retry_after is None
|
||||
|
||||
|
||||
def test_a_structured_code_body_is_decoded(client: DonutClient, fake: FakeDonut) -> None:
|
||||
"""The app shares `{"code": ...}` strings with its own frontend."""
|
||||
fake.enqueue_error(400, json.dumps({"code": "NAME_CANNOT_BE_EMPTY"}))
|
||||
with pytest.raises(ValidationError) as raised:
|
||||
client.create_group(name="")
|
||||
assert raised.value.code == "NAME_CANNOT_BE_EMPTY"
|
||||
assert raised.value.params == {}
|
||||
|
||||
|
||||
def test_a_structured_code_body_keeps_its_params(client: DonutClient, fake: FakeDonut) -> None:
|
||||
fake.enqueue_error(409, json.dumps({"code": "PROFILE_LOCKED_BY_MEMBER", "params": {"n": "5"}}))
|
||||
with pytest.raises(Conflict) as raised:
|
||||
client.run_profile("p1")
|
||||
assert raised.value.code == "PROFILE_LOCKED_BY_MEMBER"
|
||||
assert raised.value.params == {"n": "5"}
|
||||
|
||||
|
||||
def test_a_plain_text_body_leaves_code_unset(client: DonutClient, fake: FakeDonut) -> None:
|
||||
fake.enqueue_error(400, "invalid browser")
|
||||
with pytest.raises(ValidationError) as raised:
|
||||
client.create_profile(name="x", browser="chromium")
|
||||
assert raised.value.code is None
|
||||
assert raised.value.body == "invalid browser"
|
||||
|
||||
|
||||
def test_an_undocumented_status_still_raises_something_catchable(
|
||||
client: DonutClient, fake: FakeDonut
|
||||
) -> None:
|
||||
fake.enqueue_error(418, "teapot")
|
||||
with pytest.raises(DonutAPIError) as raised:
|
||||
client.list_profiles()
|
||||
assert raised.value.status == 418
|
||||
|
||||
|
||||
def test_an_undocumented_server_status_is_a_server_error(
|
||||
client: DonutClient, fake: FakeDonut
|
||||
) -> None:
|
||||
fake.enqueue_error(504, "gateway timeout")
|
||||
with pytest.raises(ServerError):
|
||||
client.list_profiles()
|
||||
|
||||
|
||||
def test_the_message_names_the_call(client: DonutClient, fake: FakeDonut) -> None:
|
||||
fake.enqueue_error(404, "Profile not found")
|
||||
with pytest.raises(NotFound) as raised:
|
||||
client.get_profile("missing")
|
||||
assert "404" in str(raised.value)
|
||||
assert "GET /v1/profiles/missing" in str(raised.value)
|
||||
|
||||
|
||||
def test_an_unreachable_app_is_not_an_api_error(fake: FakeDonut) -> None:
|
||||
port = fake.port
|
||||
fake.stop()
|
||||
with DonutClient(token="t", port=port, timeout=2.0, env={}) as client:
|
||||
with pytest.raises(DonutConnectionError) as raised:
|
||||
client.list_profiles()
|
||||
assert "Local API" in str(raised.value)
|
||||
|
||||
|
||||
def test_a_missing_token_fails_before_any_request() -> None:
|
||||
with pytest.raises(DonutError) as raised:
|
||||
DonutClient(env={})
|
||||
assert "DONUT_API_TOKEN" in str(raised.value)
|
||||
|
||||
|
||||
def test_a_non_json_answer_is_reported_as_such(client: DonutClient, fake: FakeDonut) -> None:
|
||||
fake.responses.append(QueuedResponse(status=200, body="<html>nope</html>"))
|
||||
with pytest.raises(DonutError) as raised:
|
||||
client.list_profiles()
|
||||
assert "not JSON" in str(raised.value)
|
||||
@@ -0,0 +1,721 @@
|
||||
"""Every client method sends exactly the request the app documents.
|
||||
|
||||
The table below is the whole public surface. Each row names a method, the
|
||||
arguments to call it with, and the request that must appear on the wire: the
|
||||
verb, the concrete path, the query string and the JSON body. ``operation`` is
|
||||
the path template the app publishes, which ties this file to
|
||||
``donutbrowser.coverage.OPERATIONS`` and, through it, to ``sdk/api-paths.json``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
import pytest
|
||||
from fake_donut import FakeDonut
|
||||
|
||||
from donutbrowser import DonutClient
|
||||
from donutbrowser.coverage import OPERATIONS
|
||||
|
||||
Case = Tuple[
|
||||
str, # client method
|
||||
Tuple[Any, ...], # positional arguments
|
||||
Dict[str, Any], # keyword arguments
|
||||
str, # expected verb
|
||||
str, # expected concrete path
|
||||
Optional[Dict[str, Any]], # expected JSON body, or None for no body
|
||||
Dict[str, str], # expected query string
|
||||
str, # operation template, as published by the app
|
||||
]
|
||||
|
||||
LOCATOR = {"role": "button", "name": "Sign in"}
|
||||
|
||||
CASES: List[Case] = [
|
||||
# -- profiles ----------------------------------------------------------
|
||||
("list_profiles", (), {}, "GET", "/v1/profiles", None, {}, "/v1/profiles"),
|
||||
("get_profile", ("p1",), {}, "GET", "/v1/profiles/p1", None, {}, "/v1/profiles/{id}"),
|
||||
(
|
||||
"create_profile",
|
||||
(),
|
||||
{"name": "Shopper", "browser": "wayfern", "tags": ["eu"], "ephemeral": True},
|
||||
"POST",
|
||||
"/v1/profiles",
|
||||
{"name": "Shopper", "browser": "wayfern", "tags": ["eu"], "ephemeral": True},
|
||||
{},
|
||||
"/v1/profiles",
|
||||
),
|
||||
(
|
||||
"create_profile",
|
||||
(),
|
||||
{"name": "Bare", "browser": "wayfern"},
|
||||
"POST",
|
||||
"/v1/profiles",
|
||||
{"name": "Bare", "browser": "wayfern"},
|
||||
{},
|
||||
"/v1/profiles",
|
||||
),
|
||||
(
|
||||
"update_profile",
|
||||
("p1",),
|
||||
{"name": "Renamed", "proxy_id": "", "clear_on_close": False},
|
||||
"PUT",
|
||||
"/v1/profiles/p1",
|
||||
{"name": "Renamed", "proxy_id": "", "clear_on_close": False},
|
||||
{},
|
||||
"/v1/profiles/{id}",
|
||||
),
|
||||
("delete_profile", ("p1",), {}, "DELETE", "/v1/profiles/p1", None, {}, "/v1/profiles/{id}"),
|
||||
(
|
||||
"run_profile",
|
||||
("p1",),
|
||||
{"url": "https://example.com", "headless": True},
|
||||
"POST",
|
||||
"/v1/profiles/p1/run",
|
||||
{"url": "https://example.com", "headless": True},
|
||||
{},
|
||||
"/v1/profiles/{id}/run",
|
||||
),
|
||||
(
|
||||
"run_profile_remote",
|
||||
("p1",),
|
||||
{"url": "https://example.com"},
|
||||
"POST",
|
||||
"/v1/profiles/p1/run-remote",
|
||||
{"url": "https://example.com"},
|
||||
{},
|
||||
"/v1/profiles/{id}/run-remote",
|
||||
),
|
||||
(
|
||||
"set_profile_cloud_sync",
|
||||
("p1",),
|
||||
{"mode": "Regular"},
|
||||
"POST",
|
||||
"/v1/profiles/p1/cloud-sync",
|
||||
{"mode": "Regular"},
|
||||
{},
|
||||
"/v1/profiles/{id}/cloud-sync",
|
||||
),
|
||||
(
|
||||
"open_url",
|
||||
("p1", "https://example.com/page"),
|
||||
{},
|
||||
"POST",
|
||||
"/v1/profiles/p1/open-url",
|
||||
{"url": "https://example.com/page"},
|
||||
{},
|
||||
"/v1/profiles/{id}/open-url",
|
||||
),
|
||||
(
|
||||
"kill_profile",
|
||||
("p1",),
|
||||
{},
|
||||
"POST",
|
||||
"/v1/profiles/p1/kill",
|
||||
None,
|
||||
{},
|
||||
"/v1/profiles/{id}/kill",
|
||||
),
|
||||
(
|
||||
"batch_run_profiles",
|
||||
(["p1", "p2"],),
|
||||
{"headless": False},
|
||||
"POST",
|
||||
"/v1/profiles/batch/run",
|
||||
{"profile_ids": ["p1", "p2"], "headless": False},
|
||||
{},
|
||||
"/v1/profiles/batch/run",
|
||||
),
|
||||
(
|
||||
"batch_stop_profiles",
|
||||
(["p1", "p2"],),
|
||||
{},
|
||||
"POST",
|
||||
"/v1/profiles/batch/stop",
|
||||
{"profile_ids": ["p1", "p2"]},
|
||||
{},
|
||||
"/v1/profiles/batch/stop",
|
||||
),
|
||||
(
|
||||
"distribute_proxies",
|
||||
([{"profile_id": "p1", "proxy_id": "x1"}, {"profile_id": "p2", "proxy_id": "x2"}],),
|
||||
{},
|
||||
"POST",
|
||||
"/v1/profiles/distribute-proxies",
|
||||
{
|
||||
"pairs": [
|
||||
{"profile_id": "p1", "proxy_id": "x1"},
|
||||
{"profile_id": "p2", "proxy_id": "x2"},
|
||||
]
|
||||
},
|
||||
{},
|
||||
"/v1/profiles/distribute-proxies",
|
||||
),
|
||||
(
|
||||
"detect_import_profiles",
|
||||
(),
|
||||
{"folder": "/Users/x/Chrome"},
|
||||
"GET",
|
||||
"/v1/profiles/import/detect",
|
||||
None,
|
||||
{"folder": "/Users/x/Chrome"},
|
||||
"/v1/profiles/import/detect",
|
||||
),
|
||||
(
|
||||
"detect_import_profiles",
|
||||
(),
|
||||
{},
|
||||
"GET",
|
||||
"/v1/profiles/import/detect",
|
||||
None,
|
||||
{},
|
||||
"/v1/profiles/import/detect",
|
||||
),
|
||||
(
|
||||
"import_profiles",
|
||||
([{"source_path": "/tmp/src", "new_profile_name": "Imported"}],),
|
||||
{"duplicate_strategy": "skip"},
|
||||
"POST",
|
||||
"/v1/profiles/import",
|
||||
{
|
||||
"items": [{"source_path": "/tmp/src", "new_profile_name": "Imported"}],
|
||||
"duplicate_strategy": "skip",
|
||||
},
|
||||
{},
|
||||
"/v1/profiles/import",
|
||||
),
|
||||
(
|
||||
"import_profile_cookies",
|
||||
("p1",),
|
||||
{"content": "[]"},
|
||||
"POST",
|
||||
"/v1/profiles/p1/cookies/import",
|
||||
{"content": "[]"},
|
||||
{},
|
||||
"/v1/profiles/{id}/cookies/import",
|
||||
),
|
||||
# -- agent -------------------------------------------------------------
|
||||
(
|
||||
"agent_perceive",
|
||||
("p1",),
|
||||
{"viewport_only": True, "max_bytes": 2048},
|
||||
"POST",
|
||||
"/v1/profiles/p1/agent/perceive",
|
||||
{"max_bytes": 2048, "viewport_only": True},
|
||||
{},
|
||||
"/v1/profiles/{id}/agent/perceive",
|
||||
),
|
||||
(
|
||||
"agent_perceive",
|
||||
("p1",),
|
||||
{},
|
||||
"POST",
|
||||
"/v1/profiles/p1/agent/perceive",
|
||||
{},
|
||||
{},
|
||||
"/v1/profiles/{id}/agent/perceive",
|
||||
),
|
||||
(
|
||||
"agent_resolve_locator",
|
||||
("p1",),
|
||||
{"locator": LOCATOR, "candidate_limit": 5},
|
||||
"POST",
|
||||
"/v1/profiles/p1/agent/resolve-locator",
|
||||
{"locator": LOCATOR, "candidate_limit": 5},
|
||||
{},
|
||||
"/v1/profiles/{id}/agent/resolve-locator",
|
||||
),
|
||||
(
|
||||
"agent_click",
|
||||
("p1",),
|
||||
{"locator": LOCATOR, "button": "right", "click_count": 2},
|
||||
"POST",
|
||||
"/v1/profiles/p1/agent/click",
|
||||
{"locator": LOCATOR, "button": "right", "click_count": 2},
|
||||
{},
|
||||
"/v1/profiles/{id}/agent/click",
|
||||
),
|
||||
(
|
||||
"agent_type",
|
||||
("p1",),
|
||||
{"locator": LOCATOR, "text": "hello", "clear_first": False, "wpm": 55.0},
|
||||
"POST",
|
||||
"/v1/profiles/p1/agent/type",
|
||||
{"locator": LOCATOR, "text": "hello", "clear_first": False, "wpm": 55.0},
|
||||
{},
|
||||
"/v1/profiles/{id}/agent/type",
|
||||
),
|
||||
(
|
||||
"agent_extract",
|
||||
("p1",),
|
||||
{
|
||||
"container": {"role": "listitem"},
|
||||
"field_map": [{"key": "title", "locator": {"role": "heading"}, "source": "text"}],
|
||||
"max_pages": 3,
|
||||
},
|
||||
"POST",
|
||||
"/v1/profiles/p1/agent/extract",
|
||||
{
|
||||
"container": {"role": "listitem"},
|
||||
"field_map": [{"key": "title", "locator": {"role": "heading"}, "source": "text"}],
|
||||
"max_pages": 3,
|
||||
},
|
||||
{},
|
||||
"/v1/profiles/{id}/agent/extract",
|
||||
),
|
||||
(
|
||||
"agent_pick",
|
||||
("p1",),
|
||||
{"timeout_ms": 15000},
|
||||
"POST",
|
||||
"/v1/profiles/p1/agent/pick",
|
||||
{"timeout_ms": 15000},
|
||||
{},
|
||||
"/v1/profiles/{id}/agent/pick",
|
||||
),
|
||||
# -- remote sessions ---------------------------------------------------
|
||||
(
|
||||
"list_remote_sessions",
|
||||
(),
|
||||
{},
|
||||
"GET",
|
||||
"/v1/remote-sessions",
|
||||
None,
|
||||
{},
|
||||
"/v1/remote-sessions",
|
||||
),
|
||||
(
|
||||
"get_remote_session",
|
||||
("s1",),
|
||||
{},
|
||||
"GET",
|
||||
"/v1/remote-sessions/s1",
|
||||
None,
|
||||
{},
|
||||
"/v1/remote-sessions/{id}",
|
||||
),
|
||||
(
|
||||
"stop_remote_session",
|
||||
("s1",),
|
||||
{},
|
||||
"DELETE",
|
||||
"/v1/remote-sessions/s1",
|
||||
None,
|
||||
{},
|
||||
"/v1/remote-sessions/{id}",
|
||||
),
|
||||
("get_remote_hours", (), {}, "GET", "/v1/remote-hours", None, {}, "/v1/remote-hours"),
|
||||
# -- cookie bot --------------------------------------------------------
|
||||
(
|
||||
"list_cookie_bot_schedules",
|
||||
(),
|
||||
{"scope": "team"},
|
||||
"GET",
|
||||
"/v1/cookie-bot/schedules",
|
||||
None,
|
||||
{"scope": "team"},
|
||||
"/v1/cookie-bot/schedules",
|
||||
),
|
||||
(
|
||||
"get_cookie_bot_schedule",
|
||||
("p1",),
|
||||
{},
|
||||
"GET",
|
||||
"/v1/cookie-bot/schedules/p1",
|
||||
None,
|
||||
{},
|
||||
"/v1/cookie-bot/schedules/{profile_id}",
|
||||
),
|
||||
(
|
||||
"set_cookie_bot_schedule",
|
||||
("p1",),
|
||||
{
|
||||
"enabled": True,
|
||||
"run_at_minute": 120,
|
||||
"days_mask": 31,
|
||||
"timezone": "Europe/Berlin",
|
||||
"preset": "steady",
|
||||
"max_minutes": 45,
|
||||
"sites": ["https://example.com"],
|
||||
"acknowledge_conflict": True,
|
||||
},
|
||||
"PUT",
|
||||
"/v1/cookie-bot/schedules/p1",
|
||||
{
|
||||
"enabled": True,
|
||||
"run_at_minute": 120,
|
||||
"days_mask": 31,
|
||||
"timezone": "Europe/Berlin",
|
||||
"preset": "steady",
|
||||
"max_minutes": 45,
|
||||
"sites": ["https://example.com"],
|
||||
"acknowledge_conflict": True,
|
||||
},
|
||||
{},
|
||||
"/v1/cookie-bot/schedules/{profile_id}",
|
||||
),
|
||||
(
|
||||
"delete_cookie_bot_schedule",
|
||||
("p1",),
|
||||
{},
|
||||
"DELETE",
|
||||
"/v1/cookie-bot/schedules/p1",
|
||||
None,
|
||||
{},
|
||||
"/v1/cookie-bot/schedules/{profile_id}",
|
||||
),
|
||||
(
|
||||
"get_cookie_bot_conflicts",
|
||||
("p1",),
|
||||
{"run_at_minute": 90, "timezone": "UTC", "days_mask": 7},
|
||||
"GET",
|
||||
"/v1/cookie-bot/conflicts",
|
||||
None,
|
||||
{"profile_id": "p1", "run_at_minute": "90", "timezone": "UTC", "days_mask": "7"},
|
||||
"/v1/cookie-bot/conflicts",
|
||||
),
|
||||
(
|
||||
"list_cookie_bot_runs",
|
||||
(),
|
||||
{"profile_id": "p1", "limit": 10, "before": "cursor-1"},
|
||||
"GET",
|
||||
"/v1/cookie-bot/runs",
|
||||
None,
|
||||
{"profile_id": "p1", "limit": "10", "before": "cursor-1"},
|
||||
"/v1/cookie-bot/runs",
|
||||
),
|
||||
(
|
||||
"start_cookie_bot_run",
|
||||
(),
|
||||
{"profile_id": "p1", "max_minutes": 30},
|
||||
"POST",
|
||||
"/v1/cookie-bot/runs",
|
||||
{"profile_id": "p1", "max_minutes": 30},
|
||||
{},
|
||||
"/v1/cookie-bot/runs",
|
||||
),
|
||||
(
|
||||
"cancel_cookie_bot_run",
|
||||
("r1",),
|
||||
{},
|
||||
"DELETE",
|
||||
"/v1/cookie-bot/runs/r1",
|
||||
None,
|
||||
{},
|
||||
"/v1/cookie-bot/runs/{run_id}",
|
||||
),
|
||||
(
|
||||
"list_cookie_bot_presets",
|
||||
(),
|
||||
{},
|
||||
"GET",
|
||||
"/v1/cookie-bot/presets",
|
||||
None,
|
||||
{},
|
||||
"/v1/cookie-bot/presets",
|
||||
),
|
||||
(
|
||||
"get_cookie_bot_usage",
|
||||
(),
|
||||
{"period": "2026-08"},
|
||||
"GET",
|
||||
"/v1/cookie-bot/usage",
|
||||
None,
|
||||
{"period": "2026-08"},
|
||||
"/v1/cookie-bot/usage",
|
||||
),
|
||||
# -- groups and tags ---------------------------------------------------
|
||||
("list_groups", (), {}, "GET", "/v1/groups", None, {}, "/v1/groups"),
|
||||
("get_group", ("g1",), {}, "GET", "/v1/groups/g1", None, {}, "/v1/groups/{id}"),
|
||||
("create_group", (), {"name": "Retail"}, "POST", "/v1/groups", {"name": "Retail"}, {}, "/v1/groups"),
|
||||
(
|
||||
"update_group",
|
||||
("g1",),
|
||||
{"name": "Retail EU"},
|
||||
"PUT",
|
||||
"/v1/groups/g1",
|
||||
{"name": "Retail EU"},
|
||||
{},
|
||||
"/v1/groups/{id}",
|
||||
),
|
||||
("delete_group", ("g1",), {}, "DELETE", "/v1/groups/g1", None, {}, "/v1/groups/{id}"),
|
||||
("list_tags", (), {}, "GET", "/v1/tags", None, {}, "/v1/tags"),
|
||||
# -- proxies -----------------------------------------------------------
|
||||
("list_proxies", (), {}, "GET", "/v1/proxies", None, {}, "/v1/proxies"),
|
||||
("get_proxy", ("x1",), {}, "GET", "/v1/proxies/x1", None, {}, "/v1/proxies/{id}"),
|
||||
(
|
||||
"create_proxy",
|
||||
(),
|
||||
{"name": "EU", "proxy_settings": {"proxy_type": "http", "host": "h", "port": 8080}},
|
||||
"POST",
|
||||
"/v1/proxies",
|
||||
{"name": "EU", "proxy_settings": {"proxy_type": "http", "host": "h", "port": 8080}},
|
||||
{},
|
||||
"/v1/proxies",
|
||||
),
|
||||
(
|
||||
"update_proxy",
|
||||
("x1",),
|
||||
{"name": "EU 2"},
|
||||
"PUT",
|
||||
"/v1/proxies/x1",
|
||||
{"name": "EU 2"},
|
||||
{},
|
||||
"/v1/proxies/{id}",
|
||||
),
|
||||
("delete_proxy", ("x1",), {}, "DELETE", "/v1/proxies/x1", None, {}, "/v1/proxies/{id}"),
|
||||
(
|
||||
"import_proxies",
|
||||
(),
|
||||
{"format": "txt", "content": "h:1:u:p", "name_prefix": "EU"},
|
||||
"POST",
|
||||
"/v1/proxies/import",
|
||||
{"format": "txt", "content": "h:1:u:p", "name_prefix": "EU"},
|
||||
{},
|
||||
"/v1/proxies/import",
|
||||
),
|
||||
# -- vpns --------------------------------------------------------------
|
||||
("list_vpns", (), {}, "GET", "/v1/vpns", None, {}, "/v1/vpns"),
|
||||
("get_vpn", ("v1",), {}, "GET", "/v1/vpns/v1", None, {}, "/v1/vpns/{id}"),
|
||||
("export_vpn", ("v1",), {}, "GET", "/v1/vpns/v1/export", None, {}, "/v1/vpns/{id}/export"),
|
||||
(
|
||||
"import_vpn",
|
||||
(),
|
||||
{"content": "[Interface]", "filename": "eu.conf"},
|
||||
"POST",
|
||||
"/v1/vpns/import",
|
||||
{"content": "[Interface]", "filename": "eu.conf"},
|
||||
{},
|
||||
"/v1/vpns/import",
|
||||
),
|
||||
(
|
||||
"create_vpn",
|
||||
(),
|
||||
{"name": "EU", "vpn_type": "WireGuard", "config_data": "[Interface]"},
|
||||
"POST",
|
||||
"/v1/vpns",
|
||||
{"name": "EU", "vpn_type": "WireGuard", "config_data": "[Interface]"},
|
||||
{},
|
||||
"/v1/vpns",
|
||||
),
|
||||
(
|
||||
"update_vpn",
|
||||
("v1",),
|
||||
{"name": "EU 2"},
|
||||
"PUT",
|
||||
"/v1/vpns/v1",
|
||||
{"name": "EU 2"},
|
||||
{},
|
||||
"/v1/vpns/{id}",
|
||||
),
|
||||
("delete_vpn", ("v1",), {}, "DELETE", "/v1/vpns/v1", None, {}, "/v1/vpns/{id}"),
|
||||
# -- extensions --------------------------------------------------------
|
||||
("list_extensions", (), {}, "GET", "/v1/extensions", None, {}, "/v1/extensions"),
|
||||
("get_extension", ("e1",), {}, "GET", "/v1/extensions/e1", None, {}, "/v1/extensions/{id}"),
|
||||
(
|
||||
"create_extension",
|
||||
(),
|
||||
{"name": "Blocker", "file_name": "b.crx", "file_data_base64": "AAAA"},
|
||||
"POST",
|
||||
"/v1/extensions",
|
||||
{"name": "Blocker", "file_name": "b.crx", "file_data_base64": "AAAA"},
|
||||
{},
|
||||
"/v1/extensions",
|
||||
),
|
||||
(
|
||||
"update_extension",
|
||||
("e1",),
|
||||
{"name": "Blocker 2", "link": True},
|
||||
"PUT",
|
||||
"/v1/extensions/e1",
|
||||
{"name": "Blocker 2", "link": True},
|
||||
{},
|
||||
"/v1/extensions/{id}",
|
||||
),
|
||||
(
|
||||
"delete_extension",
|
||||
("e1",),
|
||||
{},
|
||||
"DELETE",
|
||||
"/v1/extensions/e1",
|
||||
None,
|
||||
{},
|
||||
"/v1/extensions/{id}",
|
||||
),
|
||||
(
|
||||
"list_extension_groups",
|
||||
(),
|
||||
{},
|
||||
"GET",
|
||||
"/v1/extension-groups",
|
||||
None,
|
||||
{},
|
||||
"/v1/extension-groups",
|
||||
),
|
||||
(
|
||||
"get_extension_group",
|
||||
("eg1",),
|
||||
{},
|
||||
"GET",
|
||||
"/v1/extension-groups/eg1",
|
||||
None,
|
||||
{},
|
||||
"/v1/extension-groups/{id}",
|
||||
),
|
||||
(
|
||||
"create_extension_group",
|
||||
(),
|
||||
{"name": "Adblock set"},
|
||||
"POST",
|
||||
"/v1/extension-groups",
|
||||
{"name": "Adblock set"},
|
||||
{},
|
||||
"/v1/extension-groups",
|
||||
),
|
||||
(
|
||||
"update_extension_group",
|
||||
("eg1",),
|
||||
{"extension_ids": ["e1", "e2"]},
|
||||
"PUT",
|
||||
"/v1/extension-groups/eg1",
|
||||
{"extension_ids": ["e1", "e2"]},
|
||||
{},
|
||||
"/v1/extension-groups/{id}",
|
||||
),
|
||||
(
|
||||
"delete_extension_group",
|
||||
("eg1",),
|
||||
{},
|
||||
"DELETE",
|
||||
"/v1/extension-groups/eg1",
|
||||
None,
|
||||
{},
|
||||
"/v1/extension-groups/{id}",
|
||||
),
|
||||
(
|
||||
"add_extension_to_group",
|
||||
("eg1", "e1"),
|
||||
{},
|
||||
"POST",
|
||||
"/v1/extension-groups/eg1/extensions/e1",
|
||||
None,
|
||||
{},
|
||||
"/v1/extension-groups/{id}/extensions/{extension_id}",
|
||||
),
|
||||
(
|
||||
"remove_extension_from_group",
|
||||
("eg1", "e1"),
|
||||
{},
|
||||
"DELETE",
|
||||
"/v1/extension-groups/eg1/extensions/e1",
|
||||
None,
|
||||
{},
|
||||
"/v1/extension-groups/{id}/extensions/{extension_id}",
|
||||
),
|
||||
# -- browsers ----------------------------------------------------------
|
||||
(
|
||||
"download_browser",
|
||||
(),
|
||||
{"browser": "wayfern", "version": "152.0.1"},
|
||||
"POST",
|
||||
"/v1/browsers/download",
|
||||
{"browser": "wayfern", "version": "152.0.1"},
|
||||
{},
|
||||
"/v1/browsers/download",
|
||||
),
|
||||
(
|
||||
"list_browser_versions",
|
||||
("wayfern",),
|
||||
{},
|
||||
"GET",
|
||||
"/v1/browsers/wayfern/versions",
|
||||
None,
|
||||
{},
|
||||
"/v1/browsers/{browser}/versions",
|
||||
),
|
||||
(
|
||||
"is_browser_downloaded",
|
||||
("wayfern", "152.0.1"),
|
||||
{},
|
||||
"GET",
|
||||
"/v1/browsers/wayfern/versions/152.0.1/downloaded",
|
||||
None,
|
||||
{},
|
||||
"/v1/browsers/{browser}/versions/{version}/downloaded",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"case", CASES, ids=[f"{case[0]}[{index}]" for index, case in enumerate(CASES)]
|
||||
)
|
||||
def test_method_sends_the_documented_request(
|
||||
client: DonutClient, fake: FakeDonut, case: Case
|
||||
) -> None:
|
||||
name, args, kwargs, verb, path, body, query, operation = case
|
||||
|
||||
getattr(client, name)(*args, **kwargs)
|
||||
|
||||
sent = fake.last
|
||||
assert sent.method == verb
|
||||
assert sent.path == path
|
||||
assert sent.query == query
|
||||
assert sent.json == body
|
||||
assert OPERATIONS[(verb, operation)] == name
|
||||
|
||||
|
||||
def test_every_client_method_is_exercised_here() -> None:
|
||||
"""No method may be added to the table of operations without a case above."""
|
||||
covered = {case[0] for case in CASES}
|
||||
missing = sorted(set(OPERATIONS.values()) - covered)
|
||||
assert not missing, f"these wrapped operations have no request test: {missing}"
|
||||
|
||||
|
||||
def test_the_token_travels_as_a_bearer_header(client: DonutClient, fake: FakeDonut) -> None:
|
||||
client.list_profiles()
|
||||
sent = fake.last
|
||||
assert sent.header("Authorization") == "Bearer test-token-abc123"
|
||||
assert sent.header("Accept") == "application/json"
|
||||
assert sent.header("Content-Type") is None, "a GET must not claim to carry JSON"
|
||||
|
||||
|
||||
def test_a_body_is_sent_as_json(client: DonutClient, fake: FakeDonut) -> None:
|
||||
client.create_group(name="Retail")
|
||||
sent = fake.last
|
||||
assert sent.header("Content-Type") == "application/json"
|
||||
assert json.loads(sent.body.decode()) == {"name": "Retail"}
|
||||
|
||||
|
||||
def test_path_ids_are_escaped(client: DonutClient, fake: FakeDonut) -> None:
|
||||
"""An id can never break out of its own path segment."""
|
||||
client.get_profile("a/b c?d")
|
||||
assert fake.last.path == "/v1/profiles/a%2Fb%20c%3Fd"
|
||||
|
||||
|
||||
def test_none_arguments_are_left_out_of_the_body(client: DonutClient, fake: FakeDonut) -> None:
|
||||
client.update_profile("p1", name="Only this")
|
||||
assert fake.last.json == {"name": "Only this"}
|
||||
|
||||
|
||||
def test_an_empty_string_still_reaches_the_app(client: DonutClient, fake: FakeDonut) -> None:
|
||||
"""`proxy_id=""` is how the app is told to detach a proxy, so it must survive."""
|
||||
client.update_profile("p1", proxy_id="")
|
||||
assert fake.last.json == {"proxy_id": ""}
|
||||
|
||||
|
||||
def test_a_no_content_answer_becomes_none(client: DonutClient, fake: FakeDonut) -> None:
|
||||
fake.enqueue_empty(204)
|
||||
assert client.delete_profile("p1") is None
|
||||
|
||||
|
||||
def test_a_json_answer_is_returned_as_sent(client: DonutClient, fake: FakeDonut) -> None:
|
||||
fake.enqueue_json({"profiles": [{"id": "p1", "name": "Shopper"}], "total": 1})
|
||||
assert client.list_profiles() == {
|
||||
"profiles": [{"id": "p1", "name": "Shopper"}],
|
||||
"total": 1,
|
||||
}
|
||||
|
||||
|
||||
def test_a_bare_boolean_answer_is_returned(client: DonutClient, fake: FakeDonut) -> None:
|
||||
fake.enqueue_json(True)
|
||||
assert client.is_browser_downloaded("wayfern", "152.0.1") is True
|
||||
@@ -0,0 +1,92 @@
|
||||
"""`with client.run(...)` launches, hands over the CDP endpoint, and stops."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from fake_donut import FakeDonut
|
||||
|
||||
from donutbrowser import Conflict, DonutClient, DonutError
|
||||
|
||||
RUN_BODY = {"profile_id": "p1", "remote_debugging_port": 9222, "headless": True}
|
||||
|
||||
|
||||
def test_the_block_gets_the_cdp_endpoint(client: DonutClient, fake: FakeDonut) -> None:
|
||||
fake.enqueue_json(RUN_BODY)
|
||||
fake.enqueue_empty(204)
|
||||
|
||||
with client.run("p1", url="https://example.com", headless=True) as session:
|
||||
assert session.remote_debugging_port == 9222
|
||||
assert session.headless is True
|
||||
assert session.cdp_url == "http://127.0.0.1:9222"
|
||||
assert session.response == RUN_BODY
|
||||
|
||||
assert [(sent.method, sent.path) for sent in fake.requests] == [
|
||||
("POST", "/v1/profiles/p1/run"),
|
||||
("POST", "/v1/profiles/p1/kill"),
|
||||
]
|
||||
assert fake.requests[0].json == {"url": "https://example.com", "headless": True}
|
||||
|
||||
|
||||
def test_nothing_launches_until_the_block_is_entered(
|
||||
client: DonutClient, fake: FakeDonut
|
||||
) -> None:
|
||||
session = client.run("p1")
|
||||
assert session.remote_debugging_port is None
|
||||
assert fake.requests == []
|
||||
|
||||
|
||||
def test_the_browser_is_stopped_when_the_block_raises(
|
||||
client: DonutClient, fake: FakeDonut
|
||||
) -> None:
|
||||
fake.enqueue_json(RUN_BODY)
|
||||
fake.enqueue_empty(204)
|
||||
|
||||
with pytest.raises(ZeroDivisionError):
|
||||
with client.run("p1"):
|
||||
raise ZeroDivisionError("the body failed")
|
||||
|
||||
assert [sent.path for sent in fake.requests] == [
|
||||
"/v1/profiles/p1/run",
|
||||
"/v1/profiles/p1/kill",
|
||||
]
|
||||
|
||||
|
||||
def test_a_failed_stop_never_hides_why_the_block_failed(
|
||||
client: DonutClient, fake: FakeDonut
|
||||
) -> None:
|
||||
fake.enqueue_json(RUN_BODY)
|
||||
fake.enqueue_error(409, "PROFILE_LOCKED_ELSEWHERE")
|
||||
|
||||
session = client.run("p1")
|
||||
with pytest.raises(ZeroDivisionError):
|
||||
with session:
|
||||
raise ZeroDivisionError("the body failed")
|
||||
|
||||
assert isinstance(session.cleanup_error, Conflict)
|
||||
|
||||
|
||||
def test_a_failed_stop_is_raised_when_the_block_was_fine(
|
||||
client: DonutClient, fake: FakeDonut
|
||||
) -> None:
|
||||
fake.enqueue_json(RUN_BODY)
|
||||
fake.enqueue_error(503, "the fleet could not be reached")
|
||||
|
||||
with pytest.raises(DonutError):
|
||||
with client.run("p1"):
|
||||
pass
|
||||
|
||||
|
||||
def test_a_failed_launch_stops_nothing(client: DonutClient, fake: FakeDonut) -> None:
|
||||
fake.enqueue_error(409, "PROFILE_RUNNING")
|
||||
|
||||
with pytest.raises(Conflict):
|
||||
with client.run("p1"):
|
||||
pytest.fail("the block must not run when the launch failed")
|
||||
|
||||
assert [sent.path for sent in fake.requests] == ["/v1/profiles/p1/run"]
|
||||
|
||||
|
||||
def test_the_cdp_url_is_refused_before_the_block(client: DonutClient) -> None:
|
||||
session = client.run("p1")
|
||||
with pytest.raises(DonutError):
|
||||
_ = session.cdp_url
|
||||
@@ -0,0 +1,145 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Regenerate sdk/api-paths.json from the Rust REST server.
|
||||
|
||||
The served /openapi.json comes from the hand-maintained `ApiDoc` derive in
|
||||
`src-tauri/src/api_server.rs`, not from the axum router, so this script reads
|
||||
the same two things the document is built from:
|
||||
|
||||
* every `#[utoipa::path(...)]` annotation (its verb and path), and
|
||||
* the `paths(...)` list inside `#[openapi(...)]`.
|
||||
|
||||
An annotation that is not in `paths(...)` never reaches the served document, so
|
||||
the two lists are compared here and a difference fails the run. The result is a
|
||||
snapshot both SDK test suites read to prove they cover the whole API.
|
||||
|
||||
Usage (from anywhere):
|
||||
python3 sdk/tools/extract-api-paths.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
SOURCE = REPO_ROOT / "src-tauri" / "src" / "api_server.rs"
|
||||
SNAPSHOT = REPO_ROOT / "sdk" / "api-paths.json"
|
||||
|
||||
VERBS = ("get", "post", "put", "delete", "patch", "head", "options")
|
||||
|
||||
|
||||
def read_annotations(lines: list[str]) -> list[dict[str, str]]:
|
||||
"""Every `#[utoipa::path(...)]` block, paired with the fn it decorates."""
|
||||
operations: list[dict[str, str]] = []
|
||||
index = 0
|
||||
while index < len(lines):
|
||||
if lines[index].strip() != "#[utoipa::path(":
|
||||
index += 1
|
||||
continue
|
||||
|
||||
depth = 0
|
||||
end = index
|
||||
while end < len(lines):
|
||||
depth += lines[end].count("(") - lines[end].count(")")
|
||||
if depth == 0 and end > index:
|
||||
break
|
||||
end += 1
|
||||
block = lines[index : end + 1]
|
||||
|
||||
method = next(
|
||||
(line.strip().rstrip(",") for line in block if line.strip().rstrip(",") in VERBS),
|
||||
None,
|
||||
)
|
||||
path_match = next(
|
||||
(re.search(r'path\s*=\s*"([^"]+)"', line) for line in block if "path = " in line),
|
||||
None,
|
||||
)
|
||||
name_match = None
|
||||
for line in lines[end + 1 : end + 4]:
|
||||
name_match = re.search(r"\bfn\s+(\w+)\s*\(", line)
|
||||
if name_match:
|
||||
break
|
||||
|
||||
if method is None or path_match is None or name_match is None:
|
||||
raise SystemExit(
|
||||
f"{SOURCE}:{index + 1}: could not read a verb, a path and a fn name "
|
||||
"out of this #[utoipa::path] block"
|
||||
)
|
||||
|
||||
operations.append(
|
||||
{
|
||||
"operation_id": name_match.group(1),
|
||||
"method": method.upper(),
|
||||
"path": path_match.group(1),
|
||||
}
|
||||
)
|
||||
index = end + 1
|
||||
|
||||
return operations
|
||||
|
||||
|
||||
def read_apidoc_paths(text: str) -> list[str]:
|
||||
"""The operation ids listed in `#[openapi(paths(...))]`."""
|
||||
start = text.index("#[openapi(")
|
||||
listed = text.index("paths(", start) + len("paths(")
|
||||
depth = 1
|
||||
end = listed
|
||||
while depth:
|
||||
if text[end] == "(":
|
||||
depth += 1
|
||||
elif text[end] == ")":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
break
|
||||
end += 1
|
||||
body = re.sub(r"//[^\n]*", "", text[listed:end])
|
||||
return [item.strip() for item in body.split(",") if item.strip()]
|
||||
|
||||
|
||||
def main() -> int:
|
||||
text = SOURCE.read_text(encoding="utf-8")
|
||||
annotated = read_annotations(text.split("\n"))
|
||||
listed = read_apidoc_paths(text)
|
||||
|
||||
annotated_ids = {operation["operation_id"] for operation in annotated}
|
||||
listed_ids = set(listed)
|
||||
|
||||
unpublished = sorted(annotated_ids - listed_ids)
|
||||
unknown = sorted(listed_ids - annotated_ids)
|
||||
if unpublished or unknown:
|
||||
for name in unpublished:
|
||||
print(
|
||||
f"error: {name} carries a #[utoipa::path] but is missing from "
|
||||
"ApiDoc paths(...), so it is absent from the served spec",
|
||||
file=sys.stderr,
|
||||
)
|
||||
for name in unknown:
|
||||
print(
|
||||
f"error: ApiDoc paths(...) lists {name}, which has no "
|
||||
"#[utoipa::path] annotation in this file",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
operations = sorted(annotated, key=lambda op: (op["path"], op["method"]))
|
||||
snapshot = {
|
||||
"source": "src-tauri/src/api_server.rs",
|
||||
"regenerate_with": "python3 sdk/tools/extract-api-paths.py",
|
||||
"description": (
|
||||
"Every operation the desktop app publishes in its /openapi.json. The "
|
||||
"SDK test suites assert this list and their own coverage tables match "
|
||||
"exactly, so an endpoint added to the app fails the SDK tests until it "
|
||||
"is either wrapped or deliberately listed as omitted."
|
||||
),
|
||||
"operation_count": len(operations),
|
||||
"operations": operations,
|
||||
}
|
||||
SNAPSHOT.write_text(json.dumps(snapshot, indent=2) + "\n", encoding="utf-8")
|
||||
print(f"wrote {SNAPSHOT.relative_to(REPO_ROOT)} with {len(operations)} operations")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Generated
+178
-103
@@ -28,6 +28,17 @@ dependencies = [
|
||||
"inout 0.2.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aes"
|
||||
version = "0.8.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cipher 0.4.4",
|
||||
"cpufeatures 0.2.17",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aes"
|
||||
version = "0.9.3"
|
||||
@@ -39,6 +50,20 @@ dependencies = [
|
||||
"cpufeatures 0.3.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aes-gcm"
|
||||
version = "0.10.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1"
|
||||
dependencies = [
|
||||
"aead 0.5.2",
|
||||
"aes 0.8.4",
|
||||
"cipher 0.4.4",
|
||||
"ctr 0.9.2",
|
||||
"ghash 0.5.1",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aes-gcm"
|
||||
version = "0.11.1"
|
||||
@@ -46,11 +71,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f2b8006a0c83f52b62ba44a97b58bf76fe2f70a329e588f67f89691d93d498f"
|
||||
dependencies = [
|
||||
"aead 0.6.1",
|
||||
"aes",
|
||||
"aes 0.9.3",
|
||||
"cipher 0.5.2",
|
||||
"ctr",
|
||||
"ctr 0.10.1",
|
||||
"ctutils",
|
||||
"ghash",
|
||||
"ghash 0.6.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -477,30 +502,6 @@ dependencies = [
|
||||
"arrayvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aws-lc-rs"
|
||||
version = "1.18.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e"
|
||||
dependencies = [
|
||||
"aws-lc-sys",
|
||||
"untrusted 0.7.1",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aws-lc-sys"
|
||||
version = "0.45.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"cmake",
|
||||
"dunce",
|
||||
"fs_extra",
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "axum"
|
||||
version = "0.8.9"
|
||||
@@ -714,7 +715,7 @@ dependencies = [
|
||||
"aead 0.5.2",
|
||||
"base64 0.22.1",
|
||||
"blake2 0.10.6",
|
||||
"chacha20poly1305 0.10.1",
|
||||
"chacha20poly1305",
|
||||
"hex",
|
||||
"hmac 0.12.1",
|
||||
"ip_network",
|
||||
@@ -726,7 +727,7 @@ dependencies = [
|
||||
"rand_core 0.6.4",
|
||||
"ring",
|
||||
"tracing",
|
||||
"untrusted 0.9.0",
|
||||
"untrusted",
|
||||
"x25519-dalek",
|
||||
]
|
||||
|
||||
@@ -1006,7 +1007,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cipher 0.5.2",
|
||||
"cpufeatures 0.3.1",
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
@@ -1020,22 +1020,10 @@ dependencies = [
|
||||
"aead 0.5.2",
|
||||
"chacha20 0.9.1",
|
||||
"cipher 0.4.4",
|
||||
"poly1305 0.8.0",
|
||||
"poly1305",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "chacha20poly1305"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b89e1c441e926b9c82a8d023f6e1b7ae0adcfaa7d621814e4d60789bac751cb"
|
||||
dependencies = [
|
||||
"aead 0.6.1",
|
||||
"chacha20 0.10.2",
|
||||
"cipher 0.5.2",
|
||||
"poly1305 0.9.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "chrono"
|
||||
version = "0.4.45"
|
||||
@@ -1131,15 +1119,6 @@ dependencies = [
|
||||
"error-code",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cmake"
|
||||
version = "0.1.58"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cmov"
|
||||
version = "0.5.4"
|
||||
@@ -1177,6 +1156,12 @@ dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "const-oid"
|
||||
version = "0.9.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
|
||||
|
||||
[[package]]
|
||||
name = "const-oid"
|
||||
version = "0.10.2"
|
||||
@@ -1418,6 +1403,15 @@ version = "0.0.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1"
|
||||
|
||||
[[package]]
|
||||
name = "ctr"
|
||||
version = "0.9.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835"
|
||||
dependencies = [
|
||||
"cipher 0.4.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ctr"
|
||||
version = "0.10.1"
|
||||
@@ -1568,6 +1562,16 @@ dependencies = [
|
||||
"thiserror 2.0.20",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "der"
|
||||
version = "0.7.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
|
||||
dependencies = [
|
||||
"const-oid 0.9.6",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deranged"
|
||||
version = "0.5.8"
|
||||
@@ -1616,7 +1620,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
|
||||
dependencies = [
|
||||
"block-buffer 0.12.1",
|
||||
"const-oid",
|
||||
"const-oid 0.10.2",
|
||||
"crypto-common 0.2.2",
|
||||
"ctutils",
|
||||
]
|
||||
@@ -1725,8 +1729,8 @@ dependencies = [
|
||||
name = "donutbrowser"
|
||||
version = "0.30.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"aes-gcm",
|
||||
"aes 0.9.3",
|
||||
"aes-gcm 0.11.1",
|
||||
"argon2",
|
||||
"async-socks5",
|
||||
"async-trait",
|
||||
@@ -1752,6 +1756,7 @@ dependencies = [
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"image",
|
||||
"jsonc-parser",
|
||||
"lazy_static",
|
||||
"libc",
|
||||
"log",
|
||||
@@ -1759,6 +1764,7 @@ dependencies = [
|
||||
"maxminddb",
|
||||
"mime_guess",
|
||||
"msi-extract",
|
||||
"native-tls",
|
||||
"nix",
|
||||
"objc2",
|
||||
"objc2-app-kit",
|
||||
@@ -1798,9 +1804,10 @@ dependencies = [
|
||||
"tempfile",
|
||||
"thiserror 2.0.20",
|
||||
"tokio",
|
||||
"tokio-native-tls",
|
||||
"tokio-tungstenite 0.30.0",
|
||||
"tokio-util",
|
||||
"toml 1.1.4+spec-1.1.0",
|
||||
"toml_edit 0.25.13+spec-1.1.0",
|
||||
"tower",
|
||||
"tower-http 0.7.1",
|
||||
"url",
|
||||
@@ -1891,6 +1898,16 @@ dependencies = [
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ed25519"
|
||||
version = "2.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53"
|
||||
dependencies = [
|
||||
"pkcs8",
|
||||
"signature",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "either"
|
||||
version = "1.18.0"
|
||||
@@ -2282,12 +2299,6 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fs_extra"
|
||||
version = "1.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
|
||||
|
||||
[[package]]
|
||||
name = "futures"
|
||||
version = "0.3.34"
|
||||
@@ -2543,13 +2554,23 @@ dependencies = [
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ghash"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1"
|
||||
dependencies = [
|
||||
"opaque-debug",
|
||||
"polyval 0.6.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ghash"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2eecf2d5dc9b66b732b97707a0210906b1d30523eb773193ab777c0c84b3e8d5"
|
||||
dependencies = [
|
||||
"polyval",
|
||||
"polyval 0.7.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2856,6 +2877,15 @@ version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
||||
|
||||
[[package]]
|
||||
name = "hkdf"
|
||||
version = "0.12.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7"
|
||||
dependencies = [
|
||||
"hmac 0.12.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hkdf"
|
||||
version = "0.13.0"
|
||||
@@ -3511,6 +3541,15 @@ dependencies = [
|
||||
"thiserror 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jsonc-parser"
|
||||
version = "0.33.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a0560e3f9a9a03ea6b6e90b41138c5db9e21526c99eb192c1a26c68176593285"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jsonptr"
|
||||
version = "0.6.3"
|
||||
@@ -3756,12 +3795,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "md-5"
|
||||
version = "0.11.0"
|
||||
version = "0.10.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98"
|
||||
checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"digest 0.11.3",
|
||||
"digest 0.10.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4678,6 +4717,16 @@ dependencies = [
|
||||
"futures-io",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pkcs8"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
|
||||
dependencies = [
|
||||
"der",
|
||||
"spki",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pkg-config"
|
||||
version = "0.3.34"
|
||||
@@ -4748,16 +4797,6 @@ dependencies = [
|
||||
"universal-hash 0.5.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "poly1305"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e2d0073b297041425c7c3df6eb4792d598a15323fe63346852b092eca02904c"
|
||||
dependencies = [
|
||||
"cpufeatures 0.3.1",
|
||||
"universal-hash 0.6.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "polycool"
|
||||
version = "0.4.0"
|
||||
@@ -4767,6 +4806,18 @@ dependencies = [
|
||||
"arrayvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "polyval"
|
||||
version = "0.6.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.2.17",
|
||||
"opaque-debug",
|
||||
"universal-hash 0.5.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "polyval"
|
||||
version = "0.7.3"
|
||||
@@ -5323,10 +5374,23 @@ dependencies = [
|
||||
"cfg-if",
|
||||
"getrandom 0.2.17",
|
||||
"libc",
|
||||
"untrusted 0.9.0",
|
||||
"untrusted",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ring-compat"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ccce7bae150b815f0811db41b8312fcb74bffa4cab9cee5429ee00f356dd5bd4"
|
||||
dependencies = [
|
||||
"aead 0.5.2",
|
||||
"ed25519",
|
||||
"generic-array",
|
||||
"pkcs8",
|
||||
"ring",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "roxmltree"
|
||||
version = "0.20.0"
|
||||
@@ -5435,7 +5499,7 @@ checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
|
||||
dependencies = [
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"untrusted 0.9.0",
|
||||
"untrusted",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5527,13 +5591,13 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||
|
||||
[[package]]
|
||||
name = "sealed"
|
||||
version = "0.7.0"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b68e2ea526d9fb32f23ca8894fb5da9e743f34c2f41701f0501dc8a25c4b343"
|
||||
checksum = "22f968c5ea23d555e670b449c1c5e7b2fc399fdaec1d304a17cd48e288abc107"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.4",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5542,11 +5606,11 @@ version = "5.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5107b24b91445dd2aa449a258a1807b63240942157292354dc5bfdbeb8bc6db8"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"aes 0.9.3",
|
||||
"cbc",
|
||||
"futures-util",
|
||||
"getrandom 0.4.3",
|
||||
"hkdf",
|
||||
"hkdf 0.13.0",
|
||||
"hybrid-array",
|
||||
"num",
|
||||
"once_cell",
|
||||
@@ -5883,11 +5947,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "shadowsocks"
|
||||
version = "1.25.0"
|
||||
version = "1.24.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e2065b026dbe4f47048eca384adf07f693bf901050d80abd3adb7b2422709b2d"
|
||||
checksum = "482831bf9d55acf3c98e211b6c852c3dfdf1d1b0d23fdf1d887c5a4b2acad4e4"
|
||||
dependencies = [
|
||||
"base64 0.23.1",
|
||||
"base64 0.22.1",
|
||||
"blake3",
|
||||
"byte_string",
|
||||
"bytes",
|
||||
@@ -5916,18 +5980,18 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "shadowsocks-crypto"
|
||||
version = "0.8.0"
|
||||
version = "0.6.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b404a62ebea5003f44b9a2748dee7555c5f9ae39896508e466a2a91ee6fa5df5"
|
||||
checksum = "3d038a3d17586f1c1ab3c1c3b9e4d5ef8fba98fb3890ad740c8487038b2e2ca5"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"aws-lc-rs",
|
||||
"aes-gcm 0.10.3",
|
||||
"cfg-if",
|
||||
"chacha20poly1305 0.11.0",
|
||||
"hkdf",
|
||||
"chacha20poly1305",
|
||||
"hkdf 0.12.4",
|
||||
"md-5",
|
||||
"rand 0.10.2",
|
||||
"sha1 0.11.0",
|
||||
"rand 0.9.5",
|
||||
"ring-compat",
|
||||
"sha1 0.10.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5978,6 +6042,12 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "signature"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
|
||||
|
||||
[[package]]
|
||||
name = "simd-adler32"
|
||||
version = "0.3.10"
|
||||
@@ -6113,13 +6183,23 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "spin"
|
||||
version = "0.12.3"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0134f9043ed38b087ac4f7d4af44c79e2c9e5094421fe3164f435ce585953b10"
|
||||
checksum = "023a211cb3138dbc438680b32560ad89f699977624c9f8dbb95a47d5b4c07dd3"
|
||||
dependencies = [
|
||||
"lock_api",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "spki"
|
||||
version = "0.7.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
"der",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sqlite-wasm-rs"
|
||||
version = "0.5.5"
|
||||
@@ -6807,7 +6887,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom 0.3.4",
|
||||
"getrandom 0.4.3",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys 0.61.2",
|
||||
@@ -7176,6 +7256,7 @@ dependencies = [
|
||||
"indexmap 2.14.2",
|
||||
"toml_datetime 1.1.1+spec-1.1.0",
|
||||
"toml_parser",
|
||||
"toml_writer",
|
||||
"winnow 1.0.4",
|
||||
]
|
||||
|
||||
@@ -7513,12 +7594,6 @@ version = "0.2.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
|
||||
|
||||
[[package]]
|
||||
name = "untrusted"
|
||||
version = "0.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a"
|
||||
|
||||
[[package]]
|
||||
name = "untrusted"
|
||||
version = "0.9.0"
|
||||
|
||||
+19
-2
@@ -30,6 +30,8 @@ resvg = "0.48"
|
||||
|
||||
[dependencies]
|
||||
serde_json = "1"
|
||||
# Runtime as well as build time: the per-profile window badge is rendered at launch.
|
||||
resvg = "0.48"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
tauri = { version = "2", features = ["tray-icon", "image-png"] }
|
||||
tauri-plugin-opener = "2"
|
||||
@@ -47,6 +49,16 @@ env_logger = "0.11"
|
||||
|
||||
directories = "6"
|
||||
reqwest = { version = "0.13", default-features = false, features = ["native-tls", "json", "stream", "socks", "charset", "http2", "system-proxy"] }
|
||||
# The `httpstls` upstream type wraps the hop to the proxy in TLS before a single
|
||||
# byte of CONNECT or Proxy-Authorization is written. native-tls (not rustls) on
|
||||
# purpose: reqwest above already terminates its proxy TLS through native-tls, so
|
||||
# both the browser tunnel and the check-button probe consult the same platform
|
||||
# trust store. A rustls tunnel plus a native-tls probe would mean a proxy that
|
||||
# passes the check and then fails in the browser. Both crates already build
|
||||
# today as transitive deps of reqwest and tokio-tungstenite; this adds an edge,
|
||||
# not a crate.
|
||||
native-tls = "0.2"
|
||||
tokio-native-tls = "0.3"
|
||||
tokio = { version = "1", features = ["full", "sync"] }
|
||||
tokio-util = "0.7"
|
||||
sysinfo = "0.39"
|
||||
@@ -88,7 +100,11 @@ cbc = "0.2"
|
||||
ring = "0.17"
|
||||
subtle = "2"
|
||||
sha2 = "0.11"
|
||||
shadowsocks = { version = "1.24", default-features = false, features = ["aead-cipher"] }
|
||||
# Held below 1.25 on purpose. From 1.25 the `aead-cipher` feature hard-enables the
|
||||
# aws-lc crypto backend (a C/assembly library needing cmake, and NASM on Windows)
|
||||
# alongside the `ring` this crate already ships through boringtun. Moving up is a
|
||||
# build-toolchain decision for every platform, not a routine dependency refresh.
|
||||
shadowsocks = { version = ">=1.24, <1.25", default-features = false, features = ["aead-cipher"] }
|
||||
hyper = { version = "1.10", features = ["full"] }
|
||||
hyper-util = { version = "0.1", features = ["full"] }
|
||||
http-body-util = "0.1"
|
||||
@@ -100,7 +116,6 @@ async-socks5 = "0.6"
|
||||
tokio-tungstenite = { version = "0.30", features = ["native-tls"] }
|
||||
rusqlite = { version = "0.40", features = ["bundled"] }
|
||||
serde_yaml = "0.9"
|
||||
toml = "1.1"
|
||||
thiserror = "2.0"
|
||||
regex-lite = "0.1"
|
||||
tempfile = "3"
|
||||
@@ -116,6 +131,8 @@ image = "0.25"
|
||||
dirs = "6"
|
||||
crossbeam-channel = "0.5"
|
||||
sys-locale = "0.3"
|
||||
jsonc-parser = { version = "0.33", features = ["cst", "serde_json"] }
|
||||
toml_edit = "0.25"
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
nix = { version = "0.31", features = ["signal", "process"] }
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+944
-49
File diff suppressed because it is too large
Load Diff
+319
-9
@@ -61,10 +61,121 @@ fn log_dir_for(root: Option<PathBuf>, portable: Option<&PathBuf>) -> Option<Path
|
||||
/// File name `tauri-plugin-window-state` persists geometry under.
|
||||
pub const WINDOW_STATE_FILENAME: &str = ".window-state.json";
|
||||
|
||||
/// File name of the pointer that records a data directory the user chose in
|
||||
/// Settings.
|
||||
pub const DATA_ROOT_POINTER_FILENAME: &str = "data-root.json";
|
||||
|
||||
static CUSTOM_DATA_ROOT: OnceLock<Option<PathBuf>> = OnceLock::new();
|
||||
|
||||
/// Where the pointer to a user-chosen data directory lives.
|
||||
///
|
||||
/// It must never sit inside `data_dir()` itself: a move deletes the old
|
||||
/// directory once the copy verifies, which would take the pointer with it and
|
||||
/// send the next start back to the platform default. Every branch below
|
||||
/// therefore resolves OUTSIDE the data directory it points at.
|
||||
///
|
||||
/// - With `DONUTBROWSER_DATA_ROOT` set, `<root>/data-root.json`, a sibling of
|
||||
/// `<root>/data`. An isolated run (the E2E harness) then keeps its own
|
||||
/// pointer and can never read, or write, the real machine's.
|
||||
/// - In portable mode, `<exe dir>/data-root.json`, beside `<exe dir>/data`, so
|
||||
/// the choice travels with the install.
|
||||
/// - Otherwise the platform preference directory, which is a different root
|
||||
/// from `data_local_dir` on macOS, Linux and Windows alike.
|
||||
pub fn data_root_pointer_file() -> PathBuf {
|
||||
data_root_pointer_file_for(
|
||||
data_root(),
|
||||
portable_dir(),
|
||||
base_dirs().preference_dir().join(app_name()),
|
||||
)
|
||||
}
|
||||
|
||||
/// Split out from `data_root_pointer_file` so the precedence is testable
|
||||
/// without a `.portable` marker or process-wide environment mutation.
|
||||
fn data_root_pointer_file_for(
|
||||
root: Option<PathBuf>,
|
||||
portable: Option<&PathBuf>,
|
||||
preference_dir: PathBuf,
|
||||
) -> PathBuf {
|
||||
if let Some(root) = root {
|
||||
return root.join(DATA_ROOT_POINTER_FILENAME);
|
||||
}
|
||||
if let Some(dir) = portable {
|
||||
return dir.join(DATA_ROOT_POINTER_FILENAME);
|
||||
}
|
||||
preference_dir.join(DATA_ROOT_POINTER_FILENAME)
|
||||
}
|
||||
|
||||
/// Read a pointer file written by a previous "move data directory".
|
||||
///
|
||||
/// A missing, unreadable, malformed, empty or relative entry resolves to
|
||||
/// `None`. Falling back to the platform default is always better than
|
||||
/// resolving every profile, binary and setting to a path that cannot exist.
|
||||
pub fn read_data_root_pointer(file: &std::path::Path) -> Option<PathBuf> {
|
||||
let content = std::fs::read_to_string(file).ok()?;
|
||||
let parsed: serde_json::Value = match serde_json::from_str(&content) {
|
||||
Ok(value) => value,
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"Ignoring the data directory pointer at {}: it is not valid JSON ({e})",
|
||||
file.display()
|
||||
);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let path = PathBuf::from(parsed.get("path")?.as_str()?);
|
||||
if path.as_os_str().is_empty() || !path.is_absolute() {
|
||||
log::warn!(
|
||||
"Ignoring the data directory pointer at {}: {} is not an absolute path",
|
||||
file.display(),
|
||||
path.display()
|
||||
);
|
||||
return None;
|
||||
}
|
||||
Some(path)
|
||||
}
|
||||
|
||||
/// Record a data directory for the next start. Written atomically, because a
|
||||
/// truncated pointer read at startup would silently drop the user back onto
|
||||
/// the platform default with an empty profile list.
|
||||
pub fn write_data_root_pointer(
|
||||
file: &std::path::Path,
|
||||
path: &std::path::Path,
|
||||
) -> std::io::Result<()> {
|
||||
if let Some(parent) = file.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
let body = serde_json::json!({ "path": path.to_string_lossy() }).to_string();
|
||||
let temp = file.with_extension("json.tmp");
|
||||
std::fs::write(&temp, body.as_bytes())?;
|
||||
std::fs::rename(&temp, file)
|
||||
}
|
||||
|
||||
/// Forget a recorded data directory, returning the app to the default.
|
||||
pub fn clear_data_root_pointer(file: &std::path::Path) -> std::io::Result<()> {
|
||||
match std::fs::remove_file(file) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// The data directory a previous move chose, read once per process.
|
||||
///
|
||||
/// Cached deliberately. Every open handle, cached path and loaded manager in a
|
||||
/// running app points at the directory it started on, so a move must take
|
||||
/// effect at the NEXT start and never mid-session.
|
||||
pub fn custom_data_root() -> Option<&'static PathBuf> {
|
||||
CUSTOM_DATA_ROOT
|
||||
.get_or_init(|| read_data_root_pointer(&data_root_pointer_file()))
|
||||
.as_ref()
|
||||
}
|
||||
|
||||
/// True when app state has been moved off the platform default location, by
|
||||
/// portable mode or by either directory override.
|
||||
/// portable mode, either directory override, or a data directory the user
|
||||
/// chose in Settings.
|
||||
fn state_is_relocated() -> bool {
|
||||
std::env::var_os("DONUTBROWSER_DATA_DIR").is_some_and(|v| !v.is_empty())
|
||||
|| custom_data_root().is_some()
|
||||
|| data_root().is_some()
|
||||
|| portable_dir().is_some()
|
||||
}
|
||||
@@ -80,8 +191,34 @@ fn state_is_relocated() -> bool {
|
||||
/// host machine. If a future plugin version sanitises the name to a bare file
|
||||
/// component this silently reverts to the default directory, which is why the
|
||||
/// first-run probe in `lib.rs` reads this same function rather than assuming.
|
||||
///
|
||||
/// A relocation that resolves to a relative path is rejected: see
|
||||
/// `window_state_override_for`.
|
||||
pub fn window_state_path_override() -> Option<PathBuf> {
|
||||
state_is_relocated().then(|| data_dir().join(WINDOW_STATE_FILENAME))
|
||||
window_state_override_for(state_is_relocated(), data_dir())
|
||||
}
|
||||
|
||||
/// Split out from `window_state_path_override` so the absolute-path rule is
|
||||
/// testable without mutating process-wide environment variables.
|
||||
///
|
||||
/// A relative override is worse than no override: the plugin would resolve it
|
||||
/// against `app_config_dir` and write into an intermediate directory it never
|
||||
/// creates, so every save fails with ENOENT and is swallowed by the plugin's
|
||||
/// fire-and-forget exit handler. Falling back to the platform default at least
|
||||
/// persists geometry.
|
||||
fn window_state_override_for(relocated: bool, data_dir: PathBuf) -> Option<PathBuf> {
|
||||
if !relocated {
|
||||
return None;
|
||||
}
|
||||
let path = data_dir.join(WINDOW_STATE_FILENAME);
|
||||
if !path.is_absolute() {
|
||||
log::warn!(
|
||||
"Ignoring relative window-state override {}: the plugin resolves its filename against app_config_dir, so geometry would never persist. Set DONUTBROWSER_DATA_DIR/DONUTBROWSER_DATA_ROOT to an absolute path.",
|
||||
path.display()
|
||||
);
|
||||
return None;
|
||||
}
|
||||
Some(path)
|
||||
}
|
||||
|
||||
/// Where the window-state file actually is, override or not. Used for the
|
||||
@@ -115,19 +252,66 @@ pub fn data_dir() -> PathBuf {
|
||||
}
|
||||
}
|
||||
|
||||
if let Ok(dir) = std::env::var("DONUTBROWSER_DATA_DIR") {
|
||||
return PathBuf::from(dir);
|
||||
}
|
||||
data_dir_for(
|
||||
std::env::var_os("DONUTBROWSER_DATA_DIR")
|
||||
.filter(|v| !v.is_empty())
|
||||
.map(PathBuf::from),
|
||||
custom_data_root(),
|
||||
data_root(),
|
||||
portable_dir(),
|
||||
base_dirs().data_local_dir().join(app_name()),
|
||||
)
|
||||
}
|
||||
|
||||
if let Some(root) = data_root() {
|
||||
/// The data directory resolution order, split out so it can be tested without
|
||||
/// mutating process-wide environment variables.
|
||||
///
|
||||
/// `DONUTBROWSER_DATA_DIR` stays on top: it names an exact directory and is the
|
||||
/// bluntest override there is. The directory the user picked in Settings comes
|
||||
/// next, ahead of `DONUTBROWSER_DATA_ROOT` and portable mode, because both of
|
||||
/// those are defaults for where state *would* live and an explicit choice
|
||||
/// outranks a default. It cannot break an isolated run, because the pointer it
|
||||
/// is read from lives under that same `DONUTBROWSER_DATA_ROOT`.
|
||||
fn data_dir_for(
|
||||
env_data_dir: Option<PathBuf>,
|
||||
custom_root: Option<&PathBuf>,
|
||||
env_data_root: Option<PathBuf>,
|
||||
portable: Option<&PathBuf>,
|
||||
platform_default: PathBuf,
|
||||
) -> PathBuf {
|
||||
if let Some(dir) = env_data_dir {
|
||||
return dir;
|
||||
}
|
||||
if let Some(dir) = custom_root {
|
||||
return dir.clone();
|
||||
}
|
||||
if let Some(root) = env_data_root {
|
||||
return root.join("data");
|
||||
}
|
||||
|
||||
if let Some(dir) = portable_dir() {
|
||||
if let Some(dir) = portable {
|
||||
return dir.join("data");
|
||||
}
|
||||
platform_default
|
||||
}
|
||||
|
||||
base_dirs().data_local_dir().join(app_name())
|
||||
/// Where the data directory would resolve with no user choice recorded. Shown
|
||||
/// in Settings so a person can see what they moved away from.
|
||||
pub fn default_data_dir() -> PathBuf {
|
||||
data_dir_for(
|
||||
std::env::var_os("DONUTBROWSER_DATA_DIR")
|
||||
.filter(|v| !v.is_empty())
|
||||
.map(PathBuf::from),
|
||||
None,
|
||||
data_root(),
|
||||
portable_dir(),
|
||||
base_dirs().data_local_dir().join(app_name()),
|
||||
)
|
||||
}
|
||||
|
||||
/// True when an environment override decides the data directory, so a
|
||||
/// directory chosen in Settings would be recorded but not used.
|
||||
pub fn data_dir_forced_by_environment() -> bool {
|
||||
std::env::var_os("DONUTBROWSER_DATA_DIR").is_some_and(|v| !v.is_empty())
|
||||
}
|
||||
|
||||
pub fn cache_dir() -> PathBuf {
|
||||
@@ -389,6 +573,27 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn window_state_override_rejects_a_relative_data_dir() {
|
||||
// `DONUTBROWSER_DATA_ROOT=don-state` (or a relative DATA_DIR) would hand the
|
||||
// plugin a relative filename it resolves against app_config_dir, into a
|
||||
// directory nothing creates. Falling back to the default keeps geometry.
|
||||
assert_eq!(
|
||||
window_state_override_for(true, PathBuf::from("don-state/data")),
|
||||
None
|
||||
);
|
||||
assert_eq!(window_state_override_for(true, PathBuf::from("")), None);
|
||||
|
||||
// temp_dir is absolute on every platform; a hard-coded "/tmp/..." is not
|
||||
// absolute on Windows, where these tests also run.
|
||||
let relocated = std::env::temp_dir().join("donut-relocated");
|
||||
assert_eq!(
|
||||
window_state_override_for(true, relocated.clone()),
|
||||
Some(relocated.join(WINDOW_STATE_FILENAME))
|
||||
);
|
||||
assert_eq!(window_state_override_for(false, relocated), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn window_state_follows_a_relocated_data_dir() {
|
||||
let tmp = PathBuf::from("/tmp/donut-relocated");
|
||||
@@ -413,6 +618,111 @@ mod tests {
|
||||
assert!(portable.join("cache").starts_with(&portable));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn data_dir_resolution_order_puts_the_chosen_directory_under_the_exact_override() {
|
||||
let env_dir = PathBuf::from("/env/exact");
|
||||
let chosen = PathBuf::from("/Volumes/Big/DonutBrowser");
|
||||
let env_root = PathBuf::from("/env/root");
|
||||
let portable = PathBuf::from("/stick");
|
||||
let default = PathBuf::from("/home/user/.local/share/DonutBrowser");
|
||||
|
||||
// DONUTBROWSER_DATA_DIR names an exact directory and outranks everything.
|
||||
assert_eq!(
|
||||
data_dir_for(
|
||||
Some(env_dir.clone()),
|
||||
Some(&chosen),
|
||||
Some(env_root.clone()),
|
||||
Some(&portable),
|
||||
default.clone(),
|
||||
),
|
||||
env_dir
|
||||
);
|
||||
|
||||
// The directory the user picked beats both defaults-for-where-state-lives.
|
||||
assert_eq!(
|
||||
data_dir_for(
|
||||
None,
|
||||
Some(&chosen),
|
||||
Some(env_root.clone()),
|
||||
Some(&portable),
|
||||
default.clone(),
|
||||
),
|
||||
chosen
|
||||
);
|
||||
|
||||
// With nothing chosen the existing order is untouched.
|
||||
assert_eq!(
|
||||
data_dir_for(
|
||||
None,
|
||||
None,
|
||||
Some(env_root.clone()),
|
||||
Some(&portable),
|
||||
default.clone(),
|
||||
),
|
||||
env_root.join("data")
|
||||
);
|
||||
assert_eq!(
|
||||
data_dir_for(None, None, None, Some(&portable), default.clone()),
|
||||
portable.join("data")
|
||||
);
|
||||
assert_eq!(
|
||||
data_dir_for(None, None, None, None, default.clone()),
|
||||
default
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_pointer_never_lives_inside_the_directory_it_points_at() {
|
||||
let root = PathBuf::from("/tmp/donut-root");
|
||||
let portable = PathBuf::from("/tmp/donut-portable");
|
||||
let preference = PathBuf::from("/home/user/.config/DonutBrowser");
|
||||
|
||||
// With DONUTBROWSER_DATA_ROOT the data dir is <root>/data, so a sibling
|
||||
// file survives deleting it — and an isolated run reads only its own.
|
||||
let with_root =
|
||||
data_root_pointer_file_for(Some(root.clone()), Some(&portable), preference.clone());
|
||||
assert_eq!(with_root, root.join(DATA_ROOT_POINTER_FILENAME));
|
||||
assert!(!with_root.starts_with(root.join("data")));
|
||||
|
||||
let with_portable = data_root_pointer_file_for(None, Some(&portable), preference.clone());
|
||||
assert_eq!(with_portable, portable.join(DATA_ROOT_POINTER_FILENAME));
|
||||
assert!(!with_portable.starts_with(portable.join("data")));
|
||||
|
||||
assert_eq!(
|
||||
data_root_pointer_file_for(None, None, preference.clone()),
|
||||
preference.join(DATA_ROOT_POINTER_FILENAME)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_written_pointer_reads_back_and_a_broken_one_falls_back() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let file = temp.path().join("nested").join(DATA_ROOT_POINTER_FILENAME);
|
||||
let target = std::env::temp_dir().join("donut-moved-root");
|
||||
|
||||
assert_eq!(read_data_root_pointer(&file), None, "missing file");
|
||||
|
||||
write_data_root_pointer(&file, &target).unwrap();
|
||||
assert_eq!(read_data_root_pointer(&file), Some(target.clone()));
|
||||
|
||||
// A relative path would be resolved against whatever the working directory
|
||||
// happens to be, which is not a place app state can live.
|
||||
write_data_root_pointer(&file, std::path::Path::new("relative/root")).unwrap();
|
||||
assert_eq!(read_data_root_pointer(&file), None, "relative path");
|
||||
|
||||
std::fs::write(&file, b"not json at all").unwrap();
|
||||
assert_eq!(read_data_root_pointer(&file), None, "malformed file");
|
||||
|
||||
std::fs::write(&file, br#"{"other":"key"}"#).unwrap();
|
||||
assert_eq!(read_data_root_pointer(&file), None, "no path entry");
|
||||
|
||||
write_data_root_pointer(&file, &target).unwrap();
|
||||
clear_data_root_pointer(&file).unwrap();
|
||||
assert_eq!(read_data_root_pointer(&file), None, "cleared");
|
||||
// Clearing an absent pointer is not an error; the caller has nothing to fix.
|
||||
clear_data_root_pointer(&file).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_subdirectory_helpers() {
|
||||
assert!(profiles_dir().ends_with("profiles"));
|
||||
|
||||
@@ -309,6 +309,19 @@ impl AutoUpdater {
|
||||
|
||||
// Check if profile is currently running
|
||||
if profile.process_id.is_some() {
|
||||
// A pending entry is matched on the profile's current version alone,
|
||||
// so recording one for an older build would downgrade the profile the
|
||||
// moment it closes, and would pin that older binary against cleanup.
|
||||
if !self.is_version_newer(new_version, &profile.version) {
|
||||
log::debug!(
|
||||
"Not queuing {} for running profile {}: not newer than {}",
|
||||
new_version,
|
||||
profile.name,
|
||||
profile.version
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Store as pending update so it gets applied when browser closes
|
||||
log::info!(
|
||||
"Profile {} is running, storing pending update {} -> {}",
|
||||
@@ -662,6 +675,7 @@ mod tests {
|
||||
last_sync: None,
|
||||
host_os: None,
|
||||
ephemeral: false,
|
||||
temporary: false,
|
||||
extension_group_id: None,
|
||||
proxy_bypass_rules: Vec::new(),
|
||||
created_by_id: None,
|
||||
|
||||
@@ -102,7 +102,7 @@ async fn main() {
|
||||
.arg(
|
||||
Arg::new("type")
|
||||
.long("type")
|
||||
.help("Proxy type (http, https, socks4, socks5, ss)"),
|
||||
.help("Proxy type (http, https, httpstls, socks4, socks5, ss)"),
|
||||
)
|
||||
.arg(
|
||||
Arg::new("port")
|
||||
|
||||
@@ -727,6 +727,7 @@ mod tests {
|
||||
last_sync: None,
|
||||
host_os: None,
|
||||
ephemeral: false,
|
||||
temporary: false,
|
||||
extension_group_id: None,
|
||||
proxy_bypass_rules: Vec::new(),
|
||||
created_by_id: None,
|
||||
|
||||
@@ -33,6 +33,18 @@ async fn lock_profile_launch(profile_id: &str) -> tokio::sync::OwnedMutexGuard<(
|
||||
lock.lock_owned().await
|
||||
}
|
||||
|
||||
fn emit_launch_stage(profile: &BrowserProfile, stage: &str, error: Option<&str>) {
|
||||
let _ = events::emit(
|
||||
"profile-launch-stage",
|
||||
serde_json::json!({
|
||||
"id": profile.id.to_string(),
|
||||
"stage": stage,
|
||||
"timestamp": SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_millis() as u64,
|
||||
"error": error,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
pub struct BrowserRunner {
|
||||
pub profile_manager: &'static ProfileManager,
|
||||
pub downloaded_browsers_registry: &'static DownloadedBrowsersRegistry,
|
||||
@@ -208,6 +220,10 @@ impl BrowserRunner {
|
||||
.map_err(|e| format!("Failed to get executable path for {}: {e}", profile.browser).into())
|
||||
}
|
||||
|
||||
/// One argument per thing a launch decides, and they are all independent:
|
||||
/// grouping them into a struct would only move the same list one level out,
|
||||
/// and the one caller shape that repeats already has `LaunchOptions`.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn launch_browser_internal(
|
||||
&self,
|
||||
app_handle: tauri::AppHandle,
|
||||
@@ -215,6 +231,7 @@ impl BrowserRunner {
|
||||
url: Option<String>,
|
||||
remote_debugging_port: Option<u16>,
|
||||
headless: bool,
|
||||
kind: crate::wayfern_manager::LaunchKind,
|
||||
gate: &crate::launch_gate::FingerprintGate,
|
||||
) -> Result<BrowserProfile, Box<dyn std::error::Error + Send + Sync>> {
|
||||
// Handle Wayfern profiles using WayfernManager
|
||||
@@ -228,6 +245,7 @@ impl BrowserRunner {
|
||||
WayfernConfig::default()
|
||||
});
|
||||
|
||||
emit_launch_stage(profile, "network", None);
|
||||
// Always start a local proxy for Wayfern (for traffic monitoring and geoip support)
|
||||
let mut upstream_proxy = self
|
||||
.resolve_launch_proxy(profile)
|
||||
@@ -292,9 +310,16 @@ impl BrowserRunner {
|
||||
vpn_id: String,
|
||||
created: bool,
|
||||
profile_name: String,
|
||||
/// This launch's own hold on the worker, kept until the guard goes out
|
||||
/// of scope so a sibling launch cannot stop the worker while this one
|
||||
/// is still between adoption and publishing its browser PID.
|
||||
claim: Option<crate::vpn_worker_runner::VpnLaunchClaim>,
|
||||
}
|
||||
impl Drop for VpnLaunchGuard {
|
||||
fn drop(&mut self) {
|
||||
// Released before anything reads the claims, or this launch would
|
||||
// count itself as a reason to keep the worker it just failed to use.
|
||||
drop(self.claim.take());
|
||||
let Some(worker_id) = self.worker_id.take() else {
|
||||
return;
|
||||
};
|
||||
@@ -333,6 +358,7 @@ impl BrowserRunner {
|
||||
vpn_id: vpn_id.clone(),
|
||||
created: started.created,
|
||||
profile_name: profile.name.clone(),
|
||||
claim: Some(started.claim),
|
||||
});
|
||||
if let Some(port) = started.config.local_port {
|
||||
upstream_proxy = Some(ProxySettings {
|
||||
@@ -361,6 +387,12 @@ impl BrowserRunner {
|
||||
// unpack, and the browser process, so a blocked launch has nothing to
|
||||
// undo beyond the two workers whose guards are already armed above.
|
||||
//
|
||||
// The group's bookmarks are written before the gate rather than inside
|
||||
// it: the gate returns early for several kinds of profile and answers a
|
||||
// question ("may this launch proceed"), while this is a preparation step
|
||||
// every spawn needs, including a profile with no route to check.
|
||||
crate::group_bookmarks::sync_for_launch(profile);
|
||||
|
||||
// Run concurrently with the blocklist compile so the added wall clock is
|
||||
// max(), not sum().
|
||||
let (blocklist, gate_result) = tokio::join!(
|
||||
@@ -522,21 +554,20 @@ impl BrowserRunner {
|
||||
// launch at all, because nothing tells the user to stop using it.
|
||||
//
|
||||
// Structured rather than prose, because the most common failure is the
|
||||
// browser refusing a generation once the account's hourly quota is
|
||||
// spent. That has to reach the user as an explanation; a raw CDP string
|
||||
// is not one, and the frontend only translates a coded error.
|
||||
// browser refusing a generation outright. That has to reach the user as
|
||||
// an explanation; a raw CDP string is not one, and the frontend only
|
||||
// translates a coded error.
|
||||
let generated = self
|
||||
.wayfern_manager
|
||||
.generate_fingerprint_config(&app_handle, profile, &config_for_generation)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
let detail = e.to_string();
|
||||
// BOTH refusal texts, because this path serves BOTH releases. 151
|
||||
// says "Fingerprint generation limit reached for this account.";
|
||||
// the shipped 150 browser says "Too many profiles are being
|
||||
// created." Matching only the 151 wording leaves a quota-blocked
|
||||
// 150 user staring at a raw CDP string, which is the exact defect
|
||||
// this mapping exists to remove.
|
||||
// BOTH refusal texts, because a profile may be on either browser
|
||||
// version. Older builds word the generation-limit refusal
|
||||
// differently, and matching only one wording leaves those users
|
||||
// staring at a raw CDP string, which is the exact defect this
|
||||
// mapping exists to remove.
|
||||
if detail.contains("generation limit reached") || detail.contains("Too many profiles") {
|
||||
crate::backend_error_with_detail("WAYFERN_GENERATION_LIMIT_REACHED", detail)
|
||||
} else {
|
||||
@@ -650,6 +681,7 @@ impl BrowserRunner {
|
||||
let profile_path_str = profile_data_path.to_string_lossy().to_string();
|
||||
|
||||
// Install extensions if an extension group is assigned
|
||||
emit_launch_stage(profile, "extensions", None);
|
||||
let mut extension_paths = Vec::new();
|
||||
if updated_profile.extension_group_id.is_some() {
|
||||
let mgr = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
|
||||
@@ -673,6 +705,7 @@ impl BrowserRunner {
|
||||
// Get proxy URL from config
|
||||
let proxy_url = wayfern_config.proxy.as_deref();
|
||||
|
||||
emit_launch_stage(profile, "starting", None);
|
||||
let wayfern_result = self
|
||||
.wayfern_manager
|
||||
.launch_wayfern(
|
||||
@@ -686,6 +719,7 @@ impl BrowserRunner {
|
||||
&extension_paths,
|
||||
remote_debugging_port,
|
||||
headless,
|
||||
kind,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> {
|
||||
@@ -874,6 +908,7 @@ impl BrowserRunner {
|
||||
url,
|
||||
remote_debugging_port,
|
||||
headless,
|
||||
crate::wayfern_manager::LaunchKind::Automation,
|
||||
gate,
|
||||
)
|
||||
.await
|
||||
@@ -966,7 +1001,15 @@ impl BrowserRunner {
|
||||
} else {
|
||||
log::info!("Launching new browser instance - browser not running");
|
||||
self
|
||||
.launch_browser_internal(app_handle.clone(), &final_profile, url, None, false, gate)
|
||||
.launch_browser_internal(
|
||||
app_handle.clone(),
|
||||
&final_profile,
|
||||
url,
|
||||
None,
|
||||
false,
|
||||
crate::wayfern_manager::LaunchKind::Interactive,
|
||||
gate,
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
@@ -1003,7 +1046,7 @@ impl BrowserRunner {
|
||||
// "Stop this profile" has to mean the browser that is actually running, and
|
||||
// for a profile on the leased fleet that browser is not on this machine.
|
||||
// Without this, stopping reported success, killed nothing, and left the
|
||||
// session running to its two-hour cap — billing the user for every minute
|
||||
// session running to its maximum duration — spending the user's allowance
|
||||
// and holding their profile lock the whole time.
|
||||
if self.stop_remote_session_for(&app_handle, profile).await? {
|
||||
return Ok(());
|
||||
@@ -1039,10 +1082,9 @@ impl BrowserRunner {
|
||||
crate::remote_session::end_remote_session(&session_id)
|
||||
.await
|
||||
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> {
|
||||
// Surfaced rather than swallowed. The backend refuses to retire a
|
||||
// session it could not stop on the fleet, so a failure here means the
|
||||
// browser is STILL RUNNING; reporting success would tell the user their
|
||||
// profile is free when a host is still writing to it.
|
||||
// Surfaced rather than swallowed. A failure here means the browser is
|
||||
// STILL RUNNING; reporting success would tell the user their profile is
|
||||
// free when a remote host is still writing to it.
|
||||
log::warn!("Failed to stop remote session {session_id}: {e}");
|
||||
e.to_error_json().into()
|
||||
})?;
|
||||
@@ -1051,8 +1093,8 @@ impl BrowserRunner {
|
||||
// the profile into "pending sync" and starts the pull, so the user is not
|
||||
// handed back a profile directory that predates the session they just ran.
|
||||
//
|
||||
// The session's own profile lock is released by the backend when it retires
|
||||
// the row; nothing is released from here, because this client never held it.
|
||||
// The session's own profile lock is released by the server; nothing is
|
||||
// released from here, because this client never held it.
|
||||
crate::remote_session::note_session_stopped(app_handle, &session_id);
|
||||
Ok(true)
|
||||
}
|
||||
@@ -1409,6 +1451,22 @@ impl BrowserRunner {
|
||||
&profile.id.to_string(),
|
||||
);
|
||||
|
||||
// A temporary profile exists for one automation run, so the run ending
|
||||
// is what ends it. Destroyed rather than trashed: nothing here is worth
|
||||
// restoring, and a trash full of automation leftovers is its own bug.
|
||||
if profile.temporary {
|
||||
match self
|
||||
.profile_manager
|
||||
.delete_profile_permanently(&app_handle, &profile.id.to_string())
|
||||
{
|
||||
Ok(()) => log::info!(
|
||||
"Deleted temporary profile {} now that its browser has stopped",
|
||||
profile.name
|
||||
),
|
||||
Err(e) => log::warn!("Could not delete temporary profile {}: {e}", profile.name),
|
||||
}
|
||||
}
|
||||
|
||||
log::info!(
|
||||
"Wayfern process cleanup completed for profile: {} (ID: {})",
|
||||
profile.name,
|
||||
@@ -1632,6 +1690,26 @@ pub async fn launch_browser_profile_impl(
|
||||
profile: BrowserProfile,
|
||||
url: Option<String>,
|
||||
options: LaunchOptions,
|
||||
) -> Result<BrowserProfile, String> {
|
||||
let _profile_launch_guard = lock_profile_launch(&profile.id.to_string()).await;
|
||||
emit_launch_stage(&profile, "queued", None);
|
||||
let result = launch_browser_profile_tracked(app_handle, profile.clone(), url, options).await;
|
||||
match &result {
|
||||
Ok(_) => emit_launch_stage(&profile, "running", None),
|
||||
Err(error) => emit_launch_stage(
|
||||
&profile,
|
||||
"failed",
|
||||
Some(&crate::wrap_backend_error(error, "Browser launch failed")),
|
||||
),
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
async fn launch_browser_profile_tracked(
|
||||
app_handle: tauri::AppHandle,
|
||||
profile: BrowserProfile,
|
||||
url: Option<String>,
|
||||
options: LaunchOptions,
|
||||
) -> Result<BrowserProfile, String> {
|
||||
let LaunchOptions {
|
||||
remote_debugging_port,
|
||||
@@ -1644,7 +1722,7 @@ pub async fn launch_browser_profile_impl(
|
||||
profile.name,
|
||||
profile.id
|
||||
);
|
||||
let _profile_launch_guard = lock_profile_launch(&profile.id.to_string()).await;
|
||||
emit_launch_stage(&profile, "preparing", None);
|
||||
|
||||
if profile.is_cross_os() {
|
||||
return Err(format!(
|
||||
|
||||
+133
-71
@@ -2,21 +2,19 @@
|
||||
//!
|
||||
//! Until this module existed, every automation tool answered "where is this
|
||||
//! browser?" by reading a LOCAL debugging port out of the LOCAL profile
|
||||
//! directory. A profile launched on a leased host has no local port and no
|
||||
//! directory. A profile launched on a remote host has no local port and no
|
||||
//! local process, so a customer who paid for remote execution could start a
|
||||
//! session and then do nothing with it — the one thing the feature exists for.
|
||||
//!
|
||||
//! There is exactly one resolver here, [`resolve`], and one connection type,
|
||||
//! [`CdpConnection`]. Tools ask for a target and get either a page socket on
|
||||
//! this machine or a relayed socket to the fleet; nothing above this module
|
||||
//! branches on which. That is deliberate: a parallel set of remote-only tools
|
||||
//! would drift from the local ones within a release.
|
||||
//! this machine or a relayed socket to a remote browser; nothing above this
|
||||
//! module branches on which. That is deliberate: a parallel set of remote-only
|
||||
//! tools would drift from the local ones within a release.
|
||||
//!
|
||||
//! The remote arm reaches donutbrowser-infra with the USER's own access token.
|
||||
//! The desktop holds no fleet credential and knows no fleet hostname — infra
|
||||
//! verifies the session belongs to the caller and relays onward with its own
|
||||
//! service credential. That boundary is why this is a relay and not a direct
|
||||
//! connection.
|
||||
//! The remote arm reaches the cloud API with the USER's own access token, and
|
||||
//! never holds any credential or hostname belonging to the machine the browser
|
||||
//! runs on. That boundary is why this is a relay and not a direct connection.
|
||||
|
||||
use crate::profile::types::BrowserProfile;
|
||||
use serde_json::Value;
|
||||
@@ -30,16 +28,15 @@ use tokio_tungstenite::{MaybeTlsStream, WebSocketStream};
|
||||
|
||||
/// How long the WebSocket handshake may take.
|
||||
///
|
||||
/// A remote attach crosses desktop → infra → wayfern → agent → the VM, so this
|
||||
/// is far longer than a loopback connect needs. It matches the relay's own
|
||||
/// upstream handshake budget: waiting longer than the server does can only
|
||||
/// report a timeout the server already reported.
|
||||
/// A remote attach crosses several networks before it reaches the browser, so
|
||||
/// this is far longer than a loopback connect needs. Waiting longer than the
|
||||
/// server does can only report a timeout the server already reported.
|
||||
const CONNECT_TIMEOUT: Duration = Duration::from_secs(20);
|
||||
|
||||
/// How long one CDP command may wait for its reply.
|
||||
///
|
||||
/// Without a cap, a browser that never answers holds the caller until the
|
||||
/// socket dies — 90 seconds on the relay, indefinitely on loopback. An
|
||||
/// socket dies — a bounded wait remotely, indefinitely on loopback. An
|
||||
/// automation client that hangs is worse than one that fails.
|
||||
const COMMAND_TIMEOUT: Duration = Duration::from_secs(60);
|
||||
|
||||
@@ -51,10 +48,9 @@ const CONNECT_RETRY_BASE: Duration = Duration::from_millis(400);
|
||||
|
||||
/// Ceiling on a relayed CDP message.
|
||||
///
|
||||
/// Matches the relay's client-facing cap, which matches the fleet's upstream
|
||||
/// frame cap. Lower, and a screenshot the server was willing to carry is
|
||||
/// dropped on arrival; higher buys nothing, because the frame never crosses the
|
||||
/// relay in the first place.
|
||||
/// Matches the frame cap the remote endpoint enforces. Lower, and a screenshot
|
||||
/// the server was willing to carry is dropped on arrival; higher buys nothing,
|
||||
/// because the frame never crosses the network in the first place.
|
||||
const REMOTE_MAX_MESSAGE_BYTES: usize = 16 * 1024 * 1024;
|
||||
|
||||
/// Command ids for the two messages the remote arm sends before any tool does.
|
||||
@@ -72,9 +68,9 @@ pub enum CdpTarget {
|
||||
/// A browser on this machine. The URL is a PAGE-level socket, so commands
|
||||
/// carry no CDP session id.
|
||||
Local { ws_url: String },
|
||||
/// A browser on the fleet, reached through the infra relay. The relay bridges
|
||||
/// a BROWSER-level socket, so the connection attaches to a page and stamps
|
||||
/// every subsequent message with the resulting session id.
|
||||
/// A browser running remotely, reached through the cloud API. The remote
|
||||
/// endpoint exposes a BROWSER-level socket, so the connection attaches to a
|
||||
/// page and stamps every subsequent message with the resulting session id.
|
||||
Remote {
|
||||
ws_url: String,
|
||||
bearer: String,
|
||||
@@ -83,7 +79,7 @@ pub enum CdpTarget {
|
||||
}
|
||||
|
||||
impl CdpTarget {
|
||||
/// True when this browser is on the leased fleet rather than this machine.
|
||||
/// True when this browser is on a remote host rather than this machine.
|
||||
pub fn is_remote(&self) -> bool {
|
||||
matches!(self, Self::Remote { .. })
|
||||
}
|
||||
@@ -105,9 +101,9 @@ impl CdpTarget {
|
||||
/// broken one.
|
||||
#[derive(Debug)]
|
||||
pub enum CdpError {
|
||||
/// Nothing is listening, or the relay could not reach the browser.
|
||||
/// Nothing is listening, or the browser could not be reached.
|
||||
Unreachable(String),
|
||||
/// The relay refused the credential.
|
||||
/// The credential was refused.
|
||||
Unauthorized(String),
|
||||
/// The session exists but is not in a state that can be driven.
|
||||
NotDrivable(String),
|
||||
@@ -133,9 +129,9 @@ impl CdpError {
|
||||
/// Whether a fresh connection attempt could plausibly succeed.
|
||||
///
|
||||
/// A refused credential and a session that is still provisioning are answers,
|
||||
/// not failures. Retrying either spends the caller's time and, on the relay,
|
||||
/// burns one of the four attachments a session is allowed — so the retry can
|
||||
/// make the next honest attempt fail too.
|
||||
/// not failures. Retrying either spends the caller's time and counts against
|
||||
/// the session's attachment budget — so the retry can make the next honest
|
||||
/// attempt fail too.
|
||||
fn is_retryable(&self) -> bool {
|
||||
matches!(self, Self::Unreachable(_) | Self::Transport(_))
|
||||
}
|
||||
@@ -185,11 +181,11 @@ impl Patience {
|
||||
/// one crosses two networks.
|
||||
///
|
||||
/// The local check is deliberately split in two. One cheap probe decides the
|
||||
/// arm, so a profile running on the fleet is not held behind twenty-five
|
||||
/// seconds of local retries; only once remote has been ruled out does the local
|
||||
/// probe spend its full budget waiting for a browser that is still starting.
|
||||
/// The same split covers a stale `process_id` left by a crash — nothing answers
|
||||
/// on the recorded port, so the fleet session is found instead of a dead one.
|
||||
/// arm, so a profile running remotely is not held behind twenty-five seconds of
|
||||
/// local retries; only once remote has been ruled out does the local probe
|
||||
/// spend its full budget waiting for a browser that is still starting. The same
|
||||
/// split covers a stale `process_id` left by a crash — nothing answers on the
|
||||
/// recorded port, so the remote session is found instead of a dead one.
|
||||
pub async fn resolve(profile: &BrowserProfile) -> Result<CdpTarget, ResolveError> {
|
||||
if profile.browser != "wayfern" {
|
||||
return Err(ResolveError::Unsupported(format!(
|
||||
@@ -304,12 +300,30 @@ async fn local_page_ws_url(profile: &BrowserProfile, patience: Patience) -> Opti
|
||||
}
|
||||
|
||||
/// Pick a drivable page from what `/json` lists on a local browser.
|
||||
///
|
||||
/// DRIVABLE, not merely first. This used to take the first `type == "page"` and
|
||||
/// then reach for its socket, so a first entry without a
|
||||
/// `webSocketDebuggerUrl`, a page another client is already attached to, which
|
||||
/// Chromium omits the field for, made the whole call answer None and every
|
||||
/// browser tool fail, while a perfectly drivable second tab sat right behind it.
|
||||
/// The user sees "no page target found in browser" on a browser plainly showing
|
||||
/// pages.
|
||||
///
|
||||
/// `devtools://` is excluded for the same reason [`pick_remote_page_target`]
|
||||
/// excludes it: attaching there drives the inspector rather than the site, which
|
||||
/// reports success and moves nothing. The two functions answer the same question
|
||||
/// off different payload shapes, so they must not disagree about what counts.
|
||||
pub fn pick_local_page_socket(targets: &[Value]) -> Option<String> {
|
||||
targets
|
||||
.iter()
|
||||
.find(|t| t.get("type").and_then(Value::as_str) == Some("page"))
|
||||
.and_then(|t| t.get("webSocketDebuggerUrl"))
|
||||
.and_then(Value::as_str)
|
||||
.filter(|t| t.get("type").and_then(Value::as_str) == Some("page"))
|
||||
.filter(|t| {
|
||||
!t.get("url")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.starts_with("devtools://")
|
||||
})
|
||||
.find_map(|t| t.get("webSocketDebuggerUrl").and_then(Value::as_str))
|
||||
.map(str::to_string)
|
||||
}
|
||||
|
||||
@@ -414,10 +428,9 @@ impl CdpConnection {
|
||||
|
||||
/// Turn a hang-up into the error it means.
|
||||
///
|
||||
/// The relay's close codes are its whole vocabulary: 1008 is "that credential
|
||||
/// is no good", 1013 is "come back when the session is up". Reporting either
|
||||
/// as a generic transport failure throws away the only actionable thing the
|
||||
/// server said.
|
||||
/// The close codes carry the only actionable thing the server says: 1008
|
||||
/// means the credential was refused, 1013 means the session is not up yet.
|
||||
/// Reporting either as a generic transport failure throws that away.
|
||||
pub fn closed_error(&self, context: &str) -> CdpError {
|
||||
match &self.closed {
|
||||
Some(info) if info.reason.is_empty() => {
|
||||
@@ -476,20 +489,20 @@ impl CdpConnection {
|
||||
|
||||
/// Hang up politely so the peer releases its side immediately.
|
||||
///
|
||||
/// On the relay every open socket costs a real stream on the leased host and
|
||||
/// counts against the session's attachment cap, so dropping the TCP
|
||||
/// connection and letting it time out is not good enough.
|
||||
/// A remote socket that is not closed keeps consuming the session's
|
||||
/// attachment budget, so dropping the TCP connection and letting it time out
|
||||
/// is not good enough.
|
||||
pub async fn close(mut self) {
|
||||
let _ = self.stream.close(None).await;
|
||||
}
|
||||
|
||||
/// Move a browser-level socket onto a page.
|
||||
///
|
||||
/// The relay bridges `/devtools/browser/<id>`. Every tool here speaks
|
||||
/// `Page.*`, `Runtime.*` and `Input.*`, which a browser socket answers with
|
||||
/// `'Page.navigate' wasn't found`. Attaching flat, and stamping the resulting
|
||||
/// session id onto everything after it, is what makes the tools this app
|
||||
/// already has work remotely without a single per-tool change.
|
||||
/// The remote endpoint exposes `/devtools/browser/<id>`. Every tool here
|
||||
/// speaks `Page.*`, `Runtime.*` and `Input.*`, which a browser socket answers
|
||||
/// with `'Page.navigate' wasn't found`. Attaching flat, and stamping the
|
||||
/// resulting session id onto everything after it, is what makes the tools
|
||||
/// this app already has work remotely without a single per-tool change.
|
||||
async fn attach_to_page(&mut self) -> Result<(), CdpError> {
|
||||
let targets = self
|
||||
.call(
|
||||
@@ -641,8 +654,8 @@ pub async fn run_command_awaiting_load(
|
||||
///
|
||||
/// This is what "open a URL in that profile" means once the browser is already
|
||||
/// up, wherever it is. A remote session navigates its existing page rather than
|
||||
/// opening a tab: a tab opened on a leased host that nobody can see or close is
|
||||
/// not a feature, it is litter on hardware the user is paying for by the hour.
|
||||
/// opening a tab: a tab opened on a remote host that nobody can see or close is
|
||||
/// not a feature, it is litter on time the user is paying for by the hour.
|
||||
pub async fn navigate(target: &CdpTarget, url: &str, timeout_secs: u64) -> Result<(), CdpError> {
|
||||
run_command_awaiting_load(
|
||||
target,
|
||||
@@ -730,9 +743,9 @@ pub type RelaySocket = WebSocketStream<MaybeTlsStream<TcpStream>>;
|
||||
/// target from it and stamp a session id onto messages it did not address.
|
||||
///
|
||||
/// This is what makes a remote session usable from outside the app at all. The
|
||||
/// relay only accepts the user's cloud credential, which no API consumer holds
|
||||
/// and none should — so the socket is opened here, with the credential this
|
||||
/// process already has, and proxied to the caller.
|
||||
/// endpoint only accepts the user's cloud credential, which no API consumer
|
||||
/// holds and none should — so the socket is opened here, with the credential
|
||||
/// this process already has, and proxied to the caller.
|
||||
pub async fn open_relay_socket(session_id: &str) -> Result<RelaySocket, CdpError> {
|
||||
let endpoint = crate::remote_session::cdp_endpoint(session_id)
|
||||
.await
|
||||
@@ -782,7 +795,7 @@ fn endpoint_lookup_error(err: crate::remote_session::RemoteSessionError) -> CdpE
|
||||
}
|
||||
}
|
||||
|
||||
/// Frame limits for a relay socket. Matches the relay's own client-facing cap.
|
||||
/// Frame limits for a relay socket. Matches the cap the remote endpoint sets.
|
||||
pub fn relay_socket_config() -> WebSocketConfig {
|
||||
WebSocketConfig::default()
|
||||
.max_message_size(Some(REMOTE_MAX_MESSAGE_BYTES))
|
||||
@@ -935,12 +948,61 @@ mod tests {
|
||||
assert!(pick_local_page_socket(&[]).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_page_that_cannot_be_driven_does_not_hide_the_one_that_can() {
|
||||
// Chromium omits `webSocketDebuggerUrl` for a page another client is
|
||||
// already attached to. Committing to the FIRST page and then reaching for
|
||||
// its socket answered None for the whole browser, so every browser tool
|
||||
// failed with "no page target found" while a drivable tab sat behind it.
|
||||
let attached_first = vec![
|
||||
serde_json::json!({ "type": "page", "url": "https://example.com/" }),
|
||||
serde_json::json!({
|
||||
"type": "page",
|
||||
"url": "https://example.com/two",
|
||||
"webSocketDebuggerUrl": "ws://127.0.0.1:1/devtools/page/B"
|
||||
}),
|
||||
];
|
||||
assert_eq!(
|
||||
pick_local_page_socket(&attached_first).as_deref(),
|
||||
Some("ws://127.0.0.1:1/devtools/page/B")
|
||||
);
|
||||
|
||||
// And the inspector is not a site. Attaching here drives DevTools itself -
|
||||
// the failure `pick_remote_page_target` already documents, which reports
|
||||
// success and moves nothing. The two pickers answer the same question off
|
||||
// different payloads and must not disagree.
|
||||
let devtools_first = vec![
|
||||
serde_json::json!({
|
||||
"type": "page",
|
||||
"url": "devtools://devtools/bundled/devtools_app.html",
|
||||
"webSocketDebuggerUrl": "ws://127.0.0.1:1/devtools/page/DEVTOOLS"
|
||||
}),
|
||||
serde_json::json!({
|
||||
"type": "page",
|
||||
"url": "https://example.com/",
|
||||
"webSocketDebuggerUrl": "ws://127.0.0.1:1/devtools/page/REAL"
|
||||
}),
|
||||
];
|
||||
assert_eq!(
|
||||
pick_local_page_socket(&devtools_first).as_deref(),
|
||||
Some("ws://127.0.0.1:1/devtools/page/REAL")
|
||||
);
|
||||
|
||||
// A listing with pages but nothing drivable still answers None rather than
|
||||
// handing back a non-page socket.
|
||||
let nothing_drivable = vec![
|
||||
serde_json::json!({ "type": "page", "url": "https://example.com/" }),
|
||||
serde_json::json!({ "type": "worker", "webSocketDebuggerUrl": "ws://x/w" }),
|
||||
];
|
||||
assert!(pick_local_page_socket(¬hing_drivable).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_remote_frame_addresses_the_page_and_a_local_one_does_not() {
|
||||
// A page-level command sent on the relay's BROWSER socket comes back as
|
||||
// A page-level command sent on a BROWSER-level socket comes back as
|
||||
// "'Page.navigate' wasn't found". One missing sessionId on one message is
|
||||
// enough to make a single tool fail while every other tool works — a
|
||||
// partial failure that reads as a flaky VM.
|
||||
// partial failure that reads as a flaky remote browser.
|
||||
let remote = cdp_frame(
|
||||
Some("SESSION-42"),
|
||||
7,
|
||||
@@ -959,9 +1021,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn a_relay_close_says_what_the_caller_should_do_about_it() {
|
||||
// These codes are the relay's entire vocabulary. Collapsing them into one
|
||||
// transport failure is how "your session is still provisioning" and "you
|
||||
// are signed out" both become "something went wrong".
|
||||
// These codes carry the whole answer. Collapsing them into one transport
|
||||
// failure is how "your session is still provisioning" and "you are signed
|
||||
// out" both become "something went wrong".
|
||||
assert!(matches!(
|
||||
classify_close(1008, "x".into()),
|
||||
CdpError::Unauthorized(_)
|
||||
@@ -1038,9 +1100,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn a_session_that_is_over_is_not_reported_as_a_broken_gateway() {
|
||||
// Observed against the real backend: attaching to a session the user had
|
||||
// just stopped answered 502, so a CDP client read "this is finished" as
|
||||
// "the gateway is down" and retried it.
|
||||
// A session the user has already stopped, or one that is not theirs, must
|
||||
// read as a 404: there is no browser at this address. Collapsing it into
|
||||
// "unreachable" makes an automation client retry a finished session.
|
||||
use crate::remote_session::RemoteSessionError;
|
||||
assert!(matches!(
|
||||
endpoint_lookup_error(RemoteSessionError::Other(
|
||||
@@ -1090,7 +1152,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn a_hasty_probe_tries_once_and_a_patient_one_waits() {
|
||||
// The split is what stops a profile running on the fleet from being held
|
||||
// The split is what stops a profile running remotely from being held
|
||||
// behind twenty-five seconds of local retries before anyone looks remote.
|
||||
assert_eq!(Patience::Immediate.attempts(10), 1);
|
||||
assert_eq!(Patience::WaitForLaunch.attempts(10), 10);
|
||||
@@ -1121,7 +1183,7 @@ mod tests {
|
||||
/// Hang up the way a session that is not yet up does.
|
||||
RefuseAsNotDrivable,
|
||||
/// Answer the navigation, then drop the socket before the load event,
|
||||
/// the way a relay does when the browser it bridges dies mid-navigation.
|
||||
/// the way the remote endpoint does when its browser dies mid-navigation.
|
||||
DropAfterNavigateReply,
|
||||
/// Drop the socket without answering the navigation.
|
||||
DropBeforeNavigateReply,
|
||||
@@ -1134,7 +1196,7 @@ mod tests {
|
||||
/// The CDP session id the fake relay hands out for a flat attach.
|
||||
const FAKE_CDP_SESSION: &str = "CDP-SESSION-1";
|
||||
|
||||
/// A stand-in for the infra relay bridged onto a browser-level socket.
|
||||
/// A stand-in for the remote endpoint bridged onto a browser-level socket.
|
||||
///
|
||||
/// Answers `Target.getTargets` and `Target.attachToTarget` exactly as a real
|
||||
/// browser endpoint does, then echoes each command back so the test can read
|
||||
@@ -1243,8 +1305,8 @@ mod tests {
|
||||
}
|
||||
|
||||
// The browser died mid-navigation: drop the socket without a close
|
||||
// frame, the way a relay does when the VM it bridges goes away. The
|
||||
// command's reply is already in the client's hands.
|
||||
// frame, the way the remote endpoint does when the browser behind it
|
||||
// goes away. The command's reply is already in the client's hands.
|
||||
if behaviour == RelayBehaviour::DropAfterNavigateReply && method == "Page.navigate" {
|
||||
break;
|
||||
}
|
||||
@@ -1275,10 +1337,10 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_relayed_page_command_is_attached_and_stamped_with_its_session() {
|
||||
// This is the whole feature. The relay bridges /devtools/browser/<id>, so
|
||||
// without the flat attach and the sessionId stamp every existing tool
|
||||
// answers "'Page.navigate' wasn't found" and a paid remote session cannot
|
||||
// be used for anything.
|
||||
// This is the whole feature. The remote endpoint exposes
|
||||
// /devtools/browser/<id>, so without the flat attach and the sessionId
|
||||
// stamp every existing tool answers "'Page.navigate' wasn't found" and a
|
||||
// paid remote session cannot be used for anything.
|
||||
let (ws_url, server) = fake_relay(RelayBehaviour::Cooperative).await;
|
||||
let target = CdpTarget::Remote {
|
||||
ws_url,
|
||||
@@ -1368,7 +1430,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_session_that_is_not_up_yet_is_reported_as_such_not_as_a_broken_one() {
|
||||
// 1013 is the relay saying "come back when it is live". Surfacing it as a
|
||||
// 1013 means "come back when it is live". Surfacing it as a
|
||||
// transport failure would send an automation client into a retry loop
|
||||
// against a session that is doing exactly what it should.
|
||||
let (ws_url, _server) = fake_relay(RelayBehaviour::RefuseAsNotDrivable).await;
|
||||
|
||||
+594
-62
@@ -2,7 +2,6 @@ use aes_gcm::{
|
||||
aead::{Aead, KeyInit},
|
||||
Aes256Gcm, Key, Nonce,
|
||||
};
|
||||
use argon2::{password_hash::SaltString, Argon2, PasswordHasher};
|
||||
use chrono::Utc;
|
||||
use lazy_static::lazy_static;
|
||||
use rand::RngExt;
|
||||
@@ -15,18 +14,18 @@ use tokio::sync::Mutex;
|
||||
|
||||
use crate::browser::ProxySettings;
|
||||
use crate::proxy_manager::PROXY_MANAGER;
|
||||
use crate::settings_manager::SettingsManager;
|
||||
use crate::settings_manager::{SettingsManager, StoredMcpRemoteKey};
|
||||
use crate::sync;
|
||||
|
||||
pub const CLOUD_API_URL: &str = "https://api.donutbrowser.com";
|
||||
pub const CLOUD_SYNC_URL: &str = "https://sync.donutbrowser.com";
|
||||
|
||||
/// Default per-hour cap on local automation API / MCP requests. Mirrors the
|
||||
/// backend's DEFAULT_REQUESTS_PER_HOUR.
|
||||
/// Default per-hour cap on local automation API / MCP requests, used when the
|
||||
/// cloud API has not sent one.
|
||||
const DEFAULT_REQUESTS_PER_HOUR: i64 = 100;
|
||||
|
||||
/// Capability + limit set the account is entitled to, derived from its plan.
|
||||
/// Mirrors `apps/backend/src/plans/entitlements.ts`. Features are gated on these
|
||||
/// Mirrors the entitlement set the cloud API sends. Features are gated on these
|
||||
/// flags instead of a single "is paid?" boolean, so a plan like "solo" (cloud
|
||||
/// backup + nightly cookie bot, no automation, no fingerprint editing, no
|
||||
/// hands-on remote session) is just data here.
|
||||
@@ -54,6 +53,23 @@ pub struct Entitlements {
|
||||
/// control must read THIS rather than `remote_browser_hours > 0`.
|
||||
#[serde(rename = "remoteInteractive", default)]
|
||||
pub remote_interactive: bool,
|
||||
/// Whether the plan may drive THIS desktop from Donut cloud: the remote MCP
|
||||
/// endpoint and the API in front of it.
|
||||
///
|
||||
/// Read only by the UI. The bridge itself never gates on this: the relay
|
||||
/// decides who may send work, and a cached entitlement that is a refresh
|
||||
/// cycle out of date must not be what refuses a customer their own machine.
|
||||
#[serde(rename = "remoteControl", default)]
|
||||
pub remote_control: bool,
|
||||
/// Whether the plan may run the browsing agent: a goal the cloud pursues on
|
||||
/// one profile, on this desktop or on a leased host.
|
||||
///
|
||||
/// Read only by the UI, and never back-filled from `browser_automation`. A
|
||||
/// backend too old to send this key is a backend with no `api/agent` routes
|
||||
/// to be entitled to, so `false` is the true answer rather than a gap to
|
||||
/// guess at — the same reasoning `remote_control` is held to.
|
||||
#[serde(rename = "agentAutomation", default)]
|
||||
pub agent_automation: bool,
|
||||
#[serde(rename = "profileLimit", default)]
|
||||
pub profile_limit: i64,
|
||||
#[serde(rename = "requestsPerHour", default)]
|
||||
@@ -83,17 +99,26 @@ fn derive_entitlements(
|
||||
team_collaboration: false,
|
||||
cookie_bot: false,
|
||||
remote_interactive: false,
|
||||
remote_control: false,
|
||||
agent_automation: false,
|
||||
profile_limit: 0,
|
||||
requests_per_hour: 0,
|
||||
remote_browser_hours: 0,
|
||||
};
|
||||
}
|
||||
// Tuple order: (browser_automation, cross_os_fingerprints, cloud_backup,
|
||||
// team_collaboration, cookie_bot, remote_interactive).
|
||||
// team_collaboration, cookie_bot, remote_interactive, remote_control,
|
||||
// agent_automation).
|
||||
//
|
||||
// pro and any unrecognized paid plan -> pro-level (never team). Solo is the
|
||||
// one row where cookie_bot and browser_automation disagree, which is why
|
||||
// cookie_bot can no longer be derived from browser_automation below.
|
||||
//
|
||||
// remote_control is enterprise-only, and is withheld from the unrecognized
|
||||
// row rather than granted with the rest. Everything else here defaults
|
||||
// generous so a comped account is never locked out of what it is paying for;
|
||||
// an internet-facing hook into this machine is the one capability where
|
||||
// guessing "probably yes" is not the safe direction to guess in.
|
||||
let (
|
||||
browser_automation,
|
||||
cross_os_fingerprints,
|
||||
@@ -101,10 +126,13 @@ fn derive_entitlements(
|
||||
team_collaboration,
|
||||
cookie_bot,
|
||||
remote_interactive,
|
||||
remote_control,
|
||||
agent_automation,
|
||||
) = match plan {
|
||||
"solo" => (false, false, true, false, true, false),
|
||||
"team" | "enterprise" => (true, true, true, true, true, true),
|
||||
_ => (true, true, true, false, true, true),
|
||||
"solo" => (false, false, true, false, true, false, false, false),
|
||||
"enterprise" => (true, true, true, true, true, true, true, true),
|
||||
"team" => (true, true, true, true, true, true, false, true),
|
||||
_ => (true, true, true, false, true, true, false, true),
|
||||
};
|
||||
Entitlements {
|
||||
active,
|
||||
@@ -114,6 +142,8 @@ fn derive_entitlements(
|
||||
team_collaboration,
|
||||
cookie_bot,
|
||||
remote_interactive,
|
||||
remote_control,
|
||||
agent_automation,
|
||||
profile_limit,
|
||||
requests_per_hour: if browser_automation {
|
||||
DEFAULT_REQUESTS_PER_HOUR
|
||||
@@ -151,10 +181,15 @@ pub struct CloudUser {
|
||||
pub team_name: Option<String>,
|
||||
#[serde(rename = "teamRole", default)]
|
||||
pub team_role: Option<String>,
|
||||
/// The plan this account is served under. A team member's `plan` stays
|
||||
/// `"free"` (the owner pays) while the backend resolves this to the owner's
|
||||
/// tier. `default` keeps the login response and older backends deserializing;
|
||||
/// read it through `effective_plan()`.
|
||||
#[serde(rename = "effectivePlan", default)]
|
||||
pub effective_plan: Option<String>,
|
||||
// This desktop session's position among the user's active devices, oldest
|
||||
// first. Ordinal 1 is the primary device — the only one that can run browser
|
||||
// automation. `default` keeps older login/state payloads (which lack these
|
||||
// fields) deserializing cleanly.
|
||||
// first, as the cloud API reports it. Shown in the UI. `default` keeps older
|
||||
// login/state payloads (which lack these fields) deserializing cleanly.
|
||||
#[serde(rename = "deviceOrdinal", default)]
|
||||
pub device_ordinal: Option<i64>,
|
||||
#[serde(rename = "deviceCount", default)]
|
||||
@@ -168,6 +203,13 @@ pub struct CloudUser {
|
||||
}
|
||||
|
||||
impl CloudUser {
|
||||
/// The plan the account is actually served under: `effectivePlan` when the
|
||||
/// backend sent one, else the row's own `plan`. Gates that ask "is this a
|
||||
/// paid / team account" read this; billing-only surfaces keep `plan`.
|
||||
pub fn effective_plan(&self) -> &str {
|
||||
self.effective_plan.as_deref().unwrap_or(&self.plan)
|
||||
}
|
||||
|
||||
/// Authoritative entitlements: the server-sent set when present, else derived
|
||||
/// locally from the plan fields (keeps older cached state / backends working).
|
||||
pub fn entitlements(&self) -> Entitlements {
|
||||
@@ -227,6 +269,51 @@ struct SyncTokenResponse {
|
||||
sync_token: String,
|
||||
}
|
||||
|
||||
/// Prefix of a remote MCP credential. Only a key carrying this prefix may ever
|
||||
/// be stored here; a credential of any other kind is rejected.
|
||||
pub const MCP_KEY_PREFIX: &str = "dmk_";
|
||||
|
||||
/// A freshly minted remote MCP credential. The plaintext `key` is shown by the
|
||||
/// server exactly once, in this response.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct McpKeyGrant {
|
||||
pub id: String,
|
||||
pub token_prefix: String,
|
||||
pub key: String,
|
||||
}
|
||||
|
||||
/// What a key endpoint answered once the request itself went through.
|
||||
///
|
||||
/// `api_call_with_retry` reads a 401 out of the ERROR string and refreshes the
|
||||
/// session once, so only a 401 (and a transport failure) may be an `Err`. Every
|
||||
/// other refusal travels as a value, so it reaches the code mapping below
|
||||
/// instead of being mistaken for a dead session.
|
||||
enum McpKeyAnswer<T> {
|
||||
Granted(T),
|
||||
Refused { status: u16, body: String },
|
||||
}
|
||||
|
||||
/// The `{"code"}` the UI shows for a refused credential mint.
|
||||
///
|
||||
/// A 409 on `POST /api/mcp/keys` is the per-account cap, whether or not the
|
||||
/// server bothered to name it; everything else is "not right now", carrying
|
||||
/// the server's message as the detail so the log says why.
|
||||
fn mcp_key_refusal(status: u16, body: &str) -> String {
|
||||
if status == 409 {
|
||||
return crate::backend_error("MCP_REMOTE_KEY_LIMIT");
|
||||
}
|
||||
let message = serde_json::from_str::<serde_json::Value>(body)
|
||||
.ok()
|
||||
.and_then(|v| {
|
||||
v.get("message")
|
||||
.and_then(|m| m.as_str())
|
||||
.map(std::string::ToString::to_string)
|
||||
})
|
||||
.unwrap_or_else(|| body.to_string());
|
||||
crate::backend_error_with_detail("MCP_REMOTE_KEY_UNAVAILABLE", format!("{status}: {message}"))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct WayfernTokenResponse {
|
||||
token: String,
|
||||
@@ -304,17 +391,9 @@ impl CloudAuthManager {
|
||||
|
||||
let vault_password = Self::get_vault_password();
|
||||
let salt_bytes: [u8; 16] = rand::rng().random();
|
||||
let salt =
|
||||
SaltString::encode_b64(&salt_bytes).map_err(|e| format!("Failed to encode salt: {e}"))?;
|
||||
let argon2 = Argon2::default();
|
||||
let password_hash = argon2
|
||||
.hash_password(vault_password.as_bytes(), &salt)
|
||||
.map_err(|e| format!("Argon2 key derivation failed: {e}"))?;
|
||||
let hash_value = password_hash.hash.unwrap();
|
||||
let hash_bytes = hash_value.as_bytes();
|
||||
let key_bytes: [u8; 32] = hash_bytes[..32]
|
||||
.try_into()
|
||||
.map_err(|_| "Invalid key length".to_string())?;
|
||||
let salt = crate::sync::encryption::encode_salt(&salt_bytes);
|
||||
let key_bytes =
|
||||
crate::sync::encryption::derive_vault_key(vault_password.as_bytes(), &salt_bytes)?;
|
||||
let key = Key::<Aes256Gcm>::from(key_bytes);
|
||||
let cipher = Aes256Gcm::new(&key);
|
||||
let nonce_bytes: [u8; 12] = rand::rng().random();
|
||||
@@ -366,7 +445,7 @@ impl CloudAuthManager {
|
||||
}
|
||||
let salt_bytes = &file_data[offset..offset + salt_len];
|
||||
let salt_str = std::str::from_utf8(salt_bytes).map_err(|_| "Invalid salt encoding")?;
|
||||
let salt = SaltString::from_b64(salt_str).map_err(|_| "Invalid salt format")?;
|
||||
let salt_bytes = crate::sync::encryption::decode_salt(salt_str)?;
|
||||
offset += salt_len;
|
||||
|
||||
if offset + 12 > file_data.len() {
|
||||
@@ -395,15 +474,8 @@ impl CloudAuthManager {
|
||||
let ciphertext = &file_data[offset..offset + ciphertext_len];
|
||||
|
||||
let vault_password = Self::get_vault_password();
|
||||
let argon2 = Argon2::default();
|
||||
let password_hash = argon2
|
||||
.hash_password(vault_password.as_bytes(), &salt)
|
||||
.map_err(|e| format!("Argon2 key derivation failed: {e}"))?;
|
||||
let hash_value = password_hash.hash.unwrap();
|
||||
let hash_bytes = hash_value.as_bytes();
|
||||
let key_bytes: [u8; 32] = hash_bytes[..32]
|
||||
.try_into()
|
||||
.map_err(|_| "Invalid key length".to_string())?;
|
||||
let key_bytes =
|
||||
crate::sync::encryption::derive_vault_key(vault_password.as_bytes(), &salt_bytes)?;
|
||||
let key = Key::<Aes256Gcm>::from(key_bytes);
|
||||
let cipher = Aes256Gcm::new(&key);
|
||||
let plaintext = cipher
|
||||
@@ -560,9 +632,9 @@ impl CloudAuthManager {
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
// The backend returns { message, code, … } for 4xx (e.g. the 3-device
|
||||
// limit or a temporary security block). Surface the human-readable
|
||||
// message rather than the raw JSON so the sign-in screen is clear.
|
||||
// The cloud API returns { message, code, … } for 4xx. Surface the
|
||||
// human-readable message rather than the raw JSON so the sign-in screen
|
||||
// is clear.
|
||||
let message = serde_json::from_str::<serde_json::Value>(&body)
|
||||
.ok()
|
||||
.and_then(|v| {
|
||||
@@ -672,10 +744,206 @@ impl CloudAuthManager {
|
||||
pub async fn invalidate_session(&self) {
|
||||
log::warn!("Invalidating session — clearing all auth state");
|
||||
PROXY_MANAGER.remove_cloud_proxy();
|
||||
// Same reason `logout` does it: left running, the bridge reconnects with a
|
||||
// credential that no longer exists, fails, and backs off into an
|
||||
// "unauthorized" the account page shows to somebody whose session simply
|
||||
// expired. This is the AUTOMATIC twin of logout, reached when the
|
||||
// background refresh loop gives up, and it was the one teardown path that
|
||||
// did not close the bridge.
|
||||
crate::mcp_remote::stop(None);
|
||||
// The stored `dmk_` key belongs to the account that just left this
|
||||
// machine, and the agent configs would keep presenting it. There is no
|
||||
// session left to revoke it with, so only the local copy goes; the key
|
||||
// itself is retired from the account page.
|
||||
Self::forget_mcp_key_locally("after the session expired");
|
||||
self.clear_auth().await;
|
||||
let _ = crate::events::emit_empty("cloud-auth-expired");
|
||||
}
|
||||
|
||||
/// Ask the server whether this account may drive a desktop remotely.
|
||||
///
|
||||
/// The AUTHORITATIVE answer, and the only one that is correct for a team
|
||||
/// member: a team member's locally cached plan does not describe what their
|
||||
/// seat is entitled to, so the server is asked rather than guessed at.
|
||||
///
|
||||
/// It cannot be inferred from the socket either. A connected bridge only
|
||||
/// proves the plan is active, and nothing about this capability.
|
||||
pub async fn fetch_remote_control_entitlement(&self) -> Result<bool, String> {
|
||||
self
|
||||
.api_call_with_retry(|access_token| {
|
||||
let url = format!("{CLOUD_API_URL}/api/mcp/status");
|
||||
let client = self.client.clone();
|
||||
async move {
|
||||
let response = client
|
||||
.get(&url)
|
||||
.header("Authorization", format!("Bearer {access_token}"))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to read remote-control status: {e}"))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
return Err(format!("Remote-control status failed ({status})"));
|
||||
}
|
||||
|
||||
let body: serde_json::Value = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse remote-control status: {e}"))?;
|
||||
|
||||
Ok(
|
||||
body
|
||||
.get("entitled")
|
||||
.and_then(serde_json::Value::as_bool)
|
||||
.unwrap_or(false),
|
||||
)
|
||||
}
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Mint a remote MCP credential for this account.
|
||||
///
|
||||
/// The server caps how many live keys one account may hold and answers a mint
|
||||
/// past that cap with a 409; the rotation command handles that by retiring the
|
||||
/// key it is replacing first. The plaintext in the answer is the only copy
|
||||
/// there will ever be.
|
||||
pub async fn create_mcp_key(&self, label: &str) -> Result<McpKeyGrant, String> {
|
||||
let answer = self
|
||||
.api_call_with_retry(|access_token| {
|
||||
let url = format!("{CLOUD_API_URL}/api/mcp/keys");
|
||||
let client = self.client.clone();
|
||||
let label = label.to_string();
|
||||
async move {
|
||||
let response = client
|
||||
.post(&url)
|
||||
.header("Authorization", format!("Bearer {access_token}"))
|
||||
.json(&serde_json::json!({ "label": label }))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to request an MCP credential: {e}"))?;
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
if status.as_u16() == 401 {
|
||||
// Worded so `api_call_with_retry` recognises it and refreshes once.
|
||||
return Err(format!(
|
||||
"MCP credential request failed (401 Unauthorized): {body}"
|
||||
));
|
||||
}
|
||||
if !status.is_success() {
|
||||
return Ok(McpKeyAnswer::Refused {
|
||||
status: status.as_u16(),
|
||||
body,
|
||||
});
|
||||
}
|
||||
serde_json::from_str::<McpKeyGrant>(&body)
|
||||
.map(McpKeyAnswer::Granted)
|
||||
.map_err(|e| format!("Failed to parse the MCP credential response: {e}"))
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|e| crate::backend_error_with_detail("MCP_REMOTE_KEY_UNAVAILABLE", e))?;
|
||||
|
||||
match answer {
|
||||
McpKeyAnswer::Granted(grant) if grant.key.starts_with(MCP_KEY_PREFIX) => Ok(grant),
|
||||
// A credential of any other kind must never be stored as if it were ours,
|
||||
// however well it would authenticate.
|
||||
McpKeyAnswer::Granted(_) => Err(crate::backend_error_with_detail(
|
||||
"MCP_REMOTE_KEY_UNAVAILABLE",
|
||||
"the server issued a credential of an unexpected shape",
|
||||
)),
|
||||
McpKeyAnswer::Refused { status, body } => Err(mcp_key_refusal(status, &body)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Revoke a remote MCP credential by id. A key the server no longer knows
|
||||
/// (404) counts as revoked: that is the state the caller wanted.
|
||||
pub async fn revoke_mcp_key(&self, key_id: &str) -> Result<(), String> {
|
||||
let answer = self
|
||||
.api_call_with_retry(|access_token| {
|
||||
let url = format!(
|
||||
"{CLOUD_API_URL}/api/mcp/keys/{}",
|
||||
urlencoding::encode(key_id)
|
||||
);
|
||||
let client = self.client.clone();
|
||||
async move {
|
||||
let response = client
|
||||
.delete(&url)
|
||||
.header("Authorization", format!("Bearer {access_token}"))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to revoke the MCP credential: {e}"))?;
|
||||
let status = response.status();
|
||||
if status.as_u16() == 401 {
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
return Err(format!(
|
||||
"MCP credential revocation failed (401 Unauthorized): {body}"
|
||||
));
|
||||
}
|
||||
if status.is_success() || status.as_u16() == 404 {
|
||||
return Ok(McpKeyAnswer::Granted(()));
|
||||
}
|
||||
Ok(McpKeyAnswer::Refused {
|
||||
status: status.as_u16(),
|
||||
body: response.text().await.unwrap_or_default(),
|
||||
})
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|e| crate::backend_error_with_detail("MCP_REMOTE_KEY_UNAVAILABLE", e))?;
|
||||
|
||||
match answer {
|
||||
McpKeyAnswer::Granted(()) => Ok(()),
|
||||
McpKeyAnswer::Refused { status, body } => Err(crate::backend_error_with_detail(
|
||||
"MCP_REMOTE_KEY_UNAVAILABLE",
|
||||
format!("{status}: {body}"),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Retire the stored remote MCP credential on sign-out.
|
||||
///
|
||||
/// Best effort, and the local copy goes regardless. The revoke needs the
|
||||
/// session that is about to be deleted, so this runs before `clear_auth`;
|
||||
/// if it fails the key stays live server-side and the account page can
|
||||
/// revoke it, but a signed-out desktop must not keep a credential that
|
||||
/// belongs to the account that just left it.
|
||||
async fn retire_mcp_key_on_logout(&self) {
|
||||
let settings = SettingsManager::instance();
|
||||
// Stringified at once: the settings error is a `Box<dyn Error>`, which is
|
||||
// not `Send`, and the command future this runs in has to be.
|
||||
let stored = settings.get_mcp_remote_key().map_err(|e| e.to_string());
|
||||
match stored {
|
||||
Ok(Some(StoredMcpRemoteKey { id: Some(id), .. })) => {
|
||||
if let Err(e) = self.revoke_mcp_key(&id).await {
|
||||
log::warn!(
|
||||
"Could not revoke the remote MCP credential on logout; revoke it from the account page: {e}"
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(Some(StoredMcpRemoteKey { id: None, .. })) => {
|
||||
log::warn!(
|
||||
"The remote MCP credential has no stored id, so it cannot be revoked from here; revoke it from the account page"
|
||||
);
|
||||
}
|
||||
Ok(None) => return,
|
||||
Err(e) => {
|
||||
log::warn!("Could not read the remote MCP credential on logout: {e}");
|
||||
}
|
||||
}
|
||||
Self::forget_mcp_key_locally("on logout");
|
||||
}
|
||||
|
||||
/// Drop the local copy of the remote MCP credential: the storage half of
|
||||
/// `retire_mcp_key_on_logout`, shared with the expiry path, which has no
|
||||
/// session left to revoke with. Logs rather than fails, because the
|
||||
/// credential is leaving with the session either way.
|
||||
fn forget_mcp_key_locally(when: &str) {
|
||||
if let Err(e) = SettingsManager::instance().remove_mcp_remote_key() {
|
||||
log::warn!("Could not forget the remote MCP credential {when}: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn fetch_profile(&self) -> Result<CloudUser, String> {
|
||||
let user = self
|
||||
.api_call_with_retry(|access_token| {
|
||||
@@ -765,6 +1033,16 @@ impl CloudAuthManager {
|
||||
// Disconnect profile lock manager
|
||||
crate::team_lock::PROFILE_LOCK.disconnect().await;
|
||||
|
||||
// Hang up the remote-control bridge before the credential it authenticated
|
||||
// with is deleted. Left running it would reconnect, fail, and back off into
|
||||
// an "unauthorized" the account page shows to somebody who has simply
|
||||
// signed out, and it would hold the account's bridge slot meanwhile.
|
||||
crate::mcp_remote::stop(None);
|
||||
|
||||
// Before the session is closed server-side and the tokens are deleted:
|
||||
// both of those take away the only thing that can revoke it.
|
||||
self.retire_mcp_key_on_logout().await;
|
||||
|
||||
// Try to call the logout API (best-effort)
|
||||
if let Ok(Some(access_token)) = Self::load_access_token() {
|
||||
let refresh_token = Self::load_refresh_token().ok().flatten();
|
||||
@@ -1293,11 +1571,20 @@ impl CloudAuthManager {
|
||||
|
||||
// Reconnect profile lock manager if needed
|
||||
if let Some(auth_state) = CLOUD_AUTH.get_user().await {
|
||||
if auth_state.user.plan != "free" && !crate::team_lock::PROFILE_LOCK.is_connected().await {
|
||||
if auth_state.user.effective_plan() != "free"
|
||||
&& !crate::team_lock::PROFILE_LOCK.is_connected().await
|
||||
{
|
||||
crate::team_lock::PROFILE_LOCK.connect().await;
|
||||
}
|
||||
}
|
||||
|
||||
// And the remote-control bridge, for the same reason one tick below it
|
||||
// reconnects the profile lock: a setting that says "on" and a bridge that
|
||||
// is not running is a disagreement only something periodic can notice.
|
||||
// `mcp_remote::start` is idempotent, so a healthy bridge costs a load of
|
||||
// the settings file every ten minutes and nothing else.
|
||||
ensure_remote_bridge(&app_handle).await;
|
||||
|
||||
// Sync cloud proxy credentials
|
||||
CLOUD_AUTH.sync_cloud_proxy().await;
|
||||
|
||||
@@ -1323,11 +1610,10 @@ impl CloudAuthManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a rejected wayfern-token request was refused by one of the
|
||||
/// device-family rules (automation is pinned to the primary desktop session)
|
||||
/// rather than by the plan's capabilities.
|
||||
/// Whether a rejected wayfern-token request was refused by one of the device
|
||||
/// rules rather than by the plan's capabilities.
|
||||
///
|
||||
/// Matches on the backend's message because that is the only thing that
|
||||
/// Matches on the server's message because that is the only thing that
|
||||
/// distinguishes them: both arrive as a bare 403. Only these two are a state
|
||||
/// the user can clear themselves, which is what the toast asks them to do.
|
||||
fn is_device_restriction(error: &str) -> bool {
|
||||
@@ -1380,10 +1666,22 @@ pub async fn cloud_exchange_device_code(
|
||||
) -> Result<CloudAuthState, String> {
|
||||
let mut state = CLOUD_AUTH.exchange_device_code(&code).await?;
|
||||
|
||||
// The login response carries the row's own plan and entitlements only: no
|
||||
// team membership and no `effectivePlan`. For an invited member that reads
|
||||
// as a free account, and it stayed that way until the ten-minute loop got
|
||||
// round to `/api/auth/me`. Resolve the served plan here so the sync token,
|
||||
// the wayfern token and the profile lock below all see the seat. Best
|
||||
// effort: a failure leaves the login response in place.
|
||||
match CLOUD_AUTH.fetch_profile().await {
|
||||
Ok(user) => state.user = user,
|
||||
Err(e) => log::warn!("Post-login profile refresh failed: {e}"),
|
||||
}
|
||||
|
||||
let has_subscription = CLOUD_AUTH.has_active_paid_subscription().await;
|
||||
log::info!(
|
||||
"Post-login: plan={}, has_active_subscription={}",
|
||||
"Post-login: plan={}, effective_plan={}, has_active_subscription={}",
|
||||
state.user.plan,
|
||||
state.user.effective_plan(),
|
||||
has_subscription
|
||||
);
|
||||
|
||||
@@ -1406,10 +1704,19 @@ pub async fn cloud_exchange_device_code(
|
||||
CLOUD_AUTH.sync_cloud_proxy().await;
|
||||
|
||||
// Connect profile lock manager for paid users
|
||||
if state.user.plan != "free" {
|
||||
if state.user.effective_plan() != "free" {
|
||||
crate::team_lock::PROFILE_LOCK.connect().await;
|
||||
}
|
||||
|
||||
// Reopen the remote-control bridge the user had switched on.
|
||||
//
|
||||
// Signing out stops the bridge but deliberately does NOT clear the setting:
|
||||
// it is a preference, not a session. So without this, "sign out, sign back
|
||||
// in" left remote control switched on in Settings and dead in fact until the
|
||||
// app was restarted, which is the worst of both: the UI says yes and the
|
||||
// account page says no desktop is connected.
|
||||
ensure_remote_bridge(&app_handle).await;
|
||||
|
||||
let _ = crate::events::emit_empty("cloud-auth-changed");
|
||||
|
||||
let _ = &app_handle;
|
||||
@@ -1417,6 +1724,42 @@ pub async fn cloud_exchange_device_code(
|
||||
Ok(state)
|
||||
}
|
||||
|
||||
/// Open the remote-control bridge if the user asked for it and is signed in.
|
||||
///
|
||||
/// Idempotent, and safe to call from anywhere: `mcp_remote::start` returns
|
||||
/// immediately when the bridge is already up.
|
||||
///
|
||||
/// The signed-in half matters as much as the setting. A bridge opened without a
|
||||
/// credential cannot authenticate, so it would spend its life in the terminal
|
||||
/// backoff band reporting "not signed in" on the account page: an error
|
||||
/// describing nothing the user did.
|
||||
pub(crate) async fn ensure_remote_bridge(app_handle: &tauri::AppHandle) {
|
||||
if crate::mcp_remote::is_running() {
|
||||
return;
|
||||
}
|
||||
// The same two gates as `start_mcp_remote_bridge`. This helper runs at
|
||||
// boot, on sign-in and on the reconnect tick, and it used to check only the
|
||||
// sign-in half: a desktop whose terms acceptance had been withdrawn (or
|
||||
// never given, with the flag seeded on disk) opened an internet-facing
|
||||
// bridge into a browser the user had not agreed to automate.
|
||||
if !crate::wayfern_terms::WayfernTermsManager::instance().is_terms_accepted() {
|
||||
return;
|
||||
}
|
||||
if !CLOUD_AUTH.is_logged_in().await {
|
||||
return;
|
||||
}
|
||||
let enabled = crate::settings_manager::SettingsManager::instance()
|
||||
.load_settings()
|
||||
.map(|settings| settings.mcp_remote_enabled)
|
||||
.unwrap_or(false);
|
||||
if enabled {
|
||||
log::info!(
|
||||
"[mcp-remote] Remote control is enabled and the account is signed in; opening the bridge"
|
||||
);
|
||||
crate::mcp_remote::start(app_handle.clone());
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn cloud_get_user() -> Result<Option<CloudAuthState>, String> {
|
||||
Ok(CLOUD_AUTH.get_user().await.map(|mut state| {
|
||||
@@ -1452,6 +1795,13 @@ pub async fn cloud_refresh_profile() -> Result<CloudUser, String> {
|
||||
pub async fn cloud_logout(app_handle: tauri::AppHandle) -> Result<(), String> {
|
||||
CLOUD_AUTH.logout().await?;
|
||||
|
||||
// Stop the remote session-events stream. Its credential is now invalid, so
|
||||
// the SSE connection would fail its next credential re-check anyway — but
|
||||
// until it did, its reconnect loop keeps retrying every 1..60s against a
|
||||
// signed-out account. The frontend store stops its own subscription on
|
||||
// logout; nothing stopped the Rust stream, so a sign-out left it churning.
|
||||
crate::remote_session::stop_session_events();
|
||||
|
||||
// Always clear the stored sync URL and token on cloud logout. While the
|
||||
// user was signed in, the cloud auth flow populated these with the hosted
|
||||
// sync server's URL + a server-issued token — leaving them in place would
|
||||
@@ -1522,10 +1872,32 @@ struct ProxyUsageResponse {
|
||||
limit_mb: i64,
|
||||
#[serde(rename = "remainingMb")]
|
||||
remaining_mb: i64,
|
||||
#[serde(rename = "recurringLimitMb", default)]
|
||||
recurring_limit_mb: i64,
|
||||
#[serde(rename = "extraLimitMb", default)]
|
||||
extra_limit_mb: i64,
|
||||
// Optional rather than defaulted to 0 so an omitted half of the split stays
|
||||
// distinguishable from a backend that genuinely reports zero for it.
|
||||
#[serde(rename = "recurringLimitMb")]
|
||||
recurring_limit_mb: Option<i64>,
|
||||
#[serde(rename = "extraLimitMb")]
|
||||
extra_limit_mb: Option<i64>,
|
||||
}
|
||||
|
||||
/// Combine the live usage response with the cached account snapshot, each half
|
||||
/// of the recurring/extra split falling back on its own.
|
||||
///
|
||||
/// Gating both halves on `recurringLimitMb` made an omitted `extraLimitMb`
|
||||
/// report 0 against a total that included it, and threw away a live
|
||||
/// `extraLimitMb` whenever the recurring half happened to be 0.
|
||||
fn merge_proxy_usage(
|
||||
usage: &ProxyUsageResponse,
|
||||
cached_recurring: i64,
|
||||
cached_extra: i64,
|
||||
) -> CloudProxyUsage {
|
||||
CloudProxyUsage {
|
||||
used_mb: usage.used_mb,
|
||||
limit_mb: usage.limit_mb,
|
||||
remaining_mb: usage.remaining_mb,
|
||||
recurring_limit_mb: usage.recurring_limit_mb.unwrap_or(cached_recurring),
|
||||
extra_limit_mb: usage.extra_limit_mb.unwrap_or(cached_extra),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -1578,21 +1950,11 @@ pub async fn cloud_get_proxy_usage() -> Result<Option<CloudProxyUsage>, String>
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(usage) => Ok(Some(CloudProxyUsage {
|
||||
used_mb: usage.used_mb,
|
||||
limit_mb: usage.limit_mb,
|
||||
remaining_mb: usage.remaining_mb,
|
||||
recurring_limit_mb: if usage.recurring_limit_mb > 0 {
|
||||
usage.recurring_limit_mb
|
||||
} else {
|
||||
cached_recurring
|
||||
},
|
||||
extra_limit_mb: if usage.recurring_limit_mb > 0 {
|
||||
usage.extra_limit_mb
|
||||
} else {
|
||||
cached_extra
|
||||
},
|
||||
})),
|
||||
Ok(usage) => Ok(Some(merge_proxy_usage(
|
||||
&usage,
|
||||
cached_recurring,
|
||||
cached_extra,
|
||||
))),
|
||||
Err(e) => {
|
||||
log::warn!("Failed to fetch live proxy usage, falling back to cached: {e}");
|
||||
// Fallback to cached values
|
||||
@@ -1651,6 +2013,32 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_agent_follows_browser_automation_and_is_never_derived_from_it() {
|
||||
// Solo funds a nightly bot and nothing that drives a browser by hand, so
|
||||
// it does not get the agent either.
|
||||
assert!(!active_solo().agent_automation);
|
||||
for plan in ["pro", "team", "enterprise", "some-comped-plan"] {
|
||||
let derived = derive_entitlements(plan, Some("monthly"), "active", 50);
|
||||
assert!(derived.agent_automation, "{plan} should get the agent");
|
||||
}
|
||||
// An inactive subscription buys nothing, whatever the plan says.
|
||||
assert!(!derive_entitlements("pro", Some("monthly"), "canceled", 50).agent_automation);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_backend_that_never_heard_of_the_agent_reports_no_agent() {
|
||||
// The whole point of `default` here: an older backend's entitlements object
|
||||
// must decode, and the missing key must read as "no agent routes exist"
|
||||
// rather than being back-filled from browser automation.
|
||||
let older: Entitlements = serde_json::from_str(
|
||||
r#"{"active":true,"browserAutomation":true,"cloudBackup":true,"profileLimit":50}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(older.active && older.browser_automation);
|
||||
assert!(!older.agent_automation);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wayfern_token_is_gated_on_automation_not_on_being_paid() {
|
||||
// The regression this guards: gating the mint on `active` asked for a token
|
||||
@@ -1662,6 +2050,109 @@ mod tests {
|
||||
assert!(pro.active && pro.browser_automation);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_refused_credential_mint_maps_to_the_codes_the_ui_knows() {
|
||||
let limit: serde_json::Value = serde_json::from_str(&mcp_key_refusal(
|
||||
409,
|
||||
r#"{"message":"Too many MCP keys","code":"MCP_KEY_LIMIT","statusCode":409}"#,
|
||||
))
|
||||
.unwrap();
|
||||
assert_eq!(limit["code"], "MCP_REMOTE_KEY_LIMIT");
|
||||
|
||||
// The cap is the only thing a 409 on that route means, named or not.
|
||||
let unnamed: serde_json::Value = serde_json::from_str(&mcp_key_refusal(409, "")).unwrap();
|
||||
assert_eq!(unnamed["code"], "MCP_REMOTE_KEY_LIMIT");
|
||||
|
||||
let other: serde_json::Value = serde_json::from_str(&mcp_key_refusal(
|
||||
429,
|
||||
r#"{"message":"Too many requests","statusCode":429}"#,
|
||||
))
|
||||
.unwrap();
|
||||
assert_eq!(other["code"], "MCP_REMOTE_KEY_UNAVAILABLE");
|
||||
assert_eq!(other["params"]["detail"], "429: Too many requests");
|
||||
|
||||
// A body that is not JSON still reaches the log verbatim.
|
||||
let plain: serde_json::Value =
|
||||
serde_json::from_str(&mcp_key_refusal(502, "bad gateway")).unwrap();
|
||||
assert_eq!(plain["params"]["detail"], "502: bad gateway");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_boot_and_sign_in_paths_require_the_terms_like_the_command_does() {
|
||||
// `ensure_remote_bridge` is reached from boot, sign-in and the reconnect
|
||||
// tick, none of which pass through `start_mcp_remote_bridge`, so the
|
||||
// command's terms gate protects only the toggle. The helper must carry
|
||||
// the same gate itself.
|
||||
let source = include_str!("cloud_auth.rs");
|
||||
let helper = source
|
||||
.split("pub(crate) async fn ensure_remote_bridge(")
|
||||
.nth(1)
|
||||
.expect("ensure_remote_bridge must exist");
|
||||
let body = &helper[..helper.find("\n}").unwrap_or(helper.len())];
|
||||
let terms = body
|
||||
.find("is_terms_accepted()")
|
||||
.expect("ensure_remote_bridge must check the Wayfern terms");
|
||||
let start = body
|
||||
.find("crate::mcp_remote::start(")
|
||||
.expect("ensure_remote_bridge must be what starts the bridge");
|
||||
assert!(terms < start, "the terms gate must sit ahead of the start");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn logout_retires_the_remote_credential_before_the_session_is_gone() {
|
||||
// The revoke needs the access token; `clear_auth` deletes it and the
|
||||
// `/api/auth/logout` call may invalidate it server-side. Both must come
|
||||
// after.
|
||||
let source = include_str!("cloud_auth.rs");
|
||||
let logout = source
|
||||
.split("pub async fn logout(&self)")
|
||||
.nth(1)
|
||||
.expect("logout must exist");
|
||||
let body = &logout[..logout.find("\n }").unwrap_or(logout.len())];
|
||||
let retire = body
|
||||
.find("retire_mcp_key_on_logout()")
|
||||
.expect("logout must retire the remote MCP credential");
|
||||
let api_logout = body
|
||||
.find("/api/auth/logout")
|
||||
.expect("logout must still call the logout endpoint");
|
||||
let clear = body
|
||||
.find("clear_auth()")
|
||||
.expect("logout must still clear the session");
|
||||
assert!(
|
||||
retire < api_logout,
|
||||
"revoke before the server closes the session"
|
||||
);
|
||||
assert!(retire < clear, "revoke before the tokens are deleted");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_expiry_forgets_the_remote_credential_without_a_network_revoke() {
|
||||
// The automatic twin of logout: the refresh loop gave up, so the session
|
||||
// is already dead and there is nothing to revoke with. The local copy
|
||||
// still has to go, or the agents keep presenting a key that belongs to an
|
||||
// account this machine is no longer signed in to.
|
||||
let source = include_str!("cloud_auth.rs");
|
||||
let expiry = source
|
||||
.split("pub async fn invalidate_session(&self)")
|
||||
.nth(1)
|
||||
.expect("invalidate_session must exist");
|
||||
let body = &expiry[..expiry.find("\n }").unwrap_or(expiry.len())];
|
||||
let forget = body
|
||||
.find("forget_mcp_key_locally(")
|
||||
.expect("invalidate_session must forget the remote MCP credential");
|
||||
let clear = body
|
||||
.find("clear_auth()")
|
||||
.expect("invalidate_session must still clear the session");
|
||||
assert!(
|
||||
forget < clear,
|
||||
"forget the credential before the auth state is torn down"
|
||||
);
|
||||
assert!(
|
||||
!body.contains("revoke_mcp_key("),
|
||||
"a dead session has nothing to revoke with; the call could only fail"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_the_device_rules_read_as_a_restriction() {
|
||||
assert!(is_device_restriction(
|
||||
@@ -1679,4 +2170,45 @@ mod tests {
|
||||
"Wayfern token request failed (500 Internal Server Error): "
|
||||
));
|
||||
}
|
||||
|
||||
fn usage_response(recurring: Option<i64>, extra: Option<i64>) -> ProxyUsageResponse {
|
||||
ProxyUsageResponse {
|
||||
used_mb: 40,
|
||||
limit_mb: 600,
|
||||
remaining_mb: 560,
|
||||
recurring_limit_mb: recurring,
|
||||
extra_limit_mb: extra,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_half_of_the_proxy_limit_falls_back_on_its_own() {
|
||||
let both = merge_proxy_usage(&usage_response(Some(500), Some(100)), 400, 0);
|
||||
assert_eq!(both.used_mb, 40);
|
||||
assert_eq!(both.limit_mb, 600);
|
||||
assert_eq!(both.remaining_mb, 560);
|
||||
assert_eq!(both.recurring_limit_mb, 500);
|
||||
assert_eq!(both.extra_limit_mb, 100);
|
||||
|
||||
// A backend that does not report the split at all keeps the cached one.
|
||||
let neither = merge_proxy_usage(&usage_response(None, None), 500, 100);
|
||||
assert_eq!(neither.recurring_limit_mb, 500);
|
||||
assert_eq!(neither.extra_limit_mb, 100);
|
||||
|
||||
// An omitted extra half must not be read off the recurring half, which is
|
||||
// how a cached 100 MB top-up used to vanish from the split.
|
||||
let recurring_only = merge_proxy_usage(&usage_response(Some(500), None), 400, 100);
|
||||
assert_eq!(recurring_only.recurring_limit_mb, 500);
|
||||
assert_eq!(recurring_only.extra_limit_mb, 100);
|
||||
|
||||
// A fresh extra allowance survives a recurring half of zero.
|
||||
let extra_only = merge_proxy_usage(&usage_response(Some(0), Some(250)), 500, 100);
|
||||
assert_eq!(extra_only.recurring_limit_mb, 0);
|
||||
assert_eq!(extra_only.extra_limit_mb, 250);
|
||||
|
||||
// And a spent top-up reported as a live zero is not resurrected from cache.
|
||||
let spent_extra = merge_proxy_usage(&usage_response(Some(500), Some(0)), 500, 100);
|
||||
assert_eq!(spent_extra.recurring_limit_mb, 500);
|
||||
assert_eq!(spent_extra.extra_limit_mb, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Turning a donutbrowser-infra HTTP failure into a stable, translatable code.
|
||||
//! Turning a cloud API HTTP failure into a stable, translatable code.
|
||||
//!
|
||||
//! Every cloud transport in this crate flattens its failures through
|
||||
//! `api_call_with_retry`, which needs a `String` so it can sniff for a 401.
|
||||
@@ -55,7 +55,7 @@ pub struct FailureCodes {
|
||||
|
||||
/// The desktop has no cloud session at all.
|
||||
pub const NOT_SIGNED_IN: &str = "CLOUD_NOT_SIGNED_IN";
|
||||
/// The request never reached donutbrowser-infra.
|
||||
/// The request never reached the cloud API.
|
||||
pub const UNREACHABLE: &str = "CLOUD_UNREACHABLE";
|
||||
/// The backend answered, but with nothing the user can act on.
|
||||
pub const UNAVAILABLE: &str = "CLOUD_REQUEST_FAILED";
|
||||
@@ -243,8 +243,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn the_backends_own_code_wins_over_the_status_default() {
|
||||
// The status table is a fallback for gateway pages. When infra names the
|
||||
// failure, that name is the one the user's locale has a string for.
|
||||
// The status table is a fallback for gateway pages. When the server names
|
||||
// the failure, that name is the one the user's locale has a string for.
|
||||
let failure = classify(403, r#"{"code":"COOKIE_BOT_NOT_ENTITLED"}"#, CODES);
|
||||
assert_eq!(failure.code, "COOKIE_BOT_NOT_ENTITLED");
|
||||
assert_eq!(failure.status, 403);
|
||||
@@ -261,8 +261,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn capacity_and_rate_limits_are_never_reported_as_a_fault() {
|
||||
// 503 is "come back in a minute" — the fleet is four Windows hosts wide,
|
||||
// so a busy fleet is normal and must not look like an outage.
|
||||
// 503 is "come back in a minute" — remote capacity is finite, so a busy
|
||||
// period is normal and must not look like an outage.
|
||||
assert_eq!(classify(503, "", CODES).code, NO_CAPACITY);
|
||||
assert_eq!(classify(429, "", CODES).code, RATE_LIMITED);
|
||||
}
|
||||
@@ -298,10 +298,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn nested_params_are_read_because_that_is_the_shape_cookie_bot_sends() {
|
||||
// `body(code, params)` in cookie-bot.errors.ts returns `{code, params}`,
|
||||
// which Nest serialises verbatim. Reading only the top level dropped every
|
||||
// interpolated value: the timezone the user typed, the site limit, the
|
||||
// hours a team had actually spent.
|
||||
// The cookie-bot routes send every interpolated value nested under
|
||||
// `params`. Reading only the top level dropped every one of them: the
|
||||
// timezone the user typed, the site limit, the hours a team had actually
|
||||
// spent.
|
||||
let failure = classify(
|
||||
400,
|
||||
r#"{"code":"COOKIE_BOT_INVALID_TIMEZONE","params":{"timezone":"Europe/Nowhere"}}"#,
|
||||
|
||||
+120
-74
@@ -1,10 +1,8 @@
|
||||
//! Cookie-bot transport.
|
||||
//!
|
||||
//! The bot warms a profile's cookies overnight by driving it on a leased
|
||||
//! remote host. NONE of that lives here: the schedule, the calendar maths, the
|
||||
//! preset expansion, the site ordering, the dwell and scroll model, the pooled
|
||||
//! budget and the nightly dispatcher are all held by donutbrowser-infra and
|
||||
//! the Wayfern manager.
|
||||
//! The bot warms a profile's cookies overnight by driving it on a remote host.
|
||||
//! None of that behaviour lives here: the schedule and everything the bot
|
||||
//! actually does are owned by the cloud API.
|
||||
//!
|
||||
//! This module is the wire only. It sends the user's own scalars — when to
|
||||
//! run, for how long, which of their sites, which server-issued preset id —
|
||||
@@ -19,10 +17,10 @@ use serde::{Deserialize, Serialize};
|
||||
use std::sync::OnceLock;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Operating systems the fleet can lease. Linux is refused by the manager, so
|
||||
/// refusing it here turns a nightly failure at 02:00 into a refusal at the
|
||||
/// moment the user picks the profile.
|
||||
pub const BOT_PLATFORMS: [&str; 2] = ["windows", "macos"];
|
||||
/// Operating systems a remote run can be scheduled on. Anything else (a mobile
|
||||
/// OS, a typo) has no host, so it is refused here rather than as a failed run
|
||||
/// at 02:00.
|
||||
pub const BOT_PLATFORMS: [&str; 3] = ["windows", "macos", "linux"];
|
||||
|
||||
const REQUEST_TIMEOUT: Duration = Duration::from_secs(20);
|
||||
const CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
@@ -139,7 +137,7 @@ pub struct CookieBotSchedule {
|
||||
#[serde(default)]
|
||||
pub slots: Vec<CookieBotSlot>,
|
||||
pub timezone: String,
|
||||
/// Server-issued preset id. Opaque here — what it expands to is infra's.
|
||||
/// Server-issued preset id. Opaque here — what it expands to is the server's.
|
||||
pub preset: String,
|
||||
/// The template the sites came from, or `None` for the user's own list.
|
||||
///
|
||||
@@ -164,9 +162,9 @@ pub struct CookieBotSchedule {
|
||||
pub encrypted_sync: bool,
|
||||
#[serde(default)]
|
||||
pub has_proxy: bool,
|
||||
/// Whether that exit is one a leased fleet host could dial. Defaults to false
|
||||
/// on an older server that does not send it, which reads as "not reachable"
|
||||
/// and is the safe direction.
|
||||
/// Whether that exit is one a remote host could dial. Defaults to false on an
|
||||
/// older server that does not send it, which reads as "not reachable" and is
|
||||
/// the safe direction.
|
||||
#[serde(default)]
|
||||
pub proxy_remote_reachable: bool,
|
||||
#[serde(default)]
|
||||
@@ -252,7 +250,7 @@ pub struct CookieBotScheduleInput {
|
||||
// Defaulting is safe in exactly one direction: `bool::default()` is false, so
|
||||
// an unstamped input reads as "no sync, no proxy" and is REFUSED. The failure
|
||||
// this must never have is the opposite one, a defaulted `has_proxy: true`
|
||||
// warming a profile out of the fleet's own datacenter address.
|
||||
// warming a profile out of the remote host's own address.
|
||||
#[serde(default)]
|
||||
pub sync_enabled: bool,
|
||||
#[serde(default)]
|
||||
@@ -344,8 +342,8 @@ pub struct CookieBotRun {
|
||||
#[serde(default)]
|
||||
pub max_minutes: u32,
|
||||
/// How many browser sessions this night is split into, and which one is
|
||||
/// running. A night longer than one session's cap is checkpointed at each
|
||||
/// boundary, and "chunk 2 of 3" is the only honest way to report that.
|
||||
/// running. "chunk 2 of 3" is the only honest way to report a night the
|
||||
/// server split.
|
||||
#[serde(default)]
|
||||
pub chunks_total: u32,
|
||||
#[serde(default)]
|
||||
@@ -403,10 +401,8 @@ pub struct CookieBotPreset {
|
||||
/// A server-owned browsing template: a named answer to "what is this profile
|
||||
/// for", which the user picks INSTEAD of typing a site list.
|
||||
///
|
||||
/// Carries no URLs, and must not gain any. The pool a template draws from is
|
||||
/// server-side for the same reason a preset's browsing model is: a published
|
||||
/// list is one a retailer can filter, and each profile is given its own sample
|
||||
/// so the template never becomes a fleet-wide fingerprint.
|
||||
/// Carries no URLs, and must not gain any: the site pool a template draws from
|
||||
/// is server-owned, and a published list is one a retailer can filter.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
pub struct CookieBotTemplate {
|
||||
pub id: String,
|
||||
@@ -506,8 +502,8 @@ pub struct RemoteHoursMember {
|
||||
pub bot_hours: f64,
|
||||
}
|
||||
|
||||
/// The single pooled remote-hour budget. Bot and interactive hours share it;
|
||||
/// the breakdown is reporting, never a sub-cap.
|
||||
/// The remote-hour budget as the server reports it, with the bot/interactive
|
||||
/// breakdown it sends.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
pub struct RemoteHoursQuota {
|
||||
pub granted_hours: f64,
|
||||
@@ -606,15 +602,15 @@ pub struct CookieBotUsage {
|
||||
///
|
||||
/// The server is authoritative — it re-checks all of this and owns the parts
|
||||
/// the client cannot see — but a profile that can never qualify should never
|
||||
/// reach a confirm dialog, an hour of quota or a leased host. Returns the
|
||||
/// reach a confirm dialog, an hour of quota or a remote host. Returns the
|
||||
/// `{"code":…}` string a Tauri command surfaces directly.
|
||||
pub fn bot_precondition(
|
||||
profile: &BrowserProfile,
|
||||
exit: &crate::remote_exit::ExitReachability,
|
||||
) -> Result<(), String> {
|
||||
if !profile.is_sync_enabled() {
|
||||
// The host materialises the profile by pulling it from donut-sync. A
|
||||
// local-only profile has nothing there, so there is no path to a run.
|
||||
// A remote run obtains the profile through sync, so a local-only profile
|
||||
// has nothing there and there is no path to a run.
|
||||
return Err(error("COOKIE_BOT_REQUIRES_CLOUD_SYNC", &[]));
|
||||
}
|
||||
if profile.is_encrypted_sync() {
|
||||
@@ -632,16 +628,16 @@ pub fn bot_precondition(
|
||||
));
|
||||
}
|
||||
if profile.proxy_id.is_none() && profile.vpn_id.is_none() {
|
||||
// Without one the run egresses from the fleet's own datacenter address.
|
||||
// Hours of traffic from a hosting ASN is worse for the profile's identity
|
||||
// than not warming it at all.
|
||||
// Without one the run egresses from the remote host's own address instead
|
||||
// of the user's exit, which is worse for the profile's identity than not
|
||||
// warming it at all.
|
||||
return Err(error("COOKIE_BOT_REQUIRES_EXIT_NODE", &[]));
|
||||
}
|
||||
// ...and the exit has to be one the leased host can reach. The profile and its
|
||||
// proxy record are pulled onto the fleet with no address rewriting, so
|
||||
// 127.0.0.1 arrives meaning THAT host's loopback — an ordinary mistake (an SSH
|
||||
// tunnel, a local MITM proxy, a locally-run SOCKS client), and by the time the
|
||||
// run fails an hour has been leased and billed.
|
||||
// ...and the exit has to be one a remote host can reach. Addresses are not
|
||||
// rewritten in transit, so a proxy recorded as 127.0.0.1 arrives meaning THAT
|
||||
// machine's own loopback — an ordinary mistake (an SSH tunnel, a local MITM
|
||||
// proxy, a locally-run SOCKS client) that costs the user an hour of quota
|
||||
// before it fails.
|
||||
//
|
||||
// Taken as an ARGUMENT rather than resolved here, for the same reason
|
||||
// `ProfileState` is required rather than defaulted: resolving it needs the
|
||||
@@ -649,6 +645,18 @@ pub fn bot_precondition(
|
||||
// no test can set up and every caller silently depends on. `exit_reachability`
|
||||
// is the one place that resolution happens; this stays a pure predicate over
|
||||
// facts it is handed.
|
||||
// A protocol a remote host cannot speak is a PERMANENT refusal, and it has to
|
||||
// say so in its own words. A VLESS server is publicly routable, so the
|
||||
// reachability question answers "yes" and the older message ("use a proxy with
|
||||
// a public address") sends the user to fix an address that was never wrong; an
|
||||
// enrolment accepted on that answer then fails remotely, once per scheduled
|
||||
// run, until someone notices.
|
||||
if let crate::remote_exit::ExitReachability::UnsupportedKind { kind, .. } = exit {
|
||||
return Err(error(
|
||||
"COOKIE_BOT_PROXY_KIND_UNSUPPORTED",
|
||||
&[("kind", kind.as_str())],
|
||||
));
|
||||
}
|
||||
if !exit.is_remote() {
|
||||
return Err(error("COOKIE_BOT_REQUIRES_REMOTE_EXIT_NODE", &[]));
|
||||
}
|
||||
@@ -660,8 +668,8 @@ pub fn bot_precondition(
|
||||
/// The server holds the schedule; the PROFILE lives in the user's sync
|
||||
/// namespace, so `sync_enabled`, `has_proxy` and the rest are only knowable
|
||||
/// here. It requires them on every write rather than defaulting them, because
|
||||
/// a defaulted `has_proxy` is a profile warmed out of the fleet's own
|
||||
/// datacenter address.
|
||||
/// a defaulted `has_proxy` is a profile warmed out of the remote host's own
|
||||
/// address.
|
||||
///
|
||||
/// Derived in one place so the Tauri, REST and MCP call sites cannot drift into
|
||||
/// three different answers about the same profile.
|
||||
@@ -674,21 +682,20 @@ pub fn profile_state(profile: &BrowserProfile) -> ProfileState {
|
||||
has_proxy: profile.proxy_id.is_some() || profile.vpn_id.is_some(),
|
||||
// ...and, separately, whether anyone OTHER than this machine could use it.
|
||||
// `has_proxy` answers "did the user bring an exit"; this answers "is that
|
||||
// exit an address a leased host can dial". They disagree for every local
|
||||
// proxy, which is the case that used to be accepted and then fail on the
|
||||
// fleet. See `remote_exit`.
|
||||
// exit an address a remote host can dial". They disagree for every local
|
||||
// proxy, which is the case that used to be accepted and then fail remotely.
|
||||
// See `remote_exit`.
|
||||
proxy_remote_reachable: exit_reachability(profile).is_remote(),
|
||||
// Always false: this data model has no mobile/touch profile. `resolved_os`
|
||||
// yields only windows, macos or linux, and `bot_precondition` already
|
||||
// refuses everything but the first two. Reported rather than omitted so the
|
||||
// server keeps one required shape, and it stays authoritative — it sees the
|
||||
// real fingerprint on the host and can still refuse a run this cannot know
|
||||
// to reject.
|
||||
// yields only windows, macos or linux, and all three are supported
|
||||
// remotely. Reported rather than omitted so the server keeps one
|
||||
// required shape, and it stays authoritative: it sees the real fingerprint
|
||||
// on the host and can still refuse a run this cannot know to reject.
|
||||
touch_fingerprint: false,
|
||||
// A VPN is one persistent tunnel, so the night's chunks share an exit. A
|
||||
// stored proxy may rotate per connection, and claiming stickiness we cannot
|
||||
// guarantee is worse than declining it: the server's fallback is to run the
|
||||
// night as a single chunk, which is the safe answer either way.
|
||||
// guarantee is worse than declining it, so the conservative answer is the
|
||||
// safe one either way.
|
||||
sticky_exit: profile.vpn_id.is_some(),
|
||||
}
|
||||
}
|
||||
@@ -700,7 +707,7 @@ pub struct ProfileState {
|
||||
pub sync_enabled: bool,
|
||||
pub encrypted_sync: bool,
|
||||
pub has_proxy: bool,
|
||||
/// Whether that exit is an address a leased fleet host can dial.
|
||||
/// Whether that exit is an address a remote host can dial.
|
||||
pub proxy_remote_reachable: bool,
|
||||
pub touch_fingerprint: bool,
|
||||
pub sticky_exit: bool,
|
||||
@@ -713,7 +720,8 @@ pub struct ProfileState {
|
||||
/// even then would have to re-derive what the browser will actually dial.
|
||||
///
|
||||
/// A profile carrying BOTH a proxy and a VPN is judged on the proxy: that is
|
||||
/// what the browser is pointed at, and it is the address the fleet has to reach.
|
||||
/// what the browser is pointed at, and it is the address a remote host has to
|
||||
/// reach.
|
||||
pub fn exit_reachability(profile: &BrowserProfile) -> crate::remote_exit::ExitReachability {
|
||||
use crate::remote_exit::{classify_proxy, classify_wireguard_endpoint, ExitReachability};
|
||||
|
||||
@@ -916,7 +924,7 @@ pub async fn update_profile_state(
|
||||
///
|
||||
/// The server refuses a run on the copy the desktop last declared —
|
||||
/// `has_proxy: false` is `proxy_required`, and that check exists because a run
|
||||
/// without an exit node egresses from the leased host's own datacenter address.
|
||||
/// without an exit node egresses from the remote host's own address.
|
||||
/// Nothing but a full schedule write refreshed that copy, so detaching a proxy
|
||||
/// from an enrolled profile left `has_proxy: true` on the row and the night ran
|
||||
/// anyway. This closes that gap at the moment the profile changes.
|
||||
@@ -1046,10 +1054,10 @@ pub async fn run_now(
|
||||
|
||||
/// Stop a run that is still going.
|
||||
///
|
||||
/// A 503 here means the fleet could not be reached and the browser is still
|
||||
/// up, so the run stays `running` rather than being marked cancelled under a
|
||||
/// live browser — retiring a row while something is still writing the cookie
|
||||
/// jar is the two-writer case the profile lock exists to prevent.
|
||||
/// A 503 here means the remote host could not be reached and the browser is
|
||||
/// still up, so the run stays `running` rather than being marked cancelled
|
||||
/// under a live browser — retiring a row while something is still writing the
|
||||
/// cookie jar is the two-writer case the profile lock exists to prevent.
|
||||
pub async fn cancel_run(run_id: &str) -> Result<CookieBotRun, CookieBotError> {
|
||||
let envelope: RunEnvelope = request(
|
||||
reqwest::Method::DELETE,
|
||||
@@ -1294,7 +1302,7 @@ fn http() -> &'static reqwest::Client {
|
||||
///
|
||||
/// Built here rather than left to the HTTP client so a profile id or a keyset
|
||||
/// cursor containing a `&` cannot smuggle a second parameter into the request.
|
||||
fn with_query(url: &str, query: &[(String, String)]) -> String {
|
||||
pub(crate) fn with_query(url: &str, query: &[(String, String)]) -> String {
|
||||
if query.is_empty() {
|
||||
return url.to_string();
|
||||
}
|
||||
@@ -1430,9 +1438,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn a_local_only_profile_has_no_path_to_a_run() {
|
||||
// The host obtains the profile from donut-sync. Without sync there is
|
||||
// nothing to pull, so the run would warm an empty browser and then push
|
||||
// that emptiness over the user's real profile.
|
||||
// A remote run obtains the profile through sync, so a local-only profile
|
||||
// has nothing there: the run would warm an empty browser and then push that
|
||||
// emptiness over the user's real profile.
|
||||
let mut profile = eligible_profile();
|
||||
profile.sync_mode = SyncMode::Disabled;
|
||||
let err = bot_precondition(&profile, &ExitReachability::Remote)
|
||||
@@ -1452,19 +1460,37 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linux_is_refused_at_enrolment_rather_than_at_two_in_the_morning() {
|
||||
fn an_os_the_fleet_cannot_lease_is_refused_at_enrolment_rather_than_at_two_in_the_morning() {
|
||||
let mut profile = eligible_profile();
|
||||
profile.host_os = Some("linux".to_string());
|
||||
profile.host_os = Some("android".to_string());
|
||||
let err = bot_precondition(&profile, &ExitReachability::Remote)
|
||||
.expect_err("linux has no host to lease");
|
||||
.expect_err("android has no host to lease");
|
||||
let parsed: serde_json::Value = serde_json::from_str(&err).expect("valid envelope");
|
||||
assert_eq!(parsed["code"], "COOKIE_BOT_UNSUPPORTED_PLATFORM");
|
||||
assert_eq!(
|
||||
parsed["params"]["platform"], "linux",
|
||||
parsed["params"]["platform"], "android",
|
||||
"the message must name the platform that cannot run"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_linux_profile_passes_the_platform_check() {
|
||||
// Linux is a supported remote platform, so a linux profile is judged on
|
||||
// the same preconditions as the other two rather than refused for its OS.
|
||||
let mut profile = eligible_profile();
|
||||
profile.host_os = Some("linux".to_string());
|
||||
assert!(bot_precondition(&profile, &ExitReachability::Remote).is_ok());
|
||||
|
||||
// ...and it reaches the NEXT precondition when it fails one: the refusal a
|
||||
// linux profile with no exit gets is the exit-node code, not the platform
|
||||
// code.
|
||||
profile.proxy_id = None;
|
||||
profile.vpn_id = None;
|
||||
let err = bot_precondition(&profile, &ExitReachability::None)
|
||||
.expect_err("datacenter egress must be refused for linux as for any OS");
|
||||
assert_eq!(code_of(&err), "COOKIE_BOT_REQUIRES_EXIT_NODE");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_profile_with_no_recorded_os_cannot_be_scheduled_onto_a_host() {
|
||||
let mut profile = eligible_profile();
|
||||
@@ -1522,10 +1548,32 @@ mod tests {
|
||||
assert_eq!(code_of(&err), "COOKIE_BOT_REQUIRES_REMOTE_EXIT_NODE");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_proxy_kind_the_fleet_cannot_dial_gets_its_own_refusal() {
|
||||
// The repeated-nightly-failure case. This must NOT collapse into
|
||||
// REQUIRES_REMOTE_EXIT_NODE: that sentence tells the user their proxy's
|
||||
// address is unreachable, and a VLESS server's address is perfectly
|
||||
// reachable — the fix is a different protocol, not a different address.
|
||||
let err = bot_precondition(
|
||||
&eligible_profile(),
|
||||
&ExitReachability::UnsupportedKind {
|
||||
kind: "VLESS".to_string(),
|
||||
source: "proxy",
|
||||
},
|
||||
)
|
||||
.expect_err("no fleet host runs the xray sidecar VLESS needs");
|
||||
|
||||
assert_eq!(code_of(&err), "COOKIE_BOT_PROXY_KIND_UNSUPPORTED");
|
||||
// The protocol travels in `params` so the sentence can name it rather than
|
||||
// saying "this proxy type" and leaving the user to guess which one.
|
||||
let parsed: serde_json::Value = serde_json::from_str(&err).expect("an error envelope");
|
||||
assert_eq!(parsed["params"]["kind"], "VLESS");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_exit_we_could_not_read_is_refused_too() {
|
||||
// Fails closed. Refusing a working setup costs one support question;
|
||||
// accepting a broken one burns a leased hour and damages an identity.
|
||||
// accepting a broken one burns an hour of quota and damages an identity.
|
||||
let err = bot_precondition(
|
||||
&eligible_profile(),
|
||||
&ExitReachability::Unknown {
|
||||
@@ -1538,8 +1586,7 @@ mod tests {
|
||||
assert_eq!(code_of(&err), "COOKIE_BOT_REQUIRES_REMOTE_EXIT_NODE");
|
||||
}
|
||||
|
||||
/// A verbatim `CookieBotScheduleView`, field for field, as `toScheduleView`
|
||||
/// in donutbrowser-infra's `cookie-bot.service.ts` builds it.
|
||||
/// A verbatim schedule payload, field for field, as the cloud API sends it.
|
||||
const SERVER_SCHEDULE_VIEW: &str = r#"{
|
||||
"profile_id":"p1","profile_name":"Yu","platform":"macos","enabled":true,
|
||||
"run_at_minute":120,"days_mask":127,
|
||||
@@ -1555,10 +1602,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn the_schedule_payload_matches_what_the_backend_sends() {
|
||||
// Pinned against the Schedule shape in donutbrowser-infra's
|
||||
// cookie-bot controller. A field name that drifts makes every read fail
|
||||
// at the decode step, which surfaces as "something went wrong" with no
|
||||
// hint that the contract moved.
|
||||
// Pinned against the schedule shape the cloud API serves. A field name
|
||||
// that drifts makes every read fail at the decode step, which surfaces as
|
||||
// "something went wrong" with no hint that the contract moved.
|
||||
let schedule: CookieBotSchedule = serde_json::from_str(SERVER_SCHEDULE_VIEW)
|
||||
.expect("the backend's schedule payload must deserialize");
|
||||
|
||||
@@ -1737,7 +1783,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn the_run_payload_matches_what_the_backend_sends() {
|
||||
// Verbatim `CookieBotRunView`, as `toRunViews` builds it. `max_minutes`,
|
||||
// Verbatim run payload, exactly as the cloud API serves it. `max_minutes`,
|
||||
// `chunks_total`, `chunk_index`, `dispatch_after` and `team_id` were all
|
||||
// already on the wire and all silently discarded, so a multi-chunk night
|
||||
// could not be reported as one.
|
||||
@@ -1968,9 +2014,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn the_preset_list_carries_ids_not_behaviour() {
|
||||
// If this type ever gained a site list, a dwell range or a step
|
||||
// programme, the browsing model would have leaked into the open-source
|
||||
// client. Ids and a rough duration are all that may cross.
|
||||
// If this type ever gained the parameters that describe what a preset
|
||||
// actually does, the server-owned browsing model would have leaked into the
|
||||
// open-source client. Ids and a rough duration are all that may cross.
|
||||
let presets: CookieBotPresetList = serde_json::from_str(
|
||||
r#"{"presets":[{"id":"balanced","typical_minutes":35,"recommended":true}],
|
||||
"default_preset":"balanced"}"#,
|
||||
@@ -1988,9 +2034,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn a_template_crosses_the_wire_as_a_count_and_never_as_urls() {
|
||||
// The pool is server-owned for the same reason a preset's browsing model
|
||||
// is. If this type ever gained a `sites` field the curation would be
|
||||
// published, and a published list is one a retailer can filter.
|
||||
// The site pool is server-owned. If this type ever gained a `sites` field
|
||||
// the curation would be published, and a published list is one a retailer
|
||||
// can filter.
|
||||
let presets: CookieBotPresetList = serde_json::from_str(
|
||||
r#"{"presets":[],"default_preset":"balanced",
|
||||
"templates":[{"id":"low-intent-purchaser","site_count":32,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -375,14 +375,25 @@ impl DownloadedBrowsersRegistry {
|
||||
}
|
||||
|
||||
/// Get all browsers and versions referenced by active profiles
|
||||
/// Every (browser, version) something still needs.
|
||||
///
|
||||
/// A TRASHED profile counts. Its browser directory is exactly what a restore
|
||||
/// puts back into use, and removing the binary underneath it would turn an
|
||||
/// undo into a gigabyte download, quietly, days after the delete.
|
||||
pub fn get_active_browser_versions(
|
||||
&self,
|
||||
profiles: &[crate::profile::BrowserProfile],
|
||||
) -> Vec<(String, String)> {
|
||||
profiles
|
||||
let mut versions: Vec<(String, String)> = profiles
|
||||
.iter()
|
||||
.map(|profile| (profile.browser.clone(), profile.version.clone()))
|
||||
.collect()
|
||||
.collect();
|
||||
versions.extend(
|
||||
crate::profile::trash::list_entries(&crate::profile::trash::trash_dir())
|
||||
.into_iter()
|
||||
.map(|(profile, _)| (profile.browser, profile.version)),
|
||||
);
|
||||
versions
|
||||
}
|
||||
|
||||
/// Verify that all registered browsers actually exist on disk and clean up stale entries
|
||||
@@ -693,6 +704,73 @@ impl DownloadedBrowsersRegistry {
|
||||
Ok(cleaned_up)
|
||||
}
|
||||
|
||||
/// Update every stale profile of one browser to `latest_version`, then drop
|
||||
/// the version binaries that leaves unused.
|
||||
///
|
||||
/// The update and cleanup passes deliberately sit outside the classification
|
||||
/// loop. Running them inside it replayed every already-processed profile on
|
||||
/// each iteration, so N profiles cost N(N+1)/2 metadata rewrites and just as
|
||||
/// many `profile-updated` events. Taking both actions as callbacks also keeps
|
||||
/// the pass exercisable without a `tauri::AppHandle`.
|
||||
fn consolidate_profiles_for_browser(
|
||||
browser_name: &str,
|
||||
browser_profiles: &[&BrowserProfile],
|
||||
latest_version: &str,
|
||||
update_profile: &mut dyn FnMut(&BrowserProfile) -> Result<(), String>,
|
||||
remove_version: &mut dyn FnMut(&str) -> Result<(), String>,
|
||||
) -> Vec<String> {
|
||||
let mut consolidated = Vec::new();
|
||||
let mut profiles_to_update = Vec::new();
|
||||
let mut older_versions_to_remove = std::collections::HashSet::<String>::new();
|
||||
|
||||
for profile in browser_profiles {
|
||||
if profile.version != latest_version {
|
||||
// Only update if profile is not currently running
|
||||
if profile.process_id.is_none() {
|
||||
profiles_to_update.push(*profile);
|
||||
older_versions_to_remove.insert(profile.version.clone());
|
||||
} else {
|
||||
log::info!(
|
||||
"Skipping version update for running profile: {} ({})",
|
||||
profile.name,
|
||||
profile.version
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update profiles to latest version
|
||||
for profile in &profiles_to_update {
|
||||
match update_profile(profile) {
|
||||
Ok(()) => {
|
||||
consolidated.push(format!(
|
||||
"Updated profile '{}' from {} to {}",
|
||||
profile.name, profile.version, latest_version
|
||||
));
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to update profile '{}': {}", profile.name, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove older version binaries that are no longer needed
|
||||
for old_version in &older_versions_to_remove {
|
||||
log::info!("Consolidating: removing old version {browser_name} {old_version}");
|
||||
match remove_version(old_version.as_str()) {
|
||||
Ok(()) => {
|
||||
consolidated.push(format!("Removed old version: {browser_name} {old_version}"));
|
||||
log::info!("Successfully removed old version: {browser_name} {old_version}");
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to cleanup old version {browser_name} {old_version}: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
consolidated
|
||||
}
|
||||
|
||||
/// Consolidate browser versions - keep only the latest version per browser
|
||||
pub fn consolidate_browser_versions(
|
||||
&self,
|
||||
@@ -755,58 +833,24 @@ impl DownloadedBrowsersRegistry {
|
||||
let latest_version = &available_versions[0];
|
||||
log::info!("Latest available version for {browser_name}: {latest_version}");
|
||||
|
||||
// Check which profiles need to be updated to the latest version
|
||||
let mut profiles_to_update = Vec::new();
|
||||
let mut older_versions_to_remove = std::collections::HashSet::<String>::new();
|
||||
|
||||
for profile in browser_profiles {
|
||||
if profile.version != *latest_version {
|
||||
// Only update if profile is not currently running
|
||||
if profile.process_id.is_none() {
|
||||
profiles_to_update.push(profile);
|
||||
older_versions_to_remove.insert(profile.version.clone());
|
||||
} else {
|
||||
log::info!(
|
||||
"Skipping version update for running profile: {} ({})",
|
||||
profile.name,
|
||||
profile.version
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Update profiles to latest version
|
||||
for profile in &profiles_to_update {
|
||||
match self.profile_manager.update_profile_version(
|
||||
app_handle,
|
||||
&profile.id.to_string(),
|
||||
latest_version,
|
||||
) {
|
||||
Ok(_) => {
|
||||
consolidated.push(format!(
|
||||
"Updated profile '{}' from {} to {}",
|
||||
profile.name, profile.version, latest_version
|
||||
));
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to update profile '{}': {}", profile.name, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove older version binaries that are no longer needed
|
||||
for old_version in &older_versions_to_remove {
|
||||
log::info!("Consolidating: removing old version {browser_name} {old_version}");
|
||||
match self.cleanup_failed_download(browser_name, old_version) {
|
||||
Ok(_) => {
|
||||
consolidated.push(format!("Removed old version: {browser_name} {old_version}"));
|
||||
log::info!("Successfully removed old version: {browser_name} {old_version}");
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to cleanup old version {browser_name} {old_version}: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut consolidated_for_browser = Self::consolidate_profiles_for_browser(
|
||||
browser_name,
|
||||
browser_profiles,
|
||||
latest_version,
|
||||
&mut |profile: &BrowserProfile| -> Result<(), String> {
|
||||
self
|
||||
.profile_manager
|
||||
.update_profile_version(app_handle, &profile.id.to_string(), latest_version)
|
||||
.map(|_| ())
|
||||
.map_err(|e| e.to_string())
|
||||
},
|
||||
&mut |old_version: &str| -> Result<(), String> {
|
||||
self
|
||||
.cleanup_failed_download(browser_name, old_version)
|
||||
.map_err(|e| e.to_string())
|
||||
},
|
||||
);
|
||||
consolidated.append(&mut consolidated_for_browser);
|
||||
}
|
||||
|
||||
// Save registry after consolidation
|
||||
@@ -1061,6 +1105,47 @@ lazy_static::lazy_static! {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_trashed_profile_still_counts_as_a_reason_to_keep_its_browser() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let _guard = crate::app_dirs::set_test_data_dir(root.path().to_path_buf());
|
||||
let registry = DownloadedBrowsersRegistry::new();
|
||||
assert!(registry.get_active_browser_versions(&[]).is_empty());
|
||||
|
||||
// What `trash_profile` leaves behind: one directory per profile holding
|
||||
// the profile it archived.
|
||||
let profile = crate::profile::BrowserProfile {
|
||||
id: uuid::Uuid::new_v4(),
|
||||
browser: "wayfern".to_string(),
|
||||
version: "152.0.7977.64".to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
let entry = crate::profile::trash::trash_dir().join(profile.id.to_string());
|
||||
std::fs::create_dir_all(&entry).unwrap();
|
||||
std::fs::write(
|
||||
entry.join("profile.json"),
|
||||
serde_json::to_vec(&profile).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
entry.join("manifest.json"),
|
||||
serde_json::json!({
|
||||
"deleted_at": 1,
|
||||
"expires_at": 2,
|
||||
"size_bytes": 0,
|
||||
"original_name": "Trashed",
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
registry.get_active_browser_versions(&[]),
|
||||
vec![("wayfern".to_string(), "152.0.7977.64".to_string())],
|
||||
"removing the binary under a trashed profile turns an undo into a download"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_registry_creation() {
|
||||
// Create a mock profile manager for testing
|
||||
@@ -1395,6 +1480,51 @@ mod tests {
|
||||
"Browser should not be considered downloaded when files don't exist on disk"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_consolidate_profiles_for_browser_acts_once_per_profile() {
|
||||
let profile = |name: &str, version: &str, process_id: Option<u32>| BrowserProfile {
|
||||
id: uuid::Uuid::new_v4(),
|
||||
name: name.to_string(),
|
||||
browser: "testbrowser".to_string(),
|
||||
version: version.to_string(),
|
||||
process_id,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let stale_a = profile("stale-a", "139.0", None);
|
||||
let stale_b = profile("stale-b", "139.0", None);
|
||||
let older = profile("older", "138.0", None);
|
||||
let running = profile("running", "139.0", Some(4242));
|
||||
let current = profile("current", "140.0", None);
|
||||
let profiles = [&stale_a, &stale_b, &older, &running, ¤t];
|
||||
|
||||
let mut updated: Vec<String> = Vec::new();
|
||||
let mut removed: Vec<String> = Vec::new();
|
||||
|
||||
let consolidated = DownloadedBrowsersRegistry::consolidate_profiles_for_browser(
|
||||
"testbrowser",
|
||||
&profiles,
|
||||
"140.0",
|
||||
&mut |p: &BrowserProfile| -> Result<(), String> {
|
||||
updated.push(p.name.clone());
|
||||
Ok(())
|
||||
},
|
||||
&mut |version: &str| -> Result<(), String> {
|
||||
removed.push(version.to_string());
|
||||
Ok(())
|
||||
},
|
||||
);
|
||||
|
||||
// Every stale, stopped profile is updated exactly once - the loop used to
|
||||
// re-update each of them once per remaining profile.
|
||||
assert_eq!(updated, vec!["stale-a", "stale-b", "older"]);
|
||||
|
||||
removed.sort();
|
||||
assert_eq!(removed, vec!["138.0", "139.0"]);
|
||||
|
||||
assert_eq!(consolidated.len(), updated.len() + removed.len());
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
|
||||
@@ -945,8 +945,37 @@ impl Downloader {
|
||||
// Auto-update non-running profiles to the latest installed version and cleanup unused binaries
|
||||
{
|
||||
let app_handle_for_update = app_handle.clone();
|
||||
let browser_for_update = browser_str.clone();
|
||||
let version_for_update = version.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let auto_updater = crate::auto_updater::AutoUpdater::instance();
|
||||
|
||||
// A profile that is open right now cannot be switched to the new binary
|
||||
// yet, so it only gets a pending update. That entry has to exist before
|
||||
// cleanup runs: cleanup keeps a version only while it is in use or
|
||||
// pending, and would otherwise delete what was just downloaded.
|
||||
match auto_updater
|
||||
.auto_update_profile_versions(
|
||||
&app_handle_for_update,
|
||||
&browser_for_update,
|
||||
&version_for_update,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(updated) => {
|
||||
if !updated.is_empty() {
|
||||
log::info!(
|
||||
"Applied {browser_for_update} {version_for_update} to profiles: {updated:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!(
|
||||
"Failed to apply {browser_for_update} {version_for_update} to profiles: {e}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
match auto_updater.update_profiles_to_latest_installed(&app_handle_for_update) {
|
||||
Ok(updated) => {
|
||||
if !updated.is_empty() {
|
||||
|
||||
@@ -432,6 +432,7 @@ mod tests {
|
||||
last_sync: None,
|
||||
host_os: None,
|
||||
ephemeral,
|
||||
temporary: false,
|
||||
extension_group_id: None,
|
||||
proxy_bypass_rules: Vec::new(),
|
||||
created_by_id: None,
|
||||
|
||||
@@ -0,0 +1,600 @@
|
||||
//! Import a Chromium extension from a link instead of a file.
|
||||
//!
|
||||
//! Three inputs are accepted: a Chrome Web Store detail URL, the bare
|
||||
//! 32-character extension id from one, and a direct `.crx`/`.zip` URL. All
|
||||
//! three resolve to a single archive download, whose payload is normalised to
|
||||
//! the plain ZIP that `extension_manager` already stores, so nothing
|
||||
//! downstream (assignment, groups, per-profile staging, sync) has to know an
|
||||
//! extension arrived over the network.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use url::Url;
|
||||
|
||||
/// Matches the body limit the REST extension routes accept for an upload
|
||||
/// (`api_server::DefaultBodyLimit::max(64 MiB)`). A link import and a file
|
||||
/// upload land in the same store, so they get the same ceiling.
|
||||
pub const MAX_EXTENSION_BYTES: u64 = 64 * 1024 * 1024;
|
||||
|
||||
const CRX_MAGIC: &[u8; 4] = b"Cr24";
|
||||
const ZIP_MAGIC: &[u8; 4] = b"PK\x03\x04";
|
||||
const MAX_REDIRECTS: usize = 5;
|
||||
|
||||
/// The last-resort `prodversion` for the Web Store endpoint, used only when no
|
||||
/// Wayfern build is downloaded and no version cache exists yet — a fresh
|
||||
/// install that has never fetched a browser. Every other path reads the real
|
||||
/// installed version, so this is a floor, not the normal answer.
|
||||
const FALLBACK_PRODUCT_VERSION: &str = "120.0.0.0";
|
||||
|
||||
fn err(code: &str) -> String {
|
||||
crate::backend_error(code)
|
||||
}
|
||||
|
||||
/// What a link resolves to before anything is fetched.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ExtensionSource {
|
||||
/// A Chrome Web Store product id, downloaded through the update service.
|
||||
WebStore(String),
|
||||
/// An archive served directly.
|
||||
Direct(Url),
|
||||
}
|
||||
|
||||
/// A downloaded, validated extension archive, staged in the frontend exactly
|
||||
/// like a picked file so the user confirms a real name and version before it
|
||||
/// is stored.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FetchedExtension {
|
||||
pub file_name: String,
|
||||
pub file_data: Vec<u8>,
|
||||
/// The manifest's own name, with any `__MSG_key__` placeholder resolved.
|
||||
pub name: Option<String>,
|
||||
pub version: Option<String>,
|
||||
pub description: Option<String>,
|
||||
/// The URL the bytes actually came from, so the staged form can name it.
|
||||
pub source_url: String,
|
||||
/// True when the id was resolved through the Chrome Web Store update
|
||||
/// service rather than downloaded from a link the user typed in full.
|
||||
pub from_web_store: bool,
|
||||
}
|
||||
|
||||
/// A Chrome extension id is 32 characters drawn from `a`-`p`: the store
|
||||
/// re-encodes the first 128 bits of the packing key's SHA-256 with that
|
||||
/// alphabet, so anything outside it is not an id however long it is.
|
||||
pub fn parse_extension_id(candidate: &str) -> Option<String> {
|
||||
let trimmed = candidate.trim();
|
||||
if trimmed.len() != 32 {
|
||||
return None;
|
||||
}
|
||||
let lowered = trimmed.to_ascii_lowercase();
|
||||
lowered
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_lowercase() && b <= b'p')
|
||||
.then_some(lowered)
|
||||
}
|
||||
|
||||
fn web_store_id_from_path(url: &Url) -> Option<String> {
|
||||
let segments: Vec<&str> = url.path_segments()?.filter(|s| !s.is_empty()).collect();
|
||||
// `/detail/<slug>/<id>` on the current store, `/webstore/detail/<slug>/<id>`
|
||||
// on the legacy host, and both allow the slug to be omitted. Rather than
|
||||
// encoding every shape, take the first segment that is a real id.
|
||||
segments
|
||||
.iter()
|
||||
.find_map(|segment| parse_extension_id(segment))
|
||||
}
|
||||
|
||||
fn is_web_store_host(host: &str) -> bool {
|
||||
matches!(
|
||||
host,
|
||||
"chromewebstore.google.com" | "chrome.google.com" | "www.chrome.google.com"
|
||||
)
|
||||
}
|
||||
|
||||
fn path_is_archive(url: &Url) -> bool {
|
||||
let path = url.path().to_ascii_lowercase();
|
||||
path.ends_with(".crx") || path.ends_with(".zip")
|
||||
}
|
||||
|
||||
/// Loopback plain HTTP is accepted only in the `e2e` build, where the suite
|
||||
/// serves its own CRX fixture from a local server. A shipped build has no such
|
||||
/// path, so every real import crosses TLS.
|
||||
fn scheme_is_allowed(url: &Url) -> bool {
|
||||
if url.scheme() == "https" {
|
||||
return true;
|
||||
}
|
||||
cfg!(feature = "e2e") && url.scheme() == "http" && host_is_loopback(url)
|
||||
}
|
||||
|
||||
fn host_is_loopback(url: &Url) -> bool {
|
||||
match url.host() {
|
||||
Some(url::Host::Ipv4(ip)) => ip.is_loopback(),
|
||||
Some(url::Host::Ipv6(ip)) => ip.is_loopback(),
|
||||
Some(url::Host::Domain(name)) => name.eq_ignore_ascii_case("localhost"),
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Classify what the user typed. Anything that is not one of the three
|
||||
/// accepted shapes is refused here, before a single byte is requested.
|
||||
pub fn parse_extension_source(input: &str) -> Result<ExtensionSource, String> {
|
||||
let trimmed = input.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err(err("EXTENSION_URL_INVALID"));
|
||||
}
|
||||
|
||||
if let Some(id) = parse_extension_id(trimmed) {
|
||||
return Ok(ExtensionSource::WebStore(id));
|
||||
}
|
||||
|
||||
let url = Url::parse(trimmed).map_err(|_| err("EXTENSION_URL_INVALID"))?;
|
||||
if !scheme_is_allowed(&url) {
|
||||
return Err(err("EXTENSION_URL_INVALID"));
|
||||
}
|
||||
|
||||
if let Some(host) = url.host_str() {
|
||||
if is_web_store_host(host) {
|
||||
return web_store_id_from_path(&url)
|
||||
.map(ExtensionSource::WebStore)
|
||||
.ok_or_else(|| err("EXTENSION_URL_INVALID"));
|
||||
}
|
||||
}
|
||||
|
||||
if path_is_archive(&url) {
|
||||
return Ok(ExtensionSource::Direct(url));
|
||||
}
|
||||
|
||||
Err(err("EXTENSION_URL_INVALID"))
|
||||
}
|
||||
|
||||
/// The `nacl_arch` the Web Store update service expects for this machine. It
|
||||
/// picks between architecture-specific builds of the same extension, so a
|
||||
/// wrong value hands back a package the browser cannot load.
|
||||
pub fn nacl_arch() -> &'static str {
|
||||
match std::env::consts::ARCH {
|
||||
"x86_64" => "x86-64",
|
||||
"x86" => "x86-32",
|
||||
"aarch64" => "arm64",
|
||||
"arm" => "arm",
|
||||
_ => "x86-64",
|
||||
}
|
||||
}
|
||||
|
||||
/// Newest Chromium version this machine actually has, because the Web Store
|
||||
/// serves a package built for the requesting browser and a version it does not
|
||||
/// recognise is answered with an error rather than a CRX.
|
||||
pub fn chromium_product_version() -> String {
|
||||
let downloaded = crate::downloaded_browsers_registry::DownloadedBrowsersRegistry::instance()
|
||||
.get_downloaded_versions("wayfern");
|
||||
if let Some(version) = newest_version(&downloaded) {
|
||||
return version;
|
||||
}
|
||||
|
||||
let cached = crate::browser_version_manager::BrowserVersionManager::instance()
|
||||
.get_cached_browser_versions("wayfern")
|
||||
.unwrap_or_default();
|
||||
newest_version(&cached).unwrap_or_else(|| FALLBACK_PRODUCT_VERSION.to_string())
|
||||
}
|
||||
|
||||
/// Highest dotted-numeric version in `versions`. Neither the registry nor the
|
||||
/// version cache promises an order, and a lexical max reads `9.x` as newer
|
||||
/// than `151.x`.
|
||||
fn newest_version(versions: &[String]) -> Option<String> {
|
||||
versions
|
||||
.iter()
|
||||
.filter(|version| !version.trim().is_empty())
|
||||
.max_by_key(|version| version_key(version))
|
||||
.cloned()
|
||||
}
|
||||
|
||||
fn version_key(version: &str) -> [u64; 4] {
|
||||
let mut parts = [0u64; 4];
|
||||
for (slot, piece) in parts.iter_mut().zip(version.split('.')) {
|
||||
*slot = piece.trim().parse().unwrap_or(0);
|
||||
}
|
||||
parts
|
||||
}
|
||||
|
||||
/// The Chrome Web Store update service, the endpoint Chromium itself uses to
|
||||
/// fetch a package on demand. It needs the product id, the ABI, and a Chromium
|
||||
/// version, and answers with a redirect to the CRX.
|
||||
pub fn web_store_download_url(id: &str, product_version: &str, nacl_arch: &str) -> String {
|
||||
format!(
|
||||
"https://clients2.google.com/service/update2/crx\
|
||||
?response=redirect&acceptformat=crx3&prodversion={product}&nacl_arch={arch}\
|
||||
&x=id%3D{id}%26installsource%3Dondemand%26uc",
|
||||
product = urlencoding::encode(product_version),
|
||||
arch = urlencoding::encode(nacl_arch),
|
||||
id = id,
|
||||
)
|
||||
}
|
||||
|
||||
/// Unwrap a CRX3 container to the ZIP it carries.
|
||||
///
|
||||
/// A `.crx` is not a ZIP with a different name: it is `Cr24`, a little-endian
|
||||
/// format version, a little-endian header length, that many bytes of protobuf
|
||||
/// signature header, and only then the ZIP. Storing the whole file as if it
|
||||
/// were an archive leaves every reader to guess where the ZIP starts.
|
||||
pub fn crx3_zip_payload(data: &[u8]) -> Result<&[u8], String> {
|
||||
if data.len() < 16 || &data[0..4] != CRX_MAGIC {
|
||||
return Err(err("EXTENSION_NOT_AN_EXTENSION"));
|
||||
}
|
||||
let version = u32::from_le_bytes([data[4], data[5], data[6], data[7]]);
|
||||
if version != 3 {
|
||||
// CRX2 has a different header (two length fields, no protobuf) and has not
|
||||
// been accepted by Chromium for years. Refusing is more useful than
|
||||
// guessing at an offset.
|
||||
return Err(err("EXTENSION_NOT_AN_EXTENSION"));
|
||||
}
|
||||
let header_len = u32::from_le_bytes([data[8], data[9], data[10], data[11]]) as usize;
|
||||
let start = 12usize
|
||||
.checked_add(header_len)
|
||||
.ok_or_else(|| err("EXTENSION_NOT_AN_EXTENSION"))?;
|
||||
let payload = data
|
||||
.get(start..)
|
||||
.ok_or_else(|| err("EXTENSION_NOT_AN_EXTENSION"))?;
|
||||
if payload.len() < 4 || &payload[0..4] != ZIP_MAGIC {
|
||||
return Err(err("EXTENSION_NOT_AN_EXTENSION"));
|
||||
}
|
||||
Ok(payload)
|
||||
}
|
||||
|
||||
/// Normalise downloaded bytes to the plain ZIP the store keeps. A CRX3 is
|
||||
/// unwrapped; a ZIP passes through; anything else is refused.
|
||||
pub fn archive_payload(data: &[u8]) -> Result<&[u8], String> {
|
||||
if data.len() >= 4 && &data[0..4] == ZIP_MAGIC {
|
||||
return Ok(data);
|
||||
}
|
||||
crx3_zip_payload(data)
|
||||
}
|
||||
|
||||
fn redirect_policy() -> reqwest::redirect::Policy {
|
||||
reqwest::redirect::Policy::custom(|attempt| {
|
||||
if !scheme_is_allowed(attempt.url()) {
|
||||
// A store redirect that leaves TLS would download the package in the
|
||||
// clear, and the package is executable code. Stopping here surfaces the
|
||||
// final response instead of following it.
|
||||
return attempt.stop();
|
||||
}
|
||||
if attempt.previous().len() > MAX_REDIRECTS {
|
||||
return attempt.stop();
|
||||
}
|
||||
attempt.follow()
|
||||
})
|
||||
}
|
||||
|
||||
async fn download_archive(url: &str) -> Result<Vec<u8>, String> {
|
||||
use futures_util::StreamExt;
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(120))
|
||||
.connect_timeout(std::time::Duration::from_secs(15))
|
||||
.redirect(redirect_policy())
|
||||
.build()
|
||||
.map_err(|_| err("EXTENSION_DOWNLOAD_FAILED"))?;
|
||||
|
||||
let response = client
|
||||
.get(url)
|
||||
.header("User-Agent", "Mozilla/5.0 (compatible; donutbrowser)")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
log::warn!("Extension download request failed: {e}");
|
||||
err("EXTENSION_DOWNLOAD_FAILED")
|
||||
})?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
log::warn!("Extension download answered HTTP {}", response.status());
|
||||
return Err(err("EXTENSION_DOWNLOAD_FAILED"));
|
||||
}
|
||||
// A redirect the policy stopped surfaces here as a 3xx, which
|
||||
// `is_success` already rejects; the final URL is checked again so a
|
||||
// same-status hop can never slip through.
|
||||
if !scheme_is_allowed(response.url()) {
|
||||
return Err(err("EXTENSION_DOWNLOAD_FAILED"));
|
||||
}
|
||||
if response
|
||||
.content_length()
|
||||
.is_some_and(|len| len > MAX_EXTENSION_BYTES)
|
||||
{
|
||||
return Err(err("EXTENSION_TOO_LARGE"));
|
||||
}
|
||||
|
||||
let mut buffer: Vec<u8> = Vec::new();
|
||||
let mut stream = response.bytes_stream();
|
||||
while let Some(chunk) = stream.next().await {
|
||||
let chunk = chunk.map_err(|e| {
|
||||
log::warn!("Extension download stream failed: {e}");
|
||||
err("EXTENSION_DOWNLOAD_FAILED")
|
||||
})?;
|
||||
// A server is free to lie about, or omit, Content-Length, so the ceiling
|
||||
// is enforced against what actually arrives.
|
||||
if buffer.len() as u64 + chunk.len() as u64 > MAX_EXTENSION_BYTES {
|
||||
return Err(err("EXTENSION_TOO_LARGE"));
|
||||
}
|
||||
buffer.extend_from_slice(&chunk);
|
||||
}
|
||||
|
||||
if buffer.is_empty() {
|
||||
return Err(err("EXTENSION_DOWNLOAD_FAILED"));
|
||||
}
|
||||
Ok(buffer)
|
||||
}
|
||||
|
||||
/// What the archive is stored as. The payload written to the store is always
|
||||
/// the plain ZIP, so a `.crx` link keeps its name but not its extension —
|
||||
/// calling an unwrapped payload `.crx` would tell every later reader to skip a
|
||||
/// CRX header that is no longer there.
|
||||
fn direct_file_name(url: &Url) -> String {
|
||||
let raw = url
|
||||
.path_segments()
|
||||
.and_then(|mut segments| segments.rfind(|s| !s.is_empty()))
|
||||
.unwrap_or_default();
|
||||
let stem = raw
|
||||
.strip_suffix(".crx")
|
||||
.or_else(|| raw.strip_suffix(".CRX"))
|
||||
.or_else(|| raw.strip_suffix(".zip"))
|
||||
.or_else(|| raw.strip_suffix(".ZIP"))
|
||||
.unwrap_or(raw)
|
||||
.trim();
|
||||
if stem.is_empty() {
|
||||
return "extension.zip".to_string();
|
||||
}
|
||||
format!("{stem}.zip")
|
||||
}
|
||||
|
||||
/// Fetch and validate an extension from a link, returning the plain ZIP plus
|
||||
/// the identity read out of its own manifest.
|
||||
pub async fn fetch_extension(input: &str) -> Result<FetchedExtension, String> {
|
||||
let source = parse_extension_source(input)?;
|
||||
let (download_url, file_name, from_web_store) = match &source {
|
||||
ExtensionSource::WebStore(id) => (
|
||||
web_store_download_url(id, &chromium_product_version(), nacl_arch()),
|
||||
format!("{id}.zip"),
|
||||
true,
|
||||
),
|
||||
ExtensionSource::Direct(url) => (url.to_string(), direct_file_name(url), false),
|
||||
};
|
||||
|
||||
let raw = download_archive(&download_url).await?;
|
||||
let payload = archive_payload(&raw)?;
|
||||
// A ZIP that carries no manifest is not an extension, whatever it was
|
||||
// served as. Refusing here keeps a 404 page or an installer out of the
|
||||
// store instead of leaving a broken row the user has to work out.
|
||||
let manifest = crate::extension_manager::read_manifest_from_archive(payload, "zip")
|
||||
.ok_or_else(|| err("EXTENSION_NOT_AN_EXTENSION"))?;
|
||||
let (name, version, description, _author, _homepage) =
|
||||
crate::extension_manager::manifest_metadata(
|
||||
&manifest,
|
||||
&crate::extension_manager::ManifestSource::Archive {
|
||||
data: payload,
|
||||
file_type: "zip",
|
||||
},
|
||||
);
|
||||
|
||||
Ok(FetchedExtension {
|
||||
file_name,
|
||||
file_data: payload.to_vec(),
|
||||
name,
|
||||
version,
|
||||
description,
|
||||
source_url: if from_web_store {
|
||||
// The update-service URL is machine-specific noise; the store page is
|
||||
// what a user recognises and can open.
|
||||
match &source {
|
||||
ExtensionSource::WebStore(id) => {
|
||||
format!("https://chromewebstore.google.com/detail/{id}")
|
||||
}
|
||||
ExtensionSource::Direct(url) => url.to_string(),
|
||||
}
|
||||
} else {
|
||||
download_url
|
||||
},
|
||||
from_web_store,
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn fetch_extension_from_url(url: String) -> Result<FetchedExtension, String> {
|
||||
fetch_extension(&url).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn crx3(header: &[u8], zip: &[u8]) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
out.extend_from_slice(CRX_MAGIC);
|
||||
out.extend_from_slice(&3u32.to_le_bytes());
|
||||
out.extend_from_slice(&(header.len() as u32).to_le_bytes());
|
||||
out.extend_from_slice(header);
|
||||
out.extend_from_slice(zip);
|
||||
out
|
||||
}
|
||||
|
||||
fn zip_bytes() -> Vec<u8> {
|
||||
let mut zip = ZIP_MAGIC.to_vec();
|
||||
zip.extend_from_slice(b"the rest of an archive");
|
||||
zip
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_crx3_container_yields_exactly_the_zip_it_carries() {
|
||||
let zip = zip_bytes();
|
||||
let crx = crx3(&[7u8; 40], &zip);
|
||||
assert_eq!(crx3_zip_payload(&crx).unwrap(), zip.as_slice());
|
||||
assert_eq!(archive_payload(&crx).unwrap(), zip.as_slice());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_that_is_not_a_crx_is_refused_rather_than_scanned_for_a_zip() {
|
||||
let mut wrong_magic = crx3(&[0u8; 8], &zip_bytes());
|
||||
wrong_magic[0] = b'X';
|
||||
assert_eq!(
|
||||
crx3_zip_payload(&wrong_magic).unwrap_err(),
|
||||
err("EXTENSION_NOT_AN_EXTENSION")
|
||||
);
|
||||
assert_eq!(
|
||||
archive_payload(b"<!doctype html><html>404</html>").unwrap_err(),
|
||||
err("EXTENSION_NOT_AN_EXTENSION")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_truncated_crx_never_reads_past_its_own_bytes() {
|
||||
let full = crx3(&[1u8; 32], &zip_bytes());
|
||||
for cut in [8usize, 12, 20, 40] {
|
||||
assert!(crx3_zip_payload(&full[..cut.min(full.len())]).is_err());
|
||||
}
|
||||
// A header length that runs past the file must not panic or return the
|
||||
// tail of some other structure.
|
||||
let mut lying = crx3(&[1u8; 32], &zip_bytes());
|
||||
lying[8..12].copy_from_slice(&u32::MAX.to_le_bytes());
|
||||
assert!(crx3_zip_payload(&lying).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_plain_zip_is_accepted_and_a_crx2_is_not() {
|
||||
let zip = zip_bytes();
|
||||
assert_eq!(archive_payload(&zip).unwrap(), zip.as_slice());
|
||||
|
||||
let mut crx2 = crx3(&[0u8; 16], &zip);
|
||||
crx2[4..8].copy_from_slice(&2u32.to_le_bytes());
|
||||
assert_eq!(
|
||||
archive_payload(&crx2).unwrap_err(),
|
||||
err("EXTENSION_NOT_AN_EXTENSION")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_accepted_link_shape_resolves_to_one_source() {
|
||||
let id = "abcdefghijklmnopabcdefghijklmnop";
|
||||
for input in [
|
||||
id,
|
||||
&format!(" {} ", id.to_ascii_uppercase()),
|
||||
&format!("https://chromewebstore.google.com/detail/some-slug/{id}"),
|
||||
&format!("https://chromewebstore.google.com/detail/some-slug/{id}?hl=en"),
|
||||
&format!("https://chromewebstore.google.com/detail/{id}"),
|
||||
&format!("https://chrome.google.com/webstore/detail/some-slug/{id}"),
|
||||
&format!("https://chrome.google.com/webstore/detail/some-slug/{id}/related"),
|
||||
] {
|
||||
assert_eq!(
|
||||
parse_extension_source(input).unwrap(),
|
||||
ExtensionSource::WebStore(id.to_string()),
|
||||
"{input}"
|
||||
);
|
||||
}
|
||||
|
||||
let direct = "https://files.example.com/pack/ublock.crx";
|
||||
assert_eq!(
|
||||
parse_extension_source(direct).unwrap(),
|
||||
ExtensionSource::Direct(Url::parse(direct).unwrap())
|
||||
);
|
||||
assert!(matches!(
|
||||
parse_extension_source("https://files.example.com/pack/ublock.zip?v=2").unwrap(),
|
||||
ExtensionSource::Direct(_)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_link_that_is_not_an_extension_is_refused_before_anything_is_fetched() {
|
||||
for input in [
|
||||
"",
|
||||
" ",
|
||||
// 31 and 33 characters, and an id using letters past `p`.
|
||||
"abcdefghijklmnopabcdefghijklmno",
|
||||
"abcdefghijklmnopabcdefghijklmnopq",
|
||||
"abcdefghijklmnopabcdefghijklmnoz",
|
||||
"not a url at all",
|
||||
"ftp://files.example.com/ublock.crx",
|
||||
"file:///etc/passwd",
|
||||
// The right host, but no product id anywhere in the path.
|
||||
"https://chromewebstore.google.com/category/extensions",
|
||||
// An https URL that is not an archive.
|
||||
"https://files.example.com/downloads",
|
||||
"https://files.example.com/installer.exe",
|
||||
] {
|
||||
assert_eq!(
|
||||
parse_extension_source(input).unwrap_err(),
|
||||
err("EXTENSION_URL_INVALID"),
|
||||
"{input}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plain_http_is_refused_outside_the_test_build_and_never_off_loopback() {
|
||||
let loopback = Url::parse("http://127.0.0.1:8321/fixture.crx").unwrap();
|
||||
assert_eq!(scheme_is_allowed(&loopback), cfg!(feature = "e2e"));
|
||||
assert!(!scheme_is_allowed(
|
||||
&Url::parse("http://files.example.com/ublock.crx").unwrap()
|
||||
));
|
||||
assert!(scheme_is_allowed(
|
||||
&Url::parse("https://files.example.com/ublock.crx").unwrap()
|
||||
));
|
||||
assert_eq!(
|
||||
parse_extension_source("http://files.example.com/ublock.crx").unwrap_err(),
|
||||
err("EXTENSION_URL_INVALID")
|
||||
);
|
||||
}
|
||||
|
||||
/// The redirect policy is what makes the scheme guard hold for the whole
|
||||
/// chain, not only the first request: the Web Store answers with a redirect,
|
||||
/// so the URL the bytes actually come from is never the one that was typed.
|
||||
#[test]
|
||||
fn a_redirect_is_judged_by_the_same_rule_as_the_first_request() {
|
||||
let policy_allows = |url: &str| scheme_is_allowed(&Url::parse(url).unwrap());
|
||||
assert!(policy_allows(
|
||||
"https://clients2.googleusercontent.com/crx/blobs/abc/EXT.crx"
|
||||
));
|
||||
// The classic downgrade: an https request answered with a plain-HTTP
|
||||
// Location. The package is executable code, so the chain stops there.
|
||||
assert!(!policy_allows("http://mirror.example.com/EXT.crx"));
|
||||
assert!(!policy_allows("ftp://mirror.example.com/EXT.crx"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_size_ceiling_matches_the_upload_route_and_bounds_the_buffer() {
|
||||
assert_eq!(MAX_EXTENSION_BYTES, 64 * 1024 * 1024);
|
||||
// The streaming guard is a comparison on running totals; prove the
|
||||
// arithmetic it relies on rejects the first chunk that crosses the line.
|
||||
let already = MAX_EXTENSION_BYTES - 10;
|
||||
assert!(already + 11 > MAX_EXTENSION_BYTES);
|
||||
assert!(already + 10 <= MAX_EXTENSION_BYTES);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_web_store_url_carries_the_id_the_abi_and_the_installed_version() {
|
||||
let url = web_store_download_url("abcdefghijklmnopabcdefghijklmnop", "151.0.7922.76", "arm64");
|
||||
assert!(url.starts_with("https://clients2.google.com/service/update2/crx?"));
|
||||
assert!(url.contains("prodversion=151.0.7922.76"));
|
||||
assert!(url.contains("nacl_arch=arm64"));
|
||||
assert!(url.contains("id%3Dabcdefghijklmnopabcdefghijklmnop"));
|
||||
assert!(url.contains("acceptformat=crx3"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_newest_installed_version_wins_over_a_lexically_larger_one() {
|
||||
let versions = vec![
|
||||
"9.0.1.0".to_string(),
|
||||
"151.0.7922.76".to_string(),
|
||||
"147.0.7727.138".to_string(),
|
||||
];
|
||||
assert_eq!(newest_version(&versions).unwrap(), "151.0.7922.76");
|
||||
assert_eq!(newest_version(&[]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_direct_download_names_the_stored_file_a_zip() {
|
||||
assert_eq!(
|
||||
direct_file_name(&Url::parse("https://files.example.com/pack/ublock.crx").unwrap()),
|
||||
"ublock.zip"
|
||||
);
|
||||
assert_eq!(
|
||||
direct_file_name(&Url::parse("https://files.example.com/pack/ublock.zip").unwrap()),
|
||||
"ublock.zip"
|
||||
);
|
||||
assert_eq!(
|
||||
direct_file_name(&Url::parse("https://files.example.com/.crx").unwrap()),
|
||||
"extension.zip"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,9 @@ fn default_source_kind() -> String {
|
||||
pub struct Extension {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
/// The archive's identity, kept separately from the user's editable name.
|
||||
#[serde(default)]
|
||||
pub manifest_name: Option<String>,
|
||||
pub file_name: String,
|
||||
pub file_type: String,
|
||||
pub browser_compatibility: Vec<String>,
|
||||
@@ -258,7 +261,7 @@ fn extract_manifest_metadata(file_data: &[u8], file_type: &str) -> ManifestMetad
|
||||
}
|
||||
}
|
||||
|
||||
fn manifest_metadata(
|
||||
pub(crate) fn manifest_metadata(
|
||||
manifest: &serde_json::Value,
|
||||
source: &ManifestSource<'_>,
|
||||
) -> ManifestMetadata {
|
||||
@@ -630,7 +633,8 @@ impl ExtensionManager {
|
||||
|
||||
let ext = Extension {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
name: Self::resolve_name(name, manifest_name)?,
|
||||
name: Self::resolve_name(name, manifest_name.clone())?,
|
||||
manifest_name,
|
||||
file_name: file_name.clone(),
|
||||
file_type,
|
||||
browser_compatibility,
|
||||
@@ -677,7 +681,8 @@ impl ExtensionManager {
|
||||
|
||||
let ext = Extension {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
name: Self::resolve_name(name, manifest_name)?,
|
||||
name: Self::resolve_name(name, manifest_name.clone())?,
|
||||
manifest_name,
|
||||
file_name: absolute
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().to_string())
|
||||
@@ -1001,6 +1006,7 @@ impl ExtensionManager {
|
||||
if let Some(h) = homepage_url {
|
||||
ext.homepage_url = Some(h);
|
||||
}
|
||||
ext.manifest_name = manifest_name.clone();
|
||||
if let Some(mn) = manifest_name {
|
||||
if !explicit_name_provided && !mn.trim().is_empty() {
|
||||
ext.name = mn;
|
||||
@@ -1678,7 +1684,8 @@ impl ExtensionManager {
|
||||
|
||||
let (manifest_name, version, description, author, homepage_url) = metadata;
|
||||
let mut updated = ext.clone();
|
||||
let mut changed = false;
|
||||
let mut changed = updated.manifest_name != manifest_name;
|
||||
updated.manifest_name = manifest_name.clone();
|
||||
|
||||
// The name is user-editable, so it is only touched when what is stored is
|
||||
// an unresolved placeholder.
|
||||
@@ -2307,6 +2314,7 @@ mod tests {
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(ext.name, "uBlock Origin Lite");
|
||||
assert_eq!(ext.manifest_name.as_deref(), Some("uBlock Origin Lite"));
|
||||
assert_eq!(
|
||||
ext.description.as_deref(),
|
||||
Some("An efficient content blocker.")
|
||||
@@ -2314,6 +2322,31 @@ mod tests {
|
||||
assert_eq!(ext.version.as_deref(), Some("1.2.3"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manifest_identity_backfill_preserves_an_explicit_name_and_edit_time() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let _guard = crate::app_dirs::set_test_data_dir(tmp.path().to_path_buf());
|
||||
let mgr = ExtensionManager::new();
|
||||
let mut ext = mgr
|
||||
.add_extension(
|
||||
"fallback".to_string(),
|
||||
"ublock.zip".to_string(),
|
||||
localized_extension_zip(),
|
||||
)
|
||||
.unwrap();
|
||||
ext.name = "My blocker".to_string();
|
||||
ext.manifest_name = None;
|
||||
mgr.update_extension_internal(&ext).unwrap();
|
||||
mgr.ensure_icons_extracted();
|
||||
let restored = mgr.get_extension(&ext.id).unwrap();
|
||||
assert_eq!(restored.name, "My blocker");
|
||||
assert_eq!(
|
||||
restored.manifest_name.as_deref(),
|
||||
Some("uBlock Origin Lite")
|
||||
);
|
||||
assert_eq!(restored.updated_at, ext.updated_at);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stored_placeholder_is_repaired_rather_than_shown_to_the_user() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -6,6 +6,12 @@
|
||||
//! language. A mismatch (e.g. a US fingerprint behind a German exit IP) is a
|
||||
//! strong anti-bot tell even though the real device never leaks.
|
||||
//!
|
||||
//! Every comparison has three outcomes, never two: the dimensions agree, they
|
||||
//! disagree, or nothing was compared because the fingerprint declares no value
|
||||
//! to compare against. That third state is reported, never folded into the
|
||||
//! first, "we checked and it matches" and "we checked nothing" are different
|
||||
//! claims, and only one of them has been earned.
|
||||
//!
|
||||
//! This module only measures. Deciding what a mismatch *means* for a launch —
|
||||
//! block, warn, or ignore — belongs to `launch_gate`, which calls
|
||||
//! `probe_and_check_consistency` before the browser is spawned. Launches never
|
||||
@@ -89,12 +95,31 @@ lazy_static::lazy_static! {
|
||||
static ref EXIT_CACHE: Mutex<HashMap<String, CachedExit>> = Mutex::new(HashMap::new());
|
||||
}
|
||||
|
||||
/// The dimensions an exit is compared on, in report order.
|
||||
pub const CHECKED_DIMENSIONS: [&str; 2] = ["timezone", "language"];
|
||||
|
||||
/// The outcome of comparing a measured exit against a fingerprint.
|
||||
///
|
||||
/// Three states, deliberately distinct, because collapsing the third into the
|
||||
/// first is how a launch came to report a match it never made:
|
||||
///
|
||||
/// * **agree**, `checked`, `consistent`, nothing in `unverified`;
|
||||
/// * **disagree**, `checked`, not `consistent`, the offenders in `mismatches`;
|
||||
/// * **not compared**, the dimension is named in `unverified`, and if nothing
|
||||
/// at all could be compared then `checked` is false.
|
||||
///
|
||||
/// `consistent` alone never means "verified": it is also true when there was
|
||||
/// nothing to compare. Read it together with `checked` and `unverified`, or
|
||||
/// call [`ConsistencyResult::is_mismatch`] / [`ConsistencyResult::is_verified`].
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct ConsistencyResult {
|
||||
/// True when everything we could check lines up (or there was nothing to
|
||||
/// check — no proxy assigned).
|
||||
/// True when no dimension that was actually compared disagreed. Also true
|
||||
/// when nothing was compared at all, so this is a claim about what was
|
||||
/// measured, never a claim that anything was.
|
||||
pub consistent: bool,
|
||||
/// True when we actually reached an exit node and compared something.
|
||||
/// True when we reached an exit node **and** compared at least one dimension
|
||||
/// against it. False both when no exit was measured and when one was measured
|
||||
/// but the fingerprint declared nothing to compare it against.
|
||||
pub checked: bool,
|
||||
pub exit_ip: Option<String>,
|
||||
pub exit_country_code: Option<String>,
|
||||
@@ -103,6 +128,20 @@ pub struct ConsistencyResult {
|
||||
pub fingerprint_language: Option<String>,
|
||||
/// One of "timezone", "language" — the dimensions that disagree.
|
||||
pub mismatches: Vec<String>,
|
||||
/// One of "timezone", "language", dimensions the exit supplied a value for
|
||||
/// but that were never compared, because the fingerprint declares no value of
|
||||
/// its own (or, for language, because the exit country has no CLDR data).
|
||||
///
|
||||
/// Not a mismatch: a launch is never blocked on one, because a fingerprint
|
||||
/// whose geolocation probe failed legitimately carries no location at all.
|
||||
/// `wayfern_manager::apply_geolocation` writes nothing rather than inventing
|
||||
/// `America/New_York`, and `wayfern_manager::launch_fingerprint_payload`
|
||||
/// forwards that absence to the browser rather than filling it back in, so
|
||||
/// "not compared" describes what the launch actually presents. But not a pass
|
||||
/// either, these dimensions are unverified and must never be reported to the
|
||||
/// user as agreeing.
|
||||
#[serde(default)]
|
||||
pub unverified: Vec<String>,
|
||||
}
|
||||
|
||||
impl ConsistencyResult {
|
||||
@@ -116,8 +155,48 @@ impl ConsistencyResult {
|
||||
fingerprint_timezone: None,
|
||||
fingerprint_language: None,
|
||||
mismatches: Vec::new(),
|
||||
unverified: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// A positively measured disagreement, the only state that may stop a
|
||||
/// launch.
|
||||
pub fn is_mismatch(&self) -> bool {
|
||||
self.checked && !self.consistent
|
||||
}
|
||||
|
||||
/// Every dimension the exit offered was compared and agreed. The only state
|
||||
/// that has earned the word "consistent" in front of a user.
|
||||
pub fn is_verified(&self) -> bool {
|
||||
self.checked && self.consistent && self.unverified.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// Dimensions this profile can never be verified on, whatever exit it turns out
|
||||
/// to use, because its stored fingerprint declares no value to compare.
|
||||
///
|
||||
/// Pure and local: no exit measurement, no I/O, so the pre-launch report can
|
||||
/// state it before a single worker starts. A lower bound on what a real probe
|
||||
/// will report as unverified, the exit's own country can also leave the
|
||||
/// language uncomparable, and that is not knowable from here.
|
||||
pub fn unverifiable_dimensions(profile: &BrowserProfile) -> Vec<String> {
|
||||
let (fp_tz, fp_lang) = fingerprint_locale(profile);
|
||||
let mut out = Vec::new();
|
||||
if fp_tz.is_none() {
|
||||
out.push("timezone".to_string());
|
||||
}
|
||||
if fp_lang.is_none() {
|
||||
out.push("language".to_string());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// True when measuring the exit can still verify at least one dimension of this
|
||||
/// profile's fingerprint. False means a probe would compare nothing, so telling
|
||||
/// the user "Donut will check it while starting" would be a promise it cannot
|
||||
/// keep.
|
||||
pub fn can_verify_anything(profile: &BrowserProfile) -> bool {
|
||||
unverifiable_dimensions(profile).len() < CHECKED_DIMENSIONS.len()
|
||||
}
|
||||
|
||||
/// Whether this upstream can carry a probe request at all.
|
||||
@@ -126,7 +205,7 @@ impl ConsistencyResult {
|
||||
/// rather than guessed at.
|
||||
fn probe_url(settings: &crate::browser::ProxySettings) -> Option<String> {
|
||||
match settings.proxy_type.to_lowercase().as_str() {
|
||||
"http" | "https" | "socks4" | "socks5" => Some(
|
||||
"http" | "https" | "httpstls" | "socks4" | "socks5" => Some(
|
||||
crate::proxy_manager::ProxyManager::build_probe_proxy_url(settings),
|
||||
),
|
||||
_ => None,
|
||||
@@ -141,32 +220,35 @@ fn probe_url(settings: &crate::browser::ProxySettings) -> Option<String> {
|
||||
/// any table naming one "expected" language per country flags fingerprints
|
||||
/// Donut itself produced — roughly 10% of US profiles legitimately get `es-US`
|
||||
/// and ~23% of Canadian ones get `fr-CA`. `None` means the country has no CLDR
|
||||
/// data and the check is skipped.
|
||||
/// data, so the language cannot be judged either way, the caller reports that
|
||||
/// dimension as unverified rather than counting it as a match.
|
||||
fn language_matches_country(cc: &str, language: &str) -> Option<bool> {
|
||||
crate::geolocation::locale_selector()?.region_speaks(cc, language)
|
||||
}
|
||||
|
||||
/// Extract (timezone, language) from a profile's stored location, or from its
|
||||
/// legacy fingerprint payload when it still stores one.
|
||||
///
|
||||
/// Read through `WayfernManager::fingerprint_object`, the same accessor the
|
||||
/// launcher uses to build the device it hands the browser, so both stored
|
||||
/// shapes, the bare object and the legacy `{ "fingerprint": {...} }` wrapper
|
||||
/// old profiles carry, are read identically on both sides. Reading only the
|
||||
/// top level here made a wrapped fingerprint report "declares no timezone",
|
||||
/// which sent the check down the not-compared path on exactly the profiles old
|
||||
/// enough to have the wrapper, while the launch presented the timezone nested
|
||||
/// one level down. Sharing the accessor is what keeps the two from drifting
|
||||
/// apart again.
|
||||
fn fingerprint_locale(profile: &BrowserProfile) -> (Option<String>, Option<String>) {
|
||||
let Some(config) = &profile.wayfern_config else {
|
||||
let Some(fp) = profile
|
||||
.wayfern_config
|
||||
.as_ref()
|
||||
.and_then(|config| config.location.as_deref().or(config.fingerprint.as_deref()))
|
||||
.and_then(crate::wayfern_manager::WayfernManager::fingerprint_object)
|
||||
else {
|
||||
return (None, None);
|
||||
};
|
||||
let Some(fp_str) = config.location.as_ref().or(config.fingerprint.as_ref()) else {
|
||||
return (None, None);
|
||||
};
|
||||
let Ok(fp) = serde_json::from_str::<serde_json::Value>(fp_str) else {
|
||||
return (None, None);
|
||||
};
|
||||
let timezone = fp
|
||||
.get("timezone")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::to_string);
|
||||
let language = fp
|
||||
.get("language")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::to_string);
|
||||
(timezone, language)
|
||||
let read = |key: &str| fp.get(key).and_then(|v| v.as_str()).map(str::to_string);
|
||||
(read("timezone"), read("language"))
|
||||
}
|
||||
|
||||
/// A mutex whose poison is not fatal.
|
||||
@@ -187,28 +269,58 @@ pub fn compare_exit_to_fingerprint(
|
||||
) -> ConsistencyResult {
|
||||
let (fp_tz, fp_lang) = fingerprint_locale(profile);
|
||||
let mut mismatches = Vec::new();
|
||||
let mut unverified = Vec::new();
|
||||
let mut compared = 0usize;
|
||||
|
||||
if let (Some(exit), Some(fp)) = (&exit_timezone, &fp_tz) {
|
||||
if !exit.eq_ignore_ascii_case(fp) {
|
||||
mismatches.push("timezone".to_string());
|
||||
// Three outcomes per dimension, never two. An exit whose timezone the
|
||||
// fingerprint does not declare is NOT agreement: nothing was compared, and
|
||||
// folding that into "consistent" is a green light this check has not earned.
|
||||
// "Not compared" is also a claim about the launch, not just about this
|
||||
// function: it is only honest because the launcher hands the browser no
|
||||
// timezone either (`wayfern_manager::launch_fingerprint_payload`). If it ever
|
||||
// starts supplying one again, that value is what this must compare against -
|
||||
// reporting "nothing was compared" while a location ships is the one outcome
|
||||
// neither side may produce.
|
||||
if let Some(exit) = &exit_timezone {
|
||||
match &fp_tz {
|
||||
Some(fp) => {
|
||||
compared += 1;
|
||||
if !exit.eq_ignore_ascii_case(fp) {
|
||||
mismatches.push("timezone".to_string());
|
||||
}
|
||||
}
|
||||
None => unverified.push("timezone".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
if let (Some(cc), Some(lang)) = (&exit_country_code, &fp_lang) {
|
||||
if language_matches_country(cc, lang) == Some(false) {
|
||||
mismatches.push("language".to_string());
|
||||
// Language has one extra way to be uncomparable: a country CLDR has no data
|
||||
// for answers `None`, which is no more a match than a missing fingerprint
|
||||
// language is.
|
||||
if let Some(cc) = &exit_country_code {
|
||||
match fp_lang
|
||||
.as_ref()
|
||||
.and_then(|lang| language_matches_country(cc, lang))
|
||||
{
|
||||
Some(plausible) => {
|
||||
compared += 1;
|
||||
if !plausible {
|
||||
mismatches.push("language".to_string());
|
||||
}
|
||||
}
|
||||
None => unverified.push("language".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
ConsistencyResult {
|
||||
consistent: mismatches.is_empty(),
|
||||
checked: true,
|
||||
checked: compared > 0,
|
||||
exit_ip,
|
||||
exit_country_code,
|
||||
exit_timezone,
|
||||
fingerprint_timezone: fp_tz,
|
||||
fingerprint_language: fp_lang,
|
||||
mismatches,
|
||||
unverified,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -223,6 +335,14 @@ fn cached_exit(key: &ExitCacheKey) -> Option<CachedExit> {
|
||||
.cloned()
|
||||
}
|
||||
|
||||
/// The exit IP the launch gate last measured for this profile's route, while
|
||||
/// it is still fresh. Cache-only: the gate probes on an interactive launch and
|
||||
/// an automation launch never probes, so a miss here is "unknown", not "direct".
|
||||
pub fn cached_exit_ip(profile: &BrowserProfile) -> Option<String> {
|
||||
let key = exit_cache_key(profile)?;
|
||||
cached_exit(&key).and_then(|cached| cached.ip)
|
||||
}
|
||||
|
||||
/// Cache-only check. Never performs I/O, so it is safe to call before a launch
|
||||
/// and for every profile in a bulk run. Returns an unchecked result on a miss.
|
||||
pub fn check_profile_consistency_cached(profile: &BrowserProfile) -> ConsistencyResult {
|
||||
@@ -593,8 +713,252 @@ mod tests {
|
||||
Some("DE".into()),
|
||||
None,
|
||||
);
|
||||
assert!(result.consistent);
|
||||
assert!(!result.is_mismatch());
|
||||
assert!(result.mismatches.is_empty());
|
||||
// ...but skipping every dimension is not a pass, and must not be dressed
|
||||
// as one.
|
||||
assert!(!result.is_verified());
|
||||
assert!(!result.checked);
|
||||
assert_eq!(result.unverified, vec!["timezone", "language"]);
|
||||
}
|
||||
|
||||
fn profile_with_raw_fingerprint(fingerprint: serde_json::Value) -> BrowserProfile {
|
||||
let mut profile = BrowserProfile {
|
||||
id: uuid::Uuid::new_v4(),
|
||||
name: "p".into(),
|
||||
browser: "wayfern".into(),
|
||||
..Default::default()
|
||||
};
|
||||
profile.wayfern_config = Some(crate::wayfern_manager::WayfernConfig {
|
||||
fingerprint: Some(fingerprint.to_string()),
|
||||
..Default::default()
|
||||
});
|
||||
profile
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fingerprint_with_no_timezone_is_unverified_never_a_match() {
|
||||
// The regression. Generation no longer invents `America/New_York` when the
|
||||
// geolocation probe fails, so a fingerprint can legitimately carry no
|
||||
// timezone. The comparison then has nothing to compare, and reporting that
|
||||
// as agreement is a green light the check never earned, on the one
|
||||
// dimension that carries the real signal.
|
||||
let profile = profile_with_raw_fingerprint(serde_json::json!({ "language": "de-DE" }));
|
||||
let result = compare_exit_to_fingerprint(
|
||||
&profile,
|
||||
Some("Europe/Berlin".into()),
|
||||
Some("DE".into()),
|
||||
Some("1.2.3.4".into()),
|
||||
);
|
||||
|
||||
assert!(
|
||||
result.unverified.contains(&"timezone".to_string()),
|
||||
"an undeclared timezone must be reported as unverified, got {result:?}"
|
||||
);
|
||||
assert!(
|
||||
!result.is_verified(),
|
||||
"nothing compared the timezone, so this must not read as consistent"
|
||||
);
|
||||
// The language WAS compared and agreed, so the exit counts as checked...
|
||||
assert!(result.checked);
|
||||
assert!(result.mismatches.is_empty());
|
||||
// ...but a dimension nobody compared is never a reason to stop a launch.
|
||||
assert!(!result.is_mismatch());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fingerprint_with_no_locale_at_all_is_not_checked() {
|
||||
// Both dimensions undeclared: the exit was reached, and still nothing was
|
||||
// compared. `checked` has to say so, because every consumer reads it as
|
||||
// "there is a measurement here worth acting on".
|
||||
let profile = profile_with_raw_fingerprint(serde_json::json!({ "platform": "Win32" }));
|
||||
let result = compare_exit_to_fingerprint(
|
||||
&profile,
|
||||
Some("Europe/Berlin".into()),
|
||||
Some("DE".into()),
|
||||
Some("1.2.3.4".into()),
|
||||
);
|
||||
assert!(!result.checked);
|
||||
assert!(!result.is_verified());
|
||||
assert!(!result.is_mismatch());
|
||||
assert_eq!(result.unverified, vec!["timezone", "language"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_legacy_wrapped_fingerprint_is_read_the_way_the_launcher_reads_it() {
|
||||
// The launcher accepts `{"fingerprint": {...}}` as well as the bare object,
|
||||
// so reading only the top level here answered "this profile declares no
|
||||
// timezone" for a profile whose launch presents one. The check then skipped
|
||||
// the dimension carrying the real signal, on exactly the profiles old
|
||||
// enough to still have the wrapper.
|
||||
let profile = profile_with_raw_fingerprint(serde_json::json!({
|
||||
"fingerprint": { "timezone": "America/New_York", "language": "en-US" }
|
||||
}));
|
||||
|
||||
assert_eq!(
|
||||
unverifiable_dimensions(&profile),
|
||||
Vec::<String>::new(),
|
||||
"a wrapped fingerprint declares both dimensions"
|
||||
);
|
||||
assert!(can_verify_anything(&profile));
|
||||
|
||||
let result = compare_exit_to_fingerprint(
|
||||
&profile,
|
||||
Some("Europe/Berlin".into()),
|
||||
Some("DE".into()),
|
||||
Some("1.2.3.4".into()),
|
||||
);
|
||||
assert_eq!(
|
||||
result.fingerprint_timezone.as_deref(),
|
||||
Some("America/New_York"),
|
||||
"the nested timezone must be the one compared, got {result:?}"
|
||||
);
|
||||
assert_eq!(result.fingerprint_language.as_deref(), Some("en-US"));
|
||||
assert!(result.checked);
|
||||
assert!(
|
||||
result.mismatches.contains(&"timezone".to_string()),
|
||||
"a US timezone behind a German exit must flag, got {result:?}"
|
||||
);
|
||||
assert!(result.is_mismatch());
|
||||
assert!(
|
||||
result.unverified.is_empty(),
|
||||
"both dimensions were declared, so nothing is unverified: {result:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_wrapped_fingerprint_with_no_timezone_is_still_unverified() {
|
||||
// The other half: unwrapping must not turn "declares nothing" into a pass.
|
||||
let profile = profile_with_raw_fingerprint(serde_json::json!({
|
||||
"fingerprint": { "language": "de-DE" }
|
||||
}));
|
||||
assert_eq!(unverifiable_dimensions(&profile), vec!["timezone"]);
|
||||
|
||||
let result = compare_exit_to_fingerprint(
|
||||
&profile,
|
||||
Some("Europe/Berlin".into()),
|
||||
Some("DE".into()),
|
||||
None,
|
||||
);
|
||||
assert_eq!(result.unverified, vec!["timezone"]);
|
||||
assert!(!result.is_verified());
|
||||
assert!(!result.is_mismatch());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_language_the_country_has_no_cldr_data_for_is_unverified() {
|
||||
// The other way a comparison can silently not happen. `ZZ` has no CLDR
|
||||
// entry, so the language was never judged; the timezone still was.
|
||||
let profile = profile_with_fingerprint("Europe/Berlin", "de-DE");
|
||||
let result = compare_exit_to_fingerprint(
|
||||
&profile,
|
||||
Some("Europe/Berlin".into()),
|
||||
Some("ZZ".into()),
|
||||
None,
|
||||
);
|
||||
assert!(result.checked, "the timezone was compared");
|
||||
assert_eq!(result.unverified, vec!["language"]);
|
||||
assert!(!result.is_verified());
|
||||
assert!(!result.is_mismatch());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_fully_compared_agreement_reads_as_verified() {
|
||||
let profile = profile_with_fingerprint("Europe/Berlin", "de-DE");
|
||||
let result = compare_exit_to_fingerprint(
|
||||
&profile,
|
||||
Some("Europe/Berlin".into()),
|
||||
Some("DE".into()),
|
||||
Some("1.2.3.4".into()),
|
||||
);
|
||||
assert!(result.is_verified());
|
||||
assert!(result.unverified.is_empty());
|
||||
assert!(!result.is_mismatch());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_measured_mismatch_is_still_the_only_blocking_state() {
|
||||
let profile = profile_with_fingerprint("America/New_York", "en-US");
|
||||
let result = compare_exit_to_fingerprint(
|
||||
&profile,
|
||||
Some("Europe/Berlin".into()),
|
||||
Some("DE".into()),
|
||||
Some("1.2.3.4".into()),
|
||||
);
|
||||
assert!(result.is_mismatch());
|
||||
assert!(!result.is_verified());
|
||||
// A verdict that blocks must not be diluted into "unverified".
|
||||
assert!(result.unverified.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skip_reads_as_neither_verified_nor_mismatched() {
|
||||
let result = ConsistencyResult::skip();
|
||||
assert!(!result.is_verified());
|
||||
assert!(!result.is_mismatch());
|
||||
assert!(result.unverified.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unverifiable_dimensions_are_answered_without_measuring_anything() {
|
||||
// Pure and local, so the pre-launch report can say "this cannot be checked"
|
||||
// before a single worker starts.
|
||||
assert_eq!(
|
||||
unverifiable_dimensions(&profile_with_fingerprint("Europe/Berlin", "de-DE")),
|
||||
Vec::<String>::new()
|
||||
);
|
||||
assert_eq!(
|
||||
unverifiable_dimensions(&profile_with_raw_fingerprint(
|
||||
serde_json::json!({ "language": "de-DE" })
|
||||
)),
|
||||
vec!["timezone"]
|
||||
);
|
||||
assert_eq!(
|
||||
unverifiable_dimensions(&profile_with_raw_fingerprint(
|
||||
serde_json::json!({ "platform": "Win32" })
|
||||
)),
|
||||
CHECKED_DIMENSIONS.to_vec()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_probe_that_could_compare_nothing_is_not_pending_work() {
|
||||
assert!(can_verify_anything(&profile_with_fingerprint(
|
||||
"Europe/Berlin",
|
||||
"de-DE"
|
||||
)));
|
||||
// One dimension left is still worth probing for.
|
||||
assert!(can_verify_anything(&profile_with_raw_fingerprint(
|
||||
serde_json::json!({ "language": "de-DE" })
|
||||
)));
|
||||
// Nothing left: promising the user the launch will check it would be a
|
||||
// promise the gate cannot keep.
|
||||
assert!(!can_verify_anything(&profile_with_raw_fingerprint(
|
||||
serde_json::json!({ "platform": "Win32" })
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unverified_dimension_survives_serialization_to_the_ui() {
|
||||
let profile = profile_with_raw_fingerprint(serde_json::json!({ "language": "de-DE" }));
|
||||
let result = compare_exit_to_fingerprint(
|
||||
&profile,
|
||||
Some("Europe/Berlin".into()),
|
||||
Some("DE".into()),
|
||||
None,
|
||||
);
|
||||
let encoded = serde_json::to_value(&result).expect("serializable");
|
||||
assert_eq!(encoded["unverified"], serde_json::json!(["timezone"]));
|
||||
// Older payloads without the field must still decode, defaulting to "we
|
||||
// were told nothing", not to a silent pass.
|
||||
let legacy: ConsistencyResult = serde_json::from_str(
|
||||
r#"{"consistent":true,"checked":false,"exit_ip":null,"exit_country_code":null,
|
||||
"exit_timezone":null,"fingerprint_timezone":null,"fingerprint_language":null,
|
||||
"mismatches":[]}"#,
|
||||
)
|
||||
.expect("legacy payloads stay decodable");
|
||||
assert!(legacy.unverified.is_empty());
|
||||
assert!(!legacy.is_verified());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -53,6 +53,15 @@ impl GeoIPDownloader {
|
||||
Ok(Self::get_cache_dir().join("GeoLite2-City.mmdb"))
|
||||
}
|
||||
|
||||
/// Where the autonomous-system database lives. It is the only MaxMind file
|
||||
/// that carries an organisation for an address, which is what a proxy check
|
||||
/// reports as the exit's ISP; the city database has no such field. Same
|
||||
/// release, same publisher, fetched by the same code — it is simply a second
|
||||
/// asset off the download the city database already comes from.
|
||||
pub fn get_asn_mmdb_file_path() -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
|
||||
Ok(Self::get_cache_dir().join("GeoLite2-ASN.mmdb"))
|
||||
}
|
||||
|
||||
pub fn is_geoip_database_available() -> bool {
|
||||
if let Ok(mmdb_path) = Self::get_mmdb_file_path() {
|
||||
mmdb_path.exists()
|
||||
@@ -99,12 +108,15 @@ impl GeoIPDownloader {
|
||||
}
|
||||
|
||||
fn find_city_mmdb_asset(&self, release: &GithubRelease) -> Option<String> {
|
||||
for asset in &release.assets {
|
||||
if asset.name.ends_with("-City.mmdb") {
|
||||
return Some(asset.browser_download_url.clone());
|
||||
}
|
||||
}
|
||||
None
|
||||
Self::find_mmdb_asset(release, "-City.mmdb")
|
||||
}
|
||||
|
||||
fn find_mmdb_asset(release: &GithubRelease, suffix: &str) -> Option<String> {
|
||||
release
|
||||
.assets
|
||||
.iter()
|
||||
.find(|asset| asset.name.ends_with(suffix))
|
||||
.map(|asset| asset.browser_download_url.clone())
|
||||
}
|
||||
|
||||
pub async fn download_geoip_database(
|
||||
@@ -148,14 +160,26 @@ impl GeoIPDownloader {
|
||||
#[cfg(not(feature = "e2e"))]
|
||||
let fixture_url: Option<String> = None;
|
||||
|
||||
let download_url = if let Some(url) = fixture_url {
|
||||
url
|
||||
#[cfg(feature = "e2e")]
|
||||
let asn_fixture_url = std::env::var("DONUT_E2E_GEOIP_ASN_DOWNLOAD_URL")
|
||||
.ok()
|
||||
.filter(|url| !url.is_empty());
|
||||
#[cfg(not(feature = "e2e"))]
|
||||
let asn_fixture_url: Option<String> = None;
|
||||
|
||||
// The ASN asset comes off the same release as the city one, so the release
|
||||
// is kept rather than looked up twice.
|
||||
let (download_url, asn_url) = if let Some(url) = fixture_url {
|
||||
(url, asn_fixture_url)
|
||||
} else {
|
||||
let releases = self.fetch_geoip_releases().await?;
|
||||
let latest_release = releases.first().ok_or("No GeoIP database releases found")?;
|
||||
self
|
||||
.find_city_mmdb_asset(latest_release)
|
||||
.ok_or("No compatible GeoIP database asset found")?
|
||||
(
|
||||
self
|
||||
.find_city_mmdb_asset(latest_release)
|
||||
.ok_or("No compatible GeoIP database asset found")?,
|
||||
Self::find_mmdb_asset(latest_release, "-ASN.mmdb"),
|
||||
)
|
||||
};
|
||||
|
||||
// Create cache directory
|
||||
@@ -250,6 +274,15 @@ impl GeoIPDownloader {
|
||||
.as_secs();
|
||||
let _ = fs::write(×tamp_path, now.to_string()).await;
|
||||
|
||||
// The autonomous-system database, best effort. It only feeds the exit
|
||||
// organisation a proxy check reports, so a failure here must never fail
|
||||
// the download that fingerprint geolocation actually depends on.
|
||||
if let Some(url) = asn_url {
|
||||
if let Err(e) = self.download_asn_database(&url).await {
|
||||
log::warn!("Failed to download the GeoIP ASN database: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
// Emit completion
|
||||
let _ = events::emit(
|
||||
"geoip-download-progress",
|
||||
@@ -267,6 +300,34 @@ impl GeoIPDownloader {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Fetch the ASN database to a temp file and rename it into place, so a
|
||||
/// half-written file is never left where a lookup would read it.
|
||||
async fn download_asn_database(
|
||||
&self,
|
||||
url: &str,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let path = Self::get_asn_mmdb_file_path()?;
|
||||
let temp_path = path.with_extension("mmdb.downloading");
|
||||
let _ = fs::remove_file(&temp_path).await;
|
||||
|
||||
let response = self.client.get(url).send().await?;
|
||||
if !response.status().is_success() {
|
||||
return Err(format!("HTTP {}", response.status()).into());
|
||||
}
|
||||
|
||||
let mut file = fs::File::create(&temp_path).await?;
|
||||
let mut stream = response.bytes_stream();
|
||||
use futures_util::StreamExt;
|
||||
while let Some(chunk) = stream.next().await {
|
||||
file.write_all(&chunk?).await?;
|
||||
}
|
||||
file.flush().await?;
|
||||
drop(file);
|
||||
|
||||
fs::rename(&temp_path, &path).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn fetch_geoip_releases(
|
||||
&self,
|
||||
) -> Result<Vec<GithubRelease>, Box<dyn std::error::Error + Send + Sync>> {
|
||||
|
||||
@@ -261,6 +261,84 @@ fn normalize_locale(locale: &str) -> Locale {
|
||||
Locale { language, region }
|
||||
}
|
||||
|
||||
/// What the bundled MaxMind data says about an exit address, beyond the
|
||||
/// city and country a check already reports.
|
||||
///
|
||||
/// Everything here is read from the databases already on disk. A proxy check
|
||||
/// must not hand the exit address to a third-party lookup service: that would
|
||||
/// tell an outside party which addresses this machine is testing, which is the
|
||||
/// opposite of what the proxy is for.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct ExitInsight {
|
||||
pub timezone: Option<String>,
|
||||
/// The ISP, the registered organisation, or the autonomous system's
|
||||
/// organisation, whichever the installed databases carry. `None` means "not
|
||||
/// known", never "none".
|
||||
pub organization: Option<String>,
|
||||
}
|
||||
|
||||
/// Read timezone and organisation for `ip` out of the local databases.
|
||||
///
|
||||
/// Never fails: a missing database, an unroutable address or a record without
|
||||
/// the field all resolve to `None`, because "unknown" is the honest answer and
|
||||
/// a check should still report everything else it learned.
|
||||
pub fn lookup_exit_insight(ip: &str) -> ExitInsight {
|
||||
let Ok(ip_addr) = IpAddr::from_str(ip) else {
|
||||
return ExitInsight::default();
|
||||
};
|
||||
|
||||
let mut insight = ExitInsight::default();
|
||||
|
||||
if let Ok(path) = GeoIPDownloader::get_mmdb_file_path() {
|
||||
if let Ok(reader) = Reader::open_readfile(&path) {
|
||||
if let Ok(lookup) = reader.lookup(ip_addr) {
|
||||
if let Ok(Some(city)) = lookup.decode::<geoip2::City>() {
|
||||
insight.timezone = city.location.time_zone.map(|tz| tz.to_string());
|
||||
}
|
||||
}
|
||||
// The City database carries no organisation, but the same reader decodes
|
||||
// one when the file in place is an ISP or Enterprise database instead.
|
||||
if let Ok(lookup) = reader.lookup(ip_addr) {
|
||||
if let Ok(Some(isp)) = lookup.decode::<geoip2::Isp>() {
|
||||
insight.organization = first_non_empty([
|
||||
isp.isp,
|
||||
isp.organization,
|
||||
isp.autonomous_system_organization,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if insight.organization.is_none() {
|
||||
insight.organization = lookup_asn_organization(ip_addr);
|
||||
}
|
||||
|
||||
insight
|
||||
}
|
||||
|
||||
/// The autonomous system's organisation, from the ASN database that ships
|
||||
/// alongside the city one. Absent on an install that has only ever fetched the
|
||||
/// city database, which is why the caller treats `None` as "unknown".
|
||||
fn lookup_asn_organization(ip_addr: IpAddr) -> Option<String> {
|
||||
let path = GeoIPDownloader::get_asn_mmdb_file_path().ok()?;
|
||||
if !path.exists() {
|
||||
return None;
|
||||
}
|
||||
let reader = Reader::open_readfile(&path).ok()?;
|
||||
let asn: geoip2::Asn = reader.lookup(ip_addr).ok()?.decode().ok()??;
|
||||
first_non_empty([asn.autonomous_system_organization])
|
||||
}
|
||||
|
||||
fn first_non_empty<const N: usize>(candidates: [Option<&str>; N]) -> Option<String> {
|
||||
candidates
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(str::trim)
|
||||
.find(|value| !value.is_empty())
|
||||
.map(|value| value.to_string())
|
||||
}
|
||||
|
||||
pub fn get_geolocation(ip: &str) -> Result<Geolocation, GeolocationError> {
|
||||
let mmdb_path =
|
||||
GeoIPDownloader::get_mmdb_file_path().map_err(|_| GeolocationError::DatabaseNotFound)?;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,11 +4,16 @@ use std::fs;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use crate::events;
|
||||
use crate::group_bookmarks::GroupBookmark;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ProfileGroup {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
/// Bookmarks every profile in this group carries. Written into the profile's
|
||||
/// Chromium `Bookmarks` file before each launch; see `group_bookmarks`.
|
||||
#[serde(default)]
|
||||
pub bookmarks: Vec<GroupBookmark>,
|
||||
#[serde(default)]
|
||||
pub sync_enabled: bool,
|
||||
#[serde(default)]
|
||||
@@ -25,6 +30,8 @@ pub struct GroupWithCount {
|
||||
pub name: String,
|
||||
pub count: usize,
|
||||
#[serde(default)]
|
||||
pub bookmark_count: usize,
|
||||
#[serde(default)]
|
||||
pub sync_enabled: bool,
|
||||
#[serde(default)]
|
||||
pub last_sync: Option<u64>,
|
||||
@@ -104,6 +111,7 @@ impl GroupManager {
|
||||
let group = ProfileGroup {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
name,
|
||||
bookmarks: Vec::new(),
|
||||
sync_enabled,
|
||||
last_sync: None,
|
||||
updated_at: Some(crate::proxy_manager::now_secs()),
|
||||
@@ -195,6 +203,7 @@ impl GroupManager {
|
||||
|
||||
if let Some(existing) = groups_data.groups.iter_mut().find(|g| g.id == group.id) {
|
||||
existing.name = group.name.clone();
|
||||
existing.bookmarks = group.bookmarks.clone();
|
||||
existing.sync_enabled = group.sync_enabled;
|
||||
existing.last_sync = group.last_sync;
|
||||
existing.updated_at = group.updated_at;
|
||||
@@ -212,6 +221,7 @@ impl GroupManager {
|
||||
|
||||
if let Some(existing) = groups_data.groups.iter_mut().find(|g| g.id == group.id) {
|
||||
existing.name = group.name.clone();
|
||||
existing.bookmarks = group.bookmarks.clone();
|
||||
existing.sync_enabled = group.sync_enabled;
|
||||
existing.last_sync = group.last_sync;
|
||||
existing.updated_at = group.updated_at;
|
||||
@@ -294,6 +304,47 @@ impl GroupManager {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Replace a group's shared bookmark list.
|
||||
///
|
||||
/// Bumps `updated_at` because this is a real user edit, which is what sync's
|
||||
/// last-write-wins reconcile reads; `last_sync` is bookkeeping and must not
|
||||
/// decide direction.
|
||||
pub fn set_group_bookmarks(
|
||||
&self,
|
||||
_app_handle: &tauri::AppHandle,
|
||||
id: &str,
|
||||
bookmarks: Vec<GroupBookmark>,
|
||||
) -> Result<ProfileGroup, Box<dyn std::error::Error>> {
|
||||
let mut groups_data = self.load_groups_data()?;
|
||||
|
||||
let group = groups_data
|
||||
.groups
|
||||
.iter_mut()
|
||||
.find(|g| g.id == id)
|
||||
.ok_or_else(|| serde_json::json!({ "code": "GROUP_NOT_FOUND" }).to_string())?;
|
||||
|
||||
group.bookmarks = bookmarks;
|
||||
group.updated_at = Some(crate::proxy_manager::now_secs());
|
||||
let updated_group = group.clone();
|
||||
|
||||
self.save_groups_data(&groups_data)?;
|
||||
|
||||
if let Err(e) = events::emit_empty("groups-changed") {
|
||||
log::error!("Failed to emit groups-changed event: {e}");
|
||||
}
|
||||
|
||||
if updated_group.sync_enabled {
|
||||
if let Some(scheduler) = crate::sync::get_global_scheduler() {
|
||||
let id = updated_group.id.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
scheduler.queue_group_sync(id).await;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(updated_group)
|
||||
}
|
||||
|
||||
pub fn get_groups_with_profile_counts(
|
||||
&self,
|
||||
profiles: &[crate::profile::BrowserProfile],
|
||||
@@ -318,6 +369,7 @@ impl GroupManager {
|
||||
id: group.id,
|
||||
name: group.name,
|
||||
count,
|
||||
bookmark_count: group.bookmarks.len(),
|
||||
sync_enabled: group.sync_enabled,
|
||||
last_sync: group.last_sync,
|
||||
});
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
//! The pre-spawn launch gate.
|
||||
//! The pre-spawn gate.
|
||||
//!
|
||||
//! Runs on every real browser spawn, after the upstream has been normalized
|
||||
//! and before any worker, decrypted copy or browser process exists. It answers
|
||||
//! one question — may this launch proceed — and changes nothing else; the
|
||||
//! launch path does its own preparation (the group's bookmarks, the blocklist)
|
||||
//! around it.
|
||||
//!
|
||||
//! Two findings can stop a launch being what the user expects:
|
||||
//!
|
||||
@@ -126,11 +132,46 @@ fn mismatch_error(result: &ConsistencyResult, token: &str) -> String {
|
||||
"fingerprintTimezone": result.fingerprint_timezone.clone().unwrap_or_default(),
|
||||
"fingerprintLanguage": result.fingerprint_language.clone().unwrap_or_default(),
|
||||
"mismatches": result.mismatches.join(","),
|
||||
"unverified": result.unverified.join(","),
|
||||
}
|
||||
})
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Say which of the three states a non-blocking check landed in.
|
||||
///
|
||||
/// Only a measured disagreement stops a launch. The other two both continue -
|
||||
/// and telling them apart is the whole point, because "the exit and the
|
||||
/// fingerprint agree" and "nothing was compared" are different claims and the
|
||||
/// second used to be reported as the first.
|
||||
///
|
||||
/// Reaching an exit and finding nothing to compare is deliberately not a block:
|
||||
/// a fingerprint whose geolocation probe failed carries no location at all,
|
||||
/// since generation stopped inventing one, and refusing to start those profiles
|
||||
/// would break profiles that are legitimately in that state. It is not a pass
|
||||
/// either, so the launch says what it could not check.
|
||||
fn report_consistency(profile: &BrowserProfile, result: &ConsistencyResult) {
|
||||
if result.is_verified() {
|
||||
log::debug!(
|
||||
"Fingerprint gate: {} agrees with its exit on every dimension",
|
||||
profile.name
|
||||
);
|
||||
return;
|
||||
}
|
||||
if result.unverified.is_empty() {
|
||||
return;
|
||||
}
|
||||
log::warn!(
|
||||
"Fingerprint gate: {} reached its exit but could not verify {}; \
|
||||
the fingerprint declares no value to compare against",
|
||||
profile.name,
|
||||
result.unverified.join(", ")
|
||||
);
|
||||
if let Err(e) = crate::events::emit("fingerprint-consistency-unverified", result) {
|
||||
log::warn!("Failed to emit fingerprint consistency notice: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
fn gate_disabled() -> bool {
|
||||
crate::settings_manager::SettingsManager::instance()
|
||||
.load_settings()
|
||||
@@ -180,7 +221,8 @@ async fn enforce_direct_exit(
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
if !result.checked || result.consistent {
|
||||
if !result.is_mismatch() {
|
||||
report_consistency(profile, &result);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
@@ -188,8 +230,7 @@ async fn enforce_direct_exit(
|
||||
Err(mismatch_error(&result, &token))
|
||||
}
|
||||
|
||||
/// The enforcing gate. Called from the launch pipeline once the upstream is
|
||||
/// normalized and before anything expensive or user-visible happens.
|
||||
/// The pre-spawn stage: prepare the profile, then gate the launch.
|
||||
///
|
||||
/// Fails **open** on every degradation — probe failure, timeout, missing geo
|
||||
/// database, private exit IP. The gate blocks only on a positively measured
|
||||
@@ -260,7 +301,10 @@ pub async fn enforce_fingerprint_gate(
|
||||
}
|
||||
};
|
||||
|
||||
if !result.checked || result.consistent {
|
||||
if !result.is_mismatch() {
|
||||
// A mismatch carries its own report, and the dialog it opens already lists
|
||||
// whatever went uncompared alongside it.
|
||||
report_consistency(profile, &result);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
@@ -294,9 +338,15 @@ pub struct PreLaunchChecks {
|
||||
pub scan_state: String,
|
||||
/// Cache-only; `checked` is false when the exit has not been measured yet.
|
||||
pub consistency: ConsistencyResult,
|
||||
/// True when the enforcing gate will still probe during the launch, so the
|
||||
/// UI can say the check is not finished rather than implying it passed.
|
||||
/// True when the enforcing gate will still probe during the launch AND that
|
||||
/// probe can actually compare something, so the UI can say the check is not
|
||||
/// finished rather than implying it passed.
|
||||
pub exit_probe_pending: bool,
|
||||
/// Dimensions no probe can ever verify for this profile, because its
|
||||
/// fingerprint declares no value to compare. Answered locally, with no
|
||||
/// measurement. Informational and never a block, but the launch must not
|
||||
/// read as verified on a dimension nothing will compare.
|
||||
pub exit_unverified: Vec<String>,
|
||||
/// An extension holding the `proxy` permission is present, so any exit
|
||||
/// measurement describes a route the browser may not take. Informational
|
||||
/// only — it never relaxes the block.
|
||||
@@ -357,12 +407,22 @@ pub async fn get_profile_pre_launch_checks(profile_id: String) -> Result<PreLaun
|
||||
.as_ref()
|
||||
.is_some_and(|k| crate::launch_gate_prefs::fingerprint_ack_matches(&profile, &k.identity));
|
||||
|
||||
let blocking = consistency.checked && !consistency.consistent && !already_acked;
|
||||
let blocking = consistency.is_mismatch() && !already_acked;
|
||||
let consent_token = match (&key, blocking) {
|
||||
(Some(k), true) => Some(mint_consent(&profile, &k.identity)),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
// Only meaningful when an exit check is going to happen at all: an
|
||||
// acknowledged profile, a disabled gate, or a profile with no route never
|
||||
// measures an exit, so there is nothing it failed to verify.
|
||||
let gate_will_measure = !disabled && !already_acked && key.is_some();
|
||||
let exit_unverified = if gate_will_measure {
|
||||
fingerprint_consistency::unverifiable_dimensions(&profile)
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
Ok(PreLaunchChecks {
|
||||
vpn_extensions,
|
||||
scan_state: scan.scan_state,
|
||||
@@ -371,7 +431,14 @@ pub async fn get_profile_pre_launch_checks(profile_id: String) -> Result<PreLaun
|
||||
} else {
|
||||
ConsistencyResult::skip()
|
||||
},
|
||||
exit_probe_pending: !disabled && !already_acked && key.is_some() && !blocking,
|
||||
// A probe that can compare nothing is not pending work. Reporting it as
|
||||
// pending promises the user Donut "will check it while starting and stop if
|
||||
// it doesn't match", which is the same unearned assurance in a second
|
||||
// costume.
|
||||
exit_probe_pending: gate_will_measure
|
||||
&& !blocking
|
||||
&& fingerprint_consistency::can_verify_anything(&profile),
|
||||
exit_unverified,
|
||||
exit_measurement_unreliable,
|
||||
consent_token,
|
||||
})
|
||||
@@ -484,6 +551,7 @@ mod tests {
|
||||
fingerprint_timezone: Some("America/New_York".into()),
|
||||
fingerprint_language: Some("en-US".into()),
|
||||
mismatches: vec!["timezone".into(), "language".into()],
|
||||
unverified: Vec::new(),
|
||||
};
|
||||
let encoded = mismatch_error(&result, "tok");
|
||||
let parsed: serde_json::Value = serde_json::from_str(&encoded).unwrap();
|
||||
@@ -493,6 +561,69 @@ mod tests {
|
||||
assert_eq!(parsed["params"]["fingerprintTimezone"], "America/New_York");
|
||||
// params values must be strings for the frontend's interpolation.
|
||||
assert_eq!(parsed["params"]["mismatches"], "timezone,language");
|
||||
assert_eq!(parsed["params"]["unverified"], "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mismatch_error_carries_what_it_could_not_verify_too() {
|
||||
// One dimension disagreed and the other was never compared. The dialog
|
||||
// rebuilds its finding from these params, so dropping `unverified` here
|
||||
// would make the rebuilt result claim a clean bill on a dimension nothing
|
||||
// looked at.
|
||||
let result = ConsistencyResult {
|
||||
consistent: false,
|
||||
checked: true,
|
||||
exit_ip: Some("1.2.3.4".into()),
|
||||
exit_country_code: Some("DE".into()),
|
||||
exit_timezone: Some("Europe/Berlin".into()),
|
||||
fingerprint_timezone: Some("America/New_York".into()),
|
||||
fingerprint_language: None,
|
||||
mismatches: vec!["timezone".into()],
|
||||
unverified: vec!["language".into()],
|
||||
};
|
||||
let parsed: serde_json::Value = serde_json::from_str(&mismatch_error(&result, "tok")).unwrap();
|
||||
assert_eq!(parsed["params"]["mismatches"], "timezone");
|
||||
assert_eq!(parsed["params"]["unverified"], "language");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unverified_dimension_is_never_a_mismatch_and_never_blocks() {
|
||||
// The N7 shape at the gate: an exit was reached, the fingerprint declares
|
||||
// no timezone, so nothing was compared. That must not stop a launch...
|
||||
let result = ConsistencyResult {
|
||||
consistent: true,
|
||||
checked: false,
|
||||
exit_ip: Some("1.2.3.4".into()),
|
||||
exit_country_code: Some("DE".into()),
|
||||
exit_timezone: Some("Europe/Berlin".into()),
|
||||
fingerprint_timezone: None,
|
||||
fingerprint_language: None,
|
||||
mismatches: Vec::new(),
|
||||
unverified: vec!["timezone".into(), "language".into()],
|
||||
};
|
||||
assert!(
|
||||
!result.is_mismatch(),
|
||||
"an unverified dimension must not block"
|
||||
);
|
||||
// ...and must not be reported as a clean check either.
|
||||
assert!(!result.is_verified());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_profile_that_can_verify_nothing_reports_no_pending_probe() {
|
||||
// `exit_probe_pending` promises the user the launch "will check it while
|
||||
// starting and stop if it doesn't match". A fingerprint with no locale at
|
||||
// all leaves the probe nothing to compare, so that promise cannot be kept.
|
||||
let bare = profile_with(r#"{"platform":"Win32"}"#);
|
||||
assert!(!fingerprint_consistency::can_verify_anything(&bare));
|
||||
assert_eq!(
|
||||
fingerprint_consistency::unverifiable_dimensions(&bare),
|
||||
vec!["timezone", "language"]
|
||||
);
|
||||
|
||||
let located = profile_with(r#"{"timezone":"Europe/Berlin","language":"de-DE"}"#);
|
||||
assert!(fingerprint_consistency::can_verify_anything(&located));
|
||||
assert!(fingerprint_consistency::unverifiable_dimensions(&located).is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
+974
-167
File diff suppressed because it is too large
Load Diff
@@ -8,8 +8,15 @@ static PRIVATE_KEY_RE: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(r"(?is)-----BEGIN [^-\r\n]*PRIVATE KEY-----.*?-----END [^-\r\n]*PRIVATE KEY-----")
|
||||
.expect("valid private-key regex")
|
||||
});
|
||||
static BEARER_RE: LazyLock<Regex> =
|
||||
LazyLock::new(|| Regex::new(r"(?i)\bBearer\s+[A-Za-z0-9._~+/=-]+").expect("valid bearer regex"));
|
||||
/// Every HTTP auth scheme that carries its credential as a single token after
|
||||
/// the scheme name, not just `Bearer`. SECRET_RE cannot reach these: its value
|
||||
/// class stops at the space between the scheme and the credential, so a
|
||||
/// `Basic`/`NTLM` blob used to survive into an exported log verbatim. Digest's
|
||||
/// quoted-parameter form (`response="..."`) is out of scope.
|
||||
static AUTH_SCHEME_RE: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(r"(?i)\b(Bearer|Basic|Token|Digest|Negotiate|NTLM)\s+[A-Za-z0-9._~+/=-]+")
|
||||
.expect("valid auth-scheme regex")
|
||||
});
|
||||
static SECRET_RE: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(
|
||||
r"(?i)\b(api[_-]?key|authorization|password|passwd|private[_-]?key|proxy[_-]?(password|username)|refresh[_-]?token|secret|token|username)\b\s*[:=]\s*[^\s,;]+",
|
||||
@@ -41,7 +48,9 @@ pub fn url_label(value: &str) -> String {
|
||||
pub fn text(value: &str) -> String {
|
||||
let redacted = PRIVATE_KEY_RE.replace_all(value, "<redacted-private-key>");
|
||||
let redacted = URL_RE.replace_all(&redacted, "<redacted-url>");
|
||||
let redacted = BEARER_RE.replace_all(&redacted, "Bearer <redacted-secret>");
|
||||
// Must stay ahead of SECRET_RE, which would otherwise consume
|
||||
// `Authorization: Basic` and leave the credential with no scheme to match.
|
||||
let redacted = AUTH_SCHEME_RE.replace_all(&redacted, "${1} <redacted-secret>");
|
||||
let redacted = SECRET_RE.replace_all(&redacted, "<redacted-secret>");
|
||||
let redacted = EMAIL_RE.replace_all(&redacted, "<redacted-email>");
|
||||
let redacted = UNIX_HOME_RE.replace_all(&redacted, "/<redacted-home>");
|
||||
@@ -85,6 +94,28 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redacts_non_bearer_authorization_credentials() {
|
||||
let headers = [
|
||||
("Authorization: Basic ", "dXNlcjpwYXNzd29yZA=="),
|
||||
("Proxy-Authorization: Basic ", "cHJveHk6c2VjcmV0"),
|
||||
("Authorization: Token ", "gh_example_credential"),
|
||||
("authorization: bearer ", "lower-case-credential"),
|
||||
("WWW-Authenticate: NTLM ", "TlRMTVNTUAAB"),
|
||||
];
|
||||
for (header, credential) in headers {
|
||||
let output = text(&format!("{header}{credential}"));
|
||||
assert!(
|
||||
!output.contains(credential),
|
||||
"log output leaked {credential}"
|
||||
);
|
||||
}
|
||||
|
||||
// The scheme survives wherever the header name is not itself redacted, so a
|
||||
// log still says which kind of authentication was in play.
|
||||
assert!(text("WWW-Authenticate: NTLM TlRMTVNTUAAB").contains("NTLM"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn url_labels_retain_only_the_scheme() {
|
||||
assert_eq!(
|
||||
|
||||
+1697
-365
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+6003
-380
File diff suppressed because it is too large
Load Diff
@@ -4,38 +4,87 @@ use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
/// True if a process command line refers to `profile_path` as a real browser
|
||||
/// profile/data-dir argument, NOT merely a substring. A bare `contains` match
|
||||
/// force-killed unrelated processes that happened to mention the path (editors,
|
||||
/// `tail`, a terminal that `cd`'d there, or another profile whose path has this
|
||||
/// one as a prefix). Mirrors the precise matching in browser_runner/wayfern_manager.
|
||||
/// profile/data-dir argument. Only the `--user-data-dir=<path>` /
|
||||
/// `-profile=<path>` flag form counts, because the results feed a SIGKILL loop:
|
||||
/// a substring match, or the path accepted as a standalone argv token, also
|
||||
/// caught unrelated processes that legitimately name the directory (`du -sh
|
||||
/// <profile>`, `tar czf backup.tgz <profile>`, an editor, a sibling profile
|
||||
/// whose path has this one as a prefix). `browser.rs` only ever emits the flag
|
||||
/// form, so nothing Donut launches is missed.
|
||||
///
|
||||
/// Only the macOS and Linux process-kill paths use this; Windows has no
|
||||
/// `find_processes_by_profile_path`, so gate it to avoid a dead-code error there.
|
||||
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||
fn cmd_matches_profile_path(cmd: &[std::ffi::OsString], profile_path: &str) -> bool {
|
||||
let args: Vec<&str> = cmd.iter().filter_map(|a| a.to_str()).collect();
|
||||
for (i, arg) in args.iter().enumerate() {
|
||||
// Exact argument equality (some launchers pass the path as its own arg).
|
||||
if *arg == profile_path {
|
||||
return true;
|
||||
}
|
||||
// `--user-data-dir=<path>` (Chromium/Wayfern) or `-profile=<path>`.
|
||||
if let Some(val) = arg
|
||||
cmd.iter().filter_map(|a| a.to_str()).any(|arg| {
|
||||
arg
|
||||
.strip_prefix("--user-data-dir=")
|
||||
.or_else(|| arg.strip_prefix("-profile="))
|
||||
{
|
||||
if val == profile_path {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// Flag followed by the path as the next argument.
|
||||
if (*arg == "-profile" || *arg == "--user-data-dir")
|
||||
&& args.get(i + 1).is_some_and(|next| *next == profile_path)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
.is_some_and(|val| val == profile_path)
|
||||
})
|
||||
}
|
||||
|
||||
/// The profile sweep only ever wants browser processes. Every other sweep in
|
||||
/// the app (`wayfern_manager`, `profile::manager`) already filters on the
|
||||
/// executable name; without it a stray match here becomes a SIGKILL on an
|
||||
/// unrelated process.
|
||||
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||
fn is_browser_process_name(name: &std::ffi::OsStr) -> bool {
|
||||
let exe_name = name.to_string_lossy().to_lowercase();
|
||||
exe_name.contains("wayfern") || exe_name.contains("chromium") || exe_name.contains("chrome")
|
||||
}
|
||||
|
||||
#[cfg(all(test, any(target_os = "macos", target_os = "linux")))]
|
||||
mod profile_path_match_tests {
|
||||
use super::{cmd_matches_profile_path, is_browser_process_name};
|
||||
use std::ffi::{OsStr, OsString};
|
||||
|
||||
fn cmd(args: &[&str]) -> Vec<OsString> {
|
||||
args.iter().map(OsString::from).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bare_path_argument_does_not_match() {
|
||||
let profile = "/tmp/donut/profiles/work";
|
||||
assert!(!cmd_matches_profile_path(
|
||||
&cmd(&["du", "-sh", profile]),
|
||||
profile
|
||||
));
|
||||
assert!(!cmd_matches_profile_path(
|
||||
&cmd(&["tar", "czf", "backup.tgz", profile]),
|
||||
profile
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_data_dir_flag_matches() {
|
||||
let profile = "/tmp/donut/profiles/work";
|
||||
assert!(cmd_matches_profile_path(
|
||||
&cmd(&["wayfern", &format!("--user-data-dir={profile}")]),
|
||||
profile
|
||||
));
|
||||
assert!(cmd_matches_profile_path(
|
||||
&cmd(&["wayfern", &format!("-profile={profile}")]),
|
||||
profile
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sibling_profile_prefix_does_not_match() {
|
||||
let profile = "/tmp/donut/profiles/work";
|
||||
assert!(!cmd_matches_profile_path(
|
||||
&cmd(&["wayfern", "--user-data-dir=/tmp/donut/profiles/work-2"]),
|
||||
profile
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_browser_executables_are_swept() {
|
||||
assert!(is_browser_process_name(OsStr::new("Wayfern Helper")));
|
||||
assert!(is_browser_process_name(OsStr::new("chromium")));
|
||||
assert!(!is_browser_process_name(OsStr::new("du")));
|
||||
assert!(!is_browser_process_name(OsStr::new("rsync")));
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
// Platform-specific modules
|
||||
@@ -197,6 +246,10 @@ pub mod macos {
|
||||
continue;
|
||||
}
|
||||
|
||||
if !is_browser_process_name(process.name()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if cmd_matches_profile_path(cmd, profile_path) {
|
||||
pids.push(pid.as_u32());
|
||||
}
|
||||
@@ -704,6 +757,10 @@ pub mod linux {
|
||||
continue;
|
||||
}
|
||||
|
||||
if !is_browser_process_name(process.name()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if cmd_matches_profile_path(cmd, profile_path) {
|
||||
pids.push(pid.as_u32());
|
||||
}
|
||||
|
||||
@@ -20,6 +20,10 @@ const PROFILE_KEEP: &[&str] = &[
|
||||
"Extension Scripts",
|
||||
"Extension Cookies",
|
||||
"Local Extension Settings",
|
||||
// Backs chrome.storage.sync for every extension. Without a Chrome Sync
|
||||
// account this directory is the only copy of that data, so wiping it resets
|
||||
// every extension to defaults on each close.
|
||||
"Sync Extension Settings",
|
||||
"Managed Extension Settings",
|
||||
// Preferences hold the extension registry + user settings; deleting them
|
||||
// disables every installed extension, so they stay.
|
||||
@@ -149,6 +153,33 @@ mod tests {
|
||||
fs::create_dir_all(dir.join(name)).unwrap();
|
||||
}
|
||||
|
||||
/// The per-extension settings stores. Wiping any of them resets every
|
||||
/// installed extension to its defaults on the next launch.
|
||||
const EXTENSION_STORES: [&str; 3] = [
|
||||
"Local Extension Settings",
|
||||
"Sync Extension Settings",
|
||||
"Managed Extension Settings",
|
||||
];
|
||||
|
||||
fn seed_extension_stores(dir: &Path) {
|
||||
for store in EXTENSION_STORES {
|
||||
let store_dir = dir.join(store).join("abcdefghijklmnop");
|
||||
fs::create_dir_all(&store_dir).unwrap();
|
||||
fs::write(store_dir.join("000003.ldb"), "settings").unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_extension_stores_survived(dir: &Path) {
|
||||
for store in EXTENSION_STORES {
|
||||
let file = dir.join(store).join("abcdefghijklmnop").join("000003.ldb");
|
||||
assert_eq!(
|
||||
fs::read_to_string(&file).ok().as_deref(),
|
||||
Some("settings"),
|
||||
"{store} must survive clear-on-close"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clears_root_profile_layout_keeping_extensions_and_bookmarks() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
@@ -160,7 +191,7 @@ mod tests {
|
||||
touch(dir, "Web Data");
|
||||
touch(dir, "Login Data");
|
||||
mkdir(dir, "Extensions");
|
||||
mkdir(dir, "Local Extension Settings");
|
||||
seed_extension_stores(dir);
|
||||
mkdir(dir, "Cache");
|
||||
mkdir(dir, "Network");
|
||||
touch(&dir.join("Network"), "Cookies");
|
||||
@@ -175,7 +206,7 @@ mod tests {
|
||||
assert!(dir.join("Preferences").exists());
|
||||
assert!(dir.join("Bookmarks").exists());
|
||||
assert!(dir.join("Extensions").exists());
|
||||
assert!(dir.join("Local Extension Settings").exists());
|
||||
assert_extension_stores_survived(dir);
|
||||
assert!(!dir.join("History").exists());
|
||||
assert!(!dir.join("Web Data").exists());
|
||||
assert!(!dir.join("Login Data").exists());
|
||||
@@ -196,6 +227,7 @@ mod tests {
|
||||
touch(&default, "Bookmarks");
|
||||
touch(&default, "History");
|
||||
mkdir(&default, "Extensions");
|
||||
seed_extension_stores(&default);
|
||||
mkdir(&default, "IndexedDB");
|
||||
|
||||
clear_user_data_dir(dir);
|
||||
@@ -205,6 +237,7 @@ mod tests {
|
||||
assert!(default.join("Preferences").exists());
|
||||
assert!(default.join("Bookmarks").exists());
|
||||
assert!(default.join("Extensions").exists());
|
||||
assert_extension_stores_survived(&default);
|
||||
assert!(!default.join("History").exists());
|
||||
assert!(!default.join("IndexedDB").exists());
|
||||
}
|
||||
@@ -222,7 +255,7 @@ mod tests {
|
||||
touch(&default, "Bookmarks");
|
||||
touch(&default, "History");
|
||||
mkdir(&default, "Extensions");
|
||||
mkdir(&default, "Local Extension Settings");
|
||||
seed_extension_stores(&default);
|
||||
mkdir(&default, "IndexedDB");
|
||||
|
||||
clear_user_data_dir(dir);
|
||||
@@ -230,7 +263,7 @@ mod tests {
|
||||
assert!(default.exists(), "the profile dir must survive");
|
||||
assert!(default.join("Bookmarks").exists());
|
||||
assert!(default.join("Extensions").exists());
|
||||
assert!(default.join("Local Extension Settings").exists());
|
||||
assert_extension_stores_survived(&default);
|
||||
// Browsing data inside it is still cleared.
|
||||
assert!(!default.join("History").exists());
|
||||
assert!(!default.join("IndexedDB").exists());
|
||||
@@ -244,10 +277,12 @@ mod tests {
|
||||
let p2 = dir.join("Profile 2");
|
||||
touch(&p2, "Bookmarks");
|
||||
touch(&p2, "History");
|
||||
seed_extension_stores(&p2);
|
||||
|
||||
clear_user_data_dir(dir);
|
||||
|
||||
assert!(p2.join("Bookmarks").exists());
|
||||
assert_extension_stores_survived(&p2);
|
||||
assert!(!p2.join("History").exists());
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,17 @@ fn atomic_write(path: &Path, data: &[u8]) -> std::io::Result<()> {
|
||||
fs::rename(&tmp, path)
|
||||
}
|
||||
|
||||
/// Collapse an empty proxy/VPN id to `None`.
|
||||
///
|
||||
/// REST and MCP clients send `""` to detach a proxy or VPN, since omitting the
|
||||
/// field means "leave unchanged". Stored as `Some("")` it resolves to no
|
||||
/// upstream while every `proxy_id.is_some()` check still reads the profile as
|
||||
/// routed, so the launch gate probes a direct exit and can refuse the launch of
|
||||
/// a profile that has no proxy at all.
|
||||
fn normalize_network_id(id: Option<String>) -> Option<String> {
|
||||
id.filter(|id| !id.is_empty())
|
||||
}
|
||||
|
||||
pub struct ProfileManager {
|
||||
wayfern_manager: &'static crate::wayfern_manager::WayfernManager,
|
||||
}
|
||||
@@ -90,6 +101,12 @@ impl ProfileManager {
|
||||
);
|
||||
}
|
||||
|
||||
// Normalize before the mutual-exclusion check, not per caller: REST, MCP,
|
||||
// the importer and the Tauri commands all funnel through here, and a client
|
||||
// saying "neither" with two empty strings must not read as "both".
|
||||
let proxy_id = normalize_network_id(proxy_id);
|
||||
let vpn_id = normalize_network_id(vpn_id);
|
||||
|
||||
if proxy_id.is_some() && vpn_id.is_some() {
|
||||
return Err("Cannot set both proxy_id and vpn_id".into());
|
||||
}
|
||||
@@ -208,7 +225,13 @@ impl ProfileManager {
|
||||
browser: browser.to_string(),
|
||||
version: version.to_string(),
|
||||
proxy_id: proxy_id.clone(),
|
||||
vpn_id: None,
|
||||
// Carried, not None. Fingerprint generation reads this to decide
|
||||
// whether the profile routes its traffic at all; hardcoding None made
|
||||
// a VPN profile look direct, so its geolocation probe went out from
|
||||
// the user's real address and that location was baked into the
|
||||
// fingerprint, which the launch-time gate then rejects as a mismatch
|
||||
// the profile should never have had.
|
||||
vpn_id: vpn_id.clone(),
|
||||
launch_hook: launch_hook.clone(),
|
||||
process_id: None,
|
||||
last_launch: None,
|
||||
@@ -223,6 +246,7 @@ impl ProfileManager {
|
||||
last_sync: None,
|
||||
host_os: None,
|
||||
ephemeral: false,
|
||||
temporary: false,
|
||||
extension_group_id: None,
|
||||
proxy_bypass_rules: Vec::new(),
|
||||
created_by_id: None,
|
||||
@@ -332,6 +356,7 @@ impl ProfileManager {
|
||||
last_sync: None,
|
||||
host_os: Some(get_host_os()),
|
||||
ephemeral,
|
||||
temporary: false,
|
||||
extension_group_id: None,
|
||||
proxy_bypass_rules: Vec::new(),
|
||||
created_by_id: None,
|
||||
@@ -506,70 +531,168 @@ impl ProfileManager {
|
||||
Ok(profile)
|
||||
}
|
||||
|
||||
/// Delete a profile the recoverable way: it is moved to the trash and can
|
||||
/// be restored until it expires. Ephemeral profiles have nothing to keep
|
||||
/// and are destroyed outright.
|
||||
pub fn delete_profile(
|
||||
&self,
|
||||
app_handle: &tauri::AppHandle,
|
||||
profile_id: &str,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
log::info!("Attempting to delete profile with ID: {profile_id}");
|
||||
self.remove_profile(app_handle, profile_id, false, true)
|
||||
}
|
||||
|
||||
// Find the profile by ID
|
||||
/// Destroy a profile and its data for good, bypassing the trash.
|
||||
/// Mark a freshly created profile as belonging to one automation run.
|
||||
///
|
||||
/// Set after creation rather than threaded through every creation signature:
|
||||
/// only the REST and MCP paths can ask for it, and both already hold the
|
||||
/// profile they just made. Implies `ephemeral`, because a disposable profile
|
||||
/// must not leave a data directory on real disk either.
|
||||
pub fn mark_profile_temporary(
|
||||
&self,
|
||||
profile_id: &str,
|
||||
) -> Result<BrowserProfile, Box<dyn std::error::Error>> {
|
||||
let mut profile = self.find_profile(profile_id)?;
|
||||
profile.temporary = true;
|
||||
profile.ephemeral = true;
|
||||
self.save_profile(&profile)?;
|
||||
Ok(profile)
|
||||
}
|
||||
|
||||
/// Which temporary profiles a startup sweep should destroy.
|
||||
///
|
||||
/// A temporary profile is destroyed when its browser stops, so one still
|
||||
/// here at startup either outlived a crash or is being used by a browser
|
||||
/// this app did not start. `is_running` decides between the two, and is a
|
||||
/// parameter so the rule can be tested without a process table.
|
||||
pub fn temporary_profiles_to_sweep(
|
||||
profiles: &[BrowserProfile],
|
||||
is_running: impl Fn(u32) -> bool,
|
||||
) -> Vec<String> {
|
||||
profiles
|
||||
.iter()
|
||||
.filter(|profile| profile.temporary)
|
||||
.filter(|profile| !profile.process_id.is_some_and(&is_running))
|
||||
.map(|profile| profile.id.to_string())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Destroy every temporary profile that no live browser is using.
|
||||
///
|
||||
/// Runs at startup: a crash, a kill -9 or a power cut leaves a temporary
|
||||
/// profile behind, and nothing else would ever remove it. Returns how many
|
||||
/// were destroyed.
|
||||
pub fn sweep_temporary_profiles(&self, app_handle: &tauri::AppHandle) -> usize {
|
||||
let Ok(profiles) = self.list_profiles() else {
|
||||
return 0;
|
||||
};
|
||||
let stale =
|
||||
Self::temporary_profiles_to_sweep(&profiles, crate::proxy_storage::is_process_running);
|
||||
let mut swept = 0;
|
||||
for profile_id in stale {
|
||||
match self.delete_profile_permanently(app_handle, &profile_id) {
|
||||
Ok(()) => {
|
||||
swept += 1;
|
||||
log::info!("Swept temporary profile {profile_id} left by an earlier run");
|
||||
}
|
||||
Err(e) => log::warn!("Could not sweep temporary profile {profile_id}: {e}"),
|
||||
}
|
||||
}
|
||||
swept
|
||||
}
|
||||
|
||||
pub fn delete_profile_permanently(
|
||||
&self,
|
||||
app_handle: &tauri::AppHandle,
|
||||
profile_id: &str,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
self.remove_profile(app_handle, profile_id, true, true)
|
||||
}
|
||||
|
||||
fn find_profile(&self, profile_id: &str) -> Result<BrowserProfile, Box<dyn std::error::Error>> {
|
||||
let profile_uuid =
|
||||
uuid::Uuid::parse_str(profile_id).map_err(|_| format!("Invalid profile ID: {profile_id}"))?;
|
||||
let profiles = self.list_profiles()?;
|
||||
let profile = profiles
|
||||
self
|
||||
.list_profiles()?
|
||||
.into_iter()
|
||||
.find(|p| p.id == profile_uuid)
|
||||
.ok_or_else(|| format!("Profile with ID '{profile_id}' not found"))?;
|
||||
.ok_or_else(|| format!("Profile with ID '{profile_id}' not found").into())
|
||||
}
|
||||
|
||||
// Check if browser is running (cross-OS profiles can't be running locally)
|
||||
if profile.process_id.is_some() && !profile.is_cross_os() {
|
||||
return Err(
|
||||
"Cannot delete profile while browser is running. Please stop the browser first.".into(),
|
||||
);
|
||||
/// The one removal path. `permanent` destroys the directory; otherwise it
|
||||
/// moves to the trash. Either way the cloud sees a delete (tombstone) and
|
||||
/// any team lock is released, so a trashed profile is indistinguishable
|
||||
/// from a deleted one for every other device.
|
||||
fn remove_profile(
|
||||
&self,
|
||||
app_handle: &tauri::AppHandle,
|
||||
profile_id: &str,
|
||||
permanent: bool,
|
||||
emit_events: bool,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
log::info!("Attempting to delete profile with ID: {profile_id} (permanent: {permanent})");
|
||||
let profile = self.find_profile(profile_id)?;
|
||||
|
||||
if crate::profile::trash::is_running_locally(&profile) {
|
||||
return Err(crate::backend_error("PROFILE_RUNNING").into());
|
||||
}
|
||||
|
||||
// Launch-gate acknowledgements are keyed by profile id and are not synced,
|
||||
// so nothing else would ever clean them up.
|
||||
crate::launch_gate_prefs::forget_profile(profile_id);
|
||||
// An ephemeral profile keeps its data in RAM; there is nothing to trash.
|
||||
let permanent = permanent || profile.ephemeral;
|
||||
|
||||
// Deleting the profile never touched its ephemeral directory, so a
|
||||
// decrypted or in-memory copy outlived the profile it belonged to with
|
||||
// nothing left that knew to reap it. The running-browser guard above only
|
||||
// rejects a live process_id, and the keep-decrypted path deliberately
|
||||
// clears process_id while leaving the plaintext tree populated. No-ops
|
||||
// when the profile has no ephemeral directory.
|
||||
// A decrypted or in-memory copy must not outlive the profile it belonged
|
||||
// to. The running-browser guard above only rejects a live process, and
|
||||
// the keep-decrypted path deliberately clears process_id while leaving
|
||||
// the plaintext tree populated. No-ops when there is no ephemeral dir.
|
||||
crate::ephemeral_dirs::remove_ephemeral_dir(profile_id);
|
||||
if profile.password_protected {
|
||||
crate::profile::encryption::drop_cached_key(&profile.id);
|
||||
}
|
||||
|
||||
// Per-domain traffic history lives outside the profile directory, so it
|
||||
// survives the delete otherwise. It is already zero-overwritten on removal.
|
||||
crate::traffic_stats::delete_traffic_stats(profile_id);
|
||||
|
||||
// Remember sync mode before deleting local files
|
||||
let was_sync_enabled = profile.is_sync_enabled();
|
||||
|
||||
let profiles_dir = self.get_profiles_dir();
|
||||
let profile_uuid_dir = profiles_dir.join(profile.id.to_string());
|
||||
|
||||
// Delete the entire UUID directory (contains both metadata.json and profile data)
|
||||
if profile_uuid_dir.exists() {
|
||||
log::info!("Deleting profile directory: {}", profile_uuid_dir.display());
|
||||
fs::remove_dir_all(&profile_uuid_dir)?;
|
||||
log::info!("Profile directory deleted successfully");
|
||||
}
|
||||
|
||||
// Verify deletion was successful
|
||||
if profile_uuid_dir.exists() {
|
||||
return Err(format!("Failed to completely delete profile '{}'", profile.name).into());
|
||||
if permanent {
|
||||
self.forget_profile_side_state(profile_id);
|
||||
if profile_uuid_dir.exists() {
|
||||
log::info!("Deleting profile directory: {}", profile_uuid_dir.display());
|
||||
fs::remove_dir_all(&profile_uuid_dir)?;
|
||||
}
|
||||
if profile_uuid_dir.exists() {
|
||||
return Err(format!("Failed to completely delete profile '{}'", profile.name).into());
|
||||
}
|
||||
} else {
|
||||
let _guard = crate::profile::trash::mutation_lock();
|
||||
crate::profile::trash::trash_profile(
|
||||
&profiles_dir,
|
||||
&crate::profile::trash::trash_dir(),
|
||||
&profile,
|
||||
crate::profile::trash::configured_retention_days(),
|
||||
crate::proxy_manager::now_secs(),
|
||||
)?;
|
||||
}
|
||||
|
||||
log::info!(
|
||||
"Profile '{}' (ID: {}) deleted successfully",
|
||||
"Profile '{}' (ID: {}) {} successfully",
|
||||
profile.name,
|
||||
profile_id
|
||||
profile_id,
|
||||
if permanent {
|
||||
"deleted"
|
||||
} else {
|
||||
"moved to trash"
|
||||
}
|
||||
);
|
||||
|
||||
// If sync was enabled, also delete from S3
|
||||
// The browser is not running, so the team lock is normally released
|
||||
// already; this only drops a lock a crash left behind.
|
||||
let lock_profile = profile.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
crate::team_lock::release_team_lock_if_needed(&lock_profile).await;
|
||||
});
|
||||
|
||||
// From the cloud's point of view a trashed profile is deleted.
|
||||
if was_sync_enabled {
|
||||
let profile_id_owned = profile_id.to_string();
|
||||
let app_handle_clone = app_handle.clone();
|
||||
@@ -593,24 +716,132 @@ impl ProfileManager {
|
||||
});
|
||||
}
|
||||
|
||||
// Rebuild tag suggestions after deletion
|
||||
if emit_events {
|
||||
self.after_profiles_removed(!permanent);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// State that lives outside the profile directory and only makes sense
|
||||
/// while the profile can still come back. Dropped when it cannot.
|
||||
fn forget_profile_side_state(&self, profile_id: &str) {
|
||||
// Launch-gate acknowledgements are keyed by profile id and are not synced,
|
||||
// so nothing else would ever clean them up.
|
||||
crate::launch_gate_prefs::forget_profile(profile_id);
|
||||
// Per-domain traffic history is zero-overwritten on removal.
|
||||
crate::traffic_stats::delete_traffic_stats(profile_id);
|
||||
}
|
||||
|
||||
/// Bookkeeping after one or more profiles left the live list.
|
||||
fn after_profiles_removed(&self, trashed: bool) {
|
||||
let _ = crate::tag_manager::TAG_MANAGER.lock().map(|tm| {
|
||||
let _ = tm.rebuild_from_profiles(&self.list_profiles().unwrap_or_default());
|
||||
});
|
||||
|
||||
// Always perform cleanup after profile deletion to remove unused binaries
|
||||
if let Err(e) = DownloadedBrowsersRegistry::instance().cleanup_unused_binaries() {
|
||||
log::warn!("Warning: Failed to cleanup unused binaries after profile deletion: {e}");
|
||||
}
|
||||
|
||||
// Emit profile deletion event
|
||||
if let Err(e) = events::emit_empty("profiles-changed") {
|
||||
log::warn!("Warning: Failed to emit profiles-changed event: {e}");
|
||||
}
|
||||
if trashed {
|
||||
if let Err(e) = events::emit_empty("trash-changed") {
|
||||
log::warn!("Warning: Failed to emit trash-changed event: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Move a trashed profile back into the live list under its original id.
|
||||
///
|
||||
/// Sync is NOT re-enabled here: the caller (the Tauri command) routes the
|
||||
/// restored profile through `set_profile_sync_mode`, which clears the
|
||||
/// tombstone the trash wrote and queues the re-upload.
|
||||
pub fn restore_trashed_profile(
|
||||
&self,
|
||||
profile_id: &str,
|
||||
) -> Result<BrowserProfile, Box<dyn std::error::Error>> {
|
||||
let _guard = crate::profile::trash::mutation_lock();
|
||||
let live = self.list_profiles()?;
|
||||
let groups: std::collections::HashSet<String> = crate::group_manager::GROUP_MANAGER
|
||||
.lock()
|
||||
.map(|gm| {
|
||||
gm.get_all_groups()
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|g| g.id)
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let profile = crate::profile::trash::restore_profile(
|
||||
&self.get_profiles_dir(),
|
||||
&crate::profile::trash::trash_dir(),
|
||||
profile_id,
|
||||
&live,
|
||||
&|group_id| groups.contains(group_id),
|
||||
crate::proxy_manager::now_secs(),
|
||||
)?;
|
||||
// The normal save path, so tag suggestions pick the profile up again.
|
||||
self.save_profile(&profile)?;
|
||||
|
||||
log::info!(
|
||||
"Profile '{}' (ID: {}) restored from trash",
|
||||
profile.name,
|
||||
profile_id
|
||||
);
|
||||
if let Err(e) = events::emit_empty("profiles-changed") {
|
||||
log::warn!("Warning: Failed to emit profiles-changed event: {e}");
|
||||
}
|
||||
if let Err(e) = events::emit_empty("trash-changed") {
|
||||
log::warn!("Warning: Failed to emit trash-changed event: {e}");
|
||||
}
|
||||
Ok(profile)
|
||||
}
|
||||
|
||||
pub fn purge_trashed_profile(&self, profile_id: &str) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let _guard = crate::profile::trash::mutation_lock();
|
||||
crate::profile::trash::purge_entry(&crate::profile::trash::trash_dir(), profile_id)?;
|
||||
self.after_trash_purged(std::slice::from_ref(&profile_id.to_string()));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Destroy every trashed profile. Returns how many were removed.
|
||||
pub fn empty_trash(&self) -> Result<usize, Box<dyn std::error::Error>> {
|
||||
let _guard = crate::profile::trash::mutation_lock();
|
||||
let purged = crate::profile::trash::purge_all(&crate::profile::trash::trash_dir())?;
|
||||
self.after_trash_purged(&purged);
|
||||
Ok(purged.len())
|
||||
}
|
||||
|
||||
/// Destroy every trashed profile whose retention has run out. Returns how
|
||||
/// many were removed.
|
||||
pub fn purge_expired_trash(&self) -> usize {
|
||||
let _guard = crate::profile::trash::mutation_lock();
|
||||
let purged = crate::profile::trash::purge_expired(
|
||||
&crate::profile::trash::trash_dir(),
|
||||
crate::proxy_manager::now_secs(),
|
||||
);
|
||||
self.after_trash_purged(&purged);
|
||||
purged.len()
|
||||
}
|
||||
|
||||
fn after_trash_purged(&self, purged_ids: &[String]) {
|
||||
if purged_ids.is_empty() {
|
||||
return;
|
||||
}
|
||||
for id in purged_ids {
|
||||
self.forget_profile_side_state(id);
|
||||
}
|
||||
if let Err(e) = DownloadedBrowsersRegistry::instance().cleanup_unused_binaries() {
|
||||
log::warn!("Warning: Failed to cleanup unused binaries after purging the trash: {e}");
|
||||
}
|
||||
if let Err(e) = events::emit_empty("trash-changed") {
|
||||
log::warn!("Warning: Failed to emit trash-changed event: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete a profile from the local filesystem only, without triggering remote sync deletion.
|
||||
/// Used when a profile was deleted on another device and the local copy should be cleaned up.
|
||||
pub fn delete_profile_local_only(
|
||||
@@ -1001,66 +1232,26 @@ impl ProfileManager {
|
||||
Ok(profile)
|
||||
}
|
||||
|
||||
/// Trash several profiles at once. Every profile is checked before any of
|
||||
/// them moves, so one running browser blocks the whole batch instead of
|
||||
/// leaving it half done.
|
||||
pub fn delete_multiple_profiles(
|
||||
&self,
|
||||
app_handle: &tauri::AppHandle,
|
||||
profile_ids: Vec<String>,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let profiles = self.list_profiles()?;
|
||||
let mut sync_enabled_ids: Vec<String> = Vec::new();
|
||||
|
||||
for profile_id in profile_ids {
|
||||
let profile_uuid = uuid::Uuid::parse_str(&profile_id)
|
||||
.map_err(|_| format!("Invalid profile ID: {profile_id}"))?;
|
||||
let profile = profiles
|
||||
.iter()
|
||||
.find(|p| p.id == profile_uuid)
|
||||
.ok_or_else(|| format!("Profile with ID '{profile_id}' not found"))?;
|
||||
|
||||
// Check if browser is running (cross-OS profiles can't be running locally)
|
||||
if profile.process_id.is_some() && !profile.is_cross_os() {
|
||||
return Err(
|
||||
format!(
|
||||
"Cannot delete profile '{}' while browser is running. Please stop the browser first.",
|
||||
profile.name
|
||||
)
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
|
||||
// Track sync-enabled profiles for remote deletion
|
||||
if profile.is_sync_enabled() {
|
||||
sync_enabled_ids.push(profile_id.clone());
|
||||
}
|
||||
|
||||
// Delete the profile
|
||||
let profiles_dir = self.get_profiles_dir();
|
||||
let profile_uuid_dir = profiles_dir.join(profile.id.to_string());
|
||||
|
||||
if profile_uuid_dir.exists() {
|
||||
std::fs::remove_dir_all(&profile_uuid_dir)?;
|
||||
for profile_id in &profile_ids {
|
||||
let profile = self.find_profile(profile_id)?;
|
||||
if crate::profile::trash::is_running_locally(&profile) {
|
||||
return Err(crate::backend_error("PROFILE_RUNNING").into());
|
||||
}
|
||||
}
|
||||
|
||||
// Delete sync-enabled profiles from S3
|
||||
if !sync_enabled_ids.is_empty() {
|
||||
let app_handle_clone = app_handle.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
if let Ok(engine) = crate::sync::SyncEngine::create_from_settings(&app_handle_clone).await {
|
||||
for profile_id in sync_enabled_ids {
|
||||
if let Err(e) = engine.delete_profile(&profile_id).await {
|
||||
log::warn!("Failed to delete profile {} from sync: {}", profile_id, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Emit profile deletion event
|
||||
if let Err(e) = events::emit_empty("profiles-changed") {
|
||||
log::warn!("Warning: Failed to emit profiles-changed event: {e}");
|
||||
for profile_id in &profile_ids {
|
||||
self.remove_profile(app_handle, profile_id, false, false)?;
|
||||
}
|
||||
|
||||
self.after_profiles_removed(true);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1140,6 +1331,7 @@ impl ProfileManager {
|
||||
last_sync: None,
|
||||
host_os: Some(get_host_os()),
|
||||
ephemeral: false,
|
||||
temporary: false,
|
||||
extension_group_id: source.extension_group_id,
|
||||
proxy_bypass_rules: source.proxy_bypass_rules,
|
||||
created_by_id: None,
|
||||
@@ -1295,6 +1487,7 @@ impl ProfileManager {
|
||||
profile_id: &str,
|
||||
proxy_id: Option<String>,
|
||||
) -> Result<BrowserProfile, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let proxy_id = normalize_network_id(proxy_id);
|
||||
// Find the profile by ID
|
||||
let profile_uuid = uuid::Uuid::parse_str(profile_id).map_err(
|
||||
|_| -> Box<dyn std::error::Error + Send + Sync> {
|
||||
@@ -1334,8 +1527,8 @@ impl ProfileManager {
|
||||
|
||||
// The cookie bot refuses a run on a profile with no exit node, using the
|
||||
// copy of that fact the desktop last declared. Detaching a proxy has to
|
||||
// move that copy, or tonight's run egresses from the leased host's own
|
||||
// datacenter address.
|
||||
// move that copy, or tonight's run egresses from the remote host's own
|
||||
// address instead of the user's exit.
|
||||
crate::cookie_bot::report_profile_state(&profile);
|
||||
|
||||
// Auto-enable sync for new proxy if profile has sync enabled
|
||||
@@ -1367,6 +1560,7 @@ impl ProfileManager {
|
||||
profile_id: &str,
|
||||
vpn_id: Option<String>,
|
||||
) -> Result<BrowserProfile, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let vpn_id = normalize_network_id(vpn_id);
|
||||
let profile_uuid = uuid::Uuid::parse_str(profile_id).map_err(
|
||||
|_| -> Box<dyn std::error::Error + Send + Sync> {
|
||||
format!("Invalid profile ID: {profile_id}").into()
|
||||
@@ -1747,6 +1941,48 @@ mod tests {
|
||||
(profile_manager, temp_dir)
|
||||
}
|
||||
|
||||
fn temporary_profile(name: &str, process_id: Option<u32>) -> BrowserProfile {
|
||||
BrowserProfile {
|
||||
id: uuid::Uuid::new_v4(),
|
||||
name: name.to_string(),
|
||||
browser: "wayfern".to_string(),
|
||||
temporary: true,
|
||||
ephemeral: true,
|
||||
process_id,
|
||||
..BrowserProfile::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_startup_sweep_takes_the_temporary_profiles_nothing_is_running() {
|
||||
let ordinary = BrowserProfile {
|
||||
id: uuid::Uuid::new_v4(),
|
||||
name: "Kept".to_string(),
|
||||
..BrowserProfile::default()
|
||||
};
|
||||
let crashed = temporary_profile("Crashed", Some(4242));
|
||||
let never_started = temporary_profile("Never started", None);
|
||||
let live = temporary_profile("Live", Some(4243));
|
||||
let profiles = vec![
|
||||
ordinary.clone(),
|
||||
crashed.clone(),
|
||||
never_started.clone(),
|
||||
live.clone(),
|
||||
];
|
||||
|
||||
let swept = ProfileManager::temporary_profiles_to_sweep(&profiles, |pid| pid == 4243);
|
||||
assert_eq!(
|
||||
swept,
|
||||
vec![crashed.id.to_string(), never_started.id.to_string()],
|
||||
"a live browser keeps its profile; an ordinary profile is never swept"
|
||||
);
|
||||
|
||||
// Nothing running at all: every temporary profile goes, and only those.
|
||||
let swept = ProfileManager::temporary_profiles_to_sweep(&profiles, |_| false);
|
||||
assert_eq!(swept.len(), 3);
|
||||
assert!(!swept.contains(&ordinary.id.to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_profile_manager_creation() {
|
||||
let (_manager, _temp_dir) = create_test_profile_manager();
|
||||
@@ -1785,6 +2021,26 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_network_ids_normalize_to_none() {
|
||||
assert_eq!(normalize_network_id(Some(String::new())), None);
|
||||
assert_eq!(normalize_network_id(None), None);
|
||||
assert_eq!(
|
||||
normalize_network_id(Some("proxy-1".to_string())),
|
||||
Some("proxy-1".to_string())
|
||||
);
|
||||
|
||||
// A client saying "neither" with two empty strings must not trip the
|
||||
// mutual-exclusion check in create_profile_with_group.
|
||||
assert_eq!(
|
||||
(
|
||||
normalize_network_id(Some(String::new())),
|
||||
normalize_network_id(Some(String::new()))
|
||||
),
|
||||
(None, None)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalize_launch_hook_accepts_http_and_https() {
|
||||
let http =
|
||||
@@ -2128,11 +2384,27 @@ pub fn clone_profile(profile_id: String, name: Option<String>) -> Result<Browser
|
||||
.map_err(|e| format!("Failed to clone profile: {e}"))
|
||||
}
|
||||
|
||||
/// Move a profile to the trash. `permanent: true` destroys it instead.
|
||||
#[tauri::command]
|
||||
pub fn delete_profile(app_handle: tauri::AppHandle, profile_id: String) -> Result<(), String> {
|
||||
ProfileManager::instance()
|
||||
.delete_profile(&app_handle, &profile_id)
|
||||
.map_err(|e| format!("Failed to delete profile: {e}"))
|
||||
pub fn delete_profile(
|
||||
app_handle: tauri::AppHandle,
|
||||
profile_id: String,
|
||||
permanent: Option<bool>,
|
||||
) -> Result<(), String> {
|
||||
let manager = ProfileManager::instance();
|
||||
let result = if permanent.unwrap_or(false) {
|
||||
manager.delete_profile_permanently(&app_handle, &profile_id)
|
||||
} else {
|
||||
manager.delete_profile(&app_handle, &profile_id)
|
||||
};
|
||||
result.map_err(|e| {
|
||||
let msg = e.to_string();
|
||||
if msg.starts_with('{') {
|
||||
msg
|
||||
} else {
|
||||
format!("Failed to delete profile: {msg}")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
lazy_static::lazy_static! {
|
||||
|
||||
@@ -2,6 +2,8 @@ pub mod clear_on_close;
|
||||
pub mod encryption;
|
||||
pub mod manager;
|
||||
pub mod password;
|
||||
pub mod portable;
|
||||
pub mod trash;
|
||||
pub mod types;
|
||||
|
||||
pub use manager::ProfileManager;
|
||||
|
||||
@@ -17,7 +17,7 @@ use crate::sync::manifest::DEFAULT_EXCLUDE_PATTERNS;
|
||||
use serde_json::json;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Mutex;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::SystemTime;
|
||||
|
||||
/// Build a JSON error payload with just a code.
|
||||
@@ -53,6 +53,33 @@ lazy_static::lazy_static! {
|
||||
|
||||
/// Per-profile failed unlock attempt tracking for rate-limiting.
|
||||
static ref FAILED_ATTEMPTS: Mutex<HashMap<uuid::Uuid, FailureRecord>> = Mutex::new(HashMap::new());
|
||||
|
||||
/// Per-profile lock serializing the whole check-lockout -> verify -> record
|
||||
/// window. `check_lockout` and `record_failed_attempt` each take and release
|
||||
/// `FAILED_ATTEMPTS` independently, with an Argon2 verification between them,
|
||||
/// so without this a burst of concurrent attempts all read the same stale
|
||||
/// count before any of them increments it and one lockout window admits as
|
||||
/// many guesses as there are worker threads.
|
||||
static ref ATTEMPT_LOCKS: Mutex<HashMap<uuid::Uuid, Arc<tokio::sync::Mutex<()>>>> =
|
||||
Mutex::new(HashMap::new());
|
||||
}
|
||||
|
||||
/// The attempt lock for one profile. The std map lock is released before the
|
||||
/// caller awaits the returned lock, so it is never held across an await.
|
||||
///
|
||||
/// A poisoned map degrades to serialized rather than silently unserialized.
|
||||
fn attempt_lock(profile_id: &uuid::Uuid) -> Arc<tokio::sync::Mutex<()>> {
|
||||
let mut guard = ATTEMPT_LOCKS
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
// An entry only the map itself still references has no attempt in progress,
|
||||
// so dropping it here keeps a long-lived process from accumulating one lock
|
||||
// per profile ever touched. A live holder always keeps the count above 1.
|
||||
guard.retain(|_, lock| Arc::strong_count(lock) > 1);
|
||||
guard
|
||||
.entry(*profile_id)
|
||||
.or_insert_with(|| Arc::new(tokio::sync::Mutex::new(())))
|
||||
.clone()
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize)]
|
||||
@@ -309,6 +336,10 @@ pub async fn verify_profile_password(profile_id: String, password: String) -> Re
|
||||
if !profile.password_protected {
|
||||
return Err(err_code("PROFILE_NOT_PROTECTED"));
|
||||
}
|
||||
// Bound, never dropped early: it must cover check_lockout through the
|
||||
// record/clear branches below. See `attempt_lock`.
|
||||
let attempt = attempt_lock(&id);
|
||||
let _attempt_guard = attempt.lock().await;
|
||||
if let Err(secs) = check_lockout(&id) {
|
||||
return Err(err_with("LOCKED_OUT", &[("seconds", secs.to_string())]));
|
||||
}
|
||||
@@ -338,6 +369,10 @@ pub async fn unlock_profile(profile_id: String, password: String) -> Result<(),
|
||||
if !profile.password_protected {
|
||||
return Err(err_code("PROFILE_NOT_PROTECTED"));
|
||||
}
|
||||
// Bound, never dropped early: it must cover check_lockout through the
|
||||
// record/clear branches below. See `attempt_lock`.
|
||||
let attempt = attempt_lock(&id);
|
||||
let _attempt_guard = attempt.lock().await;
|
||||
if let Err(secs) = check_lockout(&id) {
|
||||
return Err(err_with("LOCKED_OUT", &[("seconds", secs.to_string())]));
|
||||
}
|
||||
@@ -399,6 +434,10 @@ pub async fn change_profile_password(
|
||||
return Err(err_code("PROFILE_RUNNING"));
|
||||
}
|
||||
|
||||
// Bound, never dropped early: it must cover check_lockout through the
|
||||
// record/clear branches below. See `attempt_lock`.
|
||||
let attempt = attempt_lock(&id);
|
||||
let _attempt_guard = attempt.lock().await;
|
||||
if let Err(secs) = check_lockout(&id) {
|
||||
return Err(err_with("LOCKED_OUT", &[("seconds", secs.to_string())]));
|
||||
}
|
||||
@@ -450,6 +489,10 @@ pub async fn remove_profile_password(profile_id: String, password: String) -> Re
|
||||
return Err(err_code("PROFILE_RUNNING"));
|
||||
}
|
||||
|
||||
// Bound, never dropped early: it must cover check_lockout through the
|
||||
// record/clear branches below. See `attempt_lock`.
|
||||
let attempt = attempt_lock(&id);
|
||||
let _attempt_guard = attempt.lock().await;
|
||||
if let Err(secs) = check_lockout(&id) {
|
||||
return Err(err_with("LOCKED_OUT", &[("seconds", secs.to_string())]));
|
||||
}
|
||||
@@ -1255,6 +1298,30 @@ mod tests {
|
||||
clear_failed_attempts(&profile.id);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn attempt_lock_serializes_one_profile_without_blocking_others() {
|
||||
let a = uuid::Uuid::new_v4();
|
||||
let b = uuid::Uuid::new_v4();
|
||||
|
||||
// One lock per profile is what turns check-lockout -> verify -> record
|
||||
// into a critical section instead of a check-then-act race.
|
||||
assert!(Arc::ptr_eq(&attempt_lock(&a), &attempt_lock(&a)));
|
||||
assert!(!Arc::ptr_eq(&attempt_lock(&a), &attempt_lock(&b)));
|
||||
|
||||
let held = attempt_lock(&a);
|
||||
let guard = held.lock().await;
|
||||
assert!(
|
||||
attempt_lock(&a).try_lock().is_err(),
|
||||
"a concurrent attempt on the same profile must wait for the window"
|
||||
);
|
||||
assert!(
|
||||
attempt_lock(&b).try_lock().is_ok(),
|
||||
"a different profile must not be serialized behind it"
|
||||
);
|
||||
drop(guard);
|
||||
assert!(attempt_lock(&a).try_lock().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lockout_schedule_progression() {
|
||||
use std::time::Duration;
|
||||
|
||||
@@ -0,0 +1,765 @@
|
||||
//! Moving a profile between machines.
|
||||
//!
|
||||
//! An export is one zip: a manifest, the profile's configuration, and
|
||||
//! optionally its browser data directory. An import creates a NEW profile from
|
||||
//! it, with a fresh id, so importing an export twice gives two profiles rather
|
||||
//! than a conflict or a silent overwrite.
|
||||
//!
|
||||
//! What deliberately does NOT travel:
|
||||
//! - the process id, the last-launch time and the cloud-sync bookkeeping,
|
||||
//! which describe the machine that exported, not the profile;
|
||||
//! - the caches, which Chromium rebuilds and which are most of the bytes;
|
||||
//! - the browser binary, which the importing machine downloads for itself;
|
||||
//! - a password-protected profile's data, because its at-rest key belongs to
|
||||
//! the exporting machine's keychain and the bytes would be unreadable
|
||||
//! anywhere else. Its configuration exports, its data does not, and the
|
||||
//! export says so rather than shipping an archive nobody can open.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::io::{Read, Seek, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::profile::types::BrowserProfile;
|
||||
|
||||
/// Bumped when the archive layout changes in a way an older build cannot read.
|
||||
const FORMAT_VERSION: u32 = 1;
|
||||
const MANIFEST_ENTRY: &str = "manifest.json";
|
||||
const PROFILE_ENTRY: &str = "profile.json";
|
||||
const DATA_PREFIX: &str = "data/";
|
||||
/// A profile directory is browsing history, cookies and extension state. Past
|
||||
/// this size an export is almost certainly a mistake (a cache directory that
|
||||
/// escaped the prune, say), and writing gigabytes to a user's Downloads folder
|
||||
/// without saying why is worse than refusing.
|
||||
const MAX_DATA_BYTES: u64 = 4 * 1024 * 1024 * 1024;
|
||||
|
||||
/// What one archive says about itself.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PortableManifest {
|
||||
pub format_version: u32,
|
||||
/// The app that wrote it, for a bug report.
|
||||
pub exported_by: String,
|
||||
pub exported_at: u64,
|
||||
/// The profile's name at export time. The id is deliberately absent: an
|
||||
/// import mints a new one, and carrying the old id invites a caller to
|
||||
/// "restore" over a live profile.
|
||||
pub profile_name: String,
|
||||
pub browser: String,
|
||||
pub version: String,
|
||||
/// Whether `data/` is present. False for a configuration-only export and for
|
||||
/// a password-protected profile, whose bytes cannot travel.
|
||||
pub includes_data: bool,
|
||||
/// Why the data is absent, when it is.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub data_omitted_reason: Option<String>,
|
||||
}
|
||||
|
||||
/// What an import found in an archive, before it creates anything.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PortablePreview {
|
||||
pub manifest: PortableManifest,
|
||||
/// The proxy the exporting machine had assigned, by name, when it had one.
|
||||
/// An import never links a proxy by id: ids are local to a machine.
|
||||
pub proxy_name: Option<String>,
|
||||
pub group_name: Option<String>,
|
||||
pub tags: Vec<String>,
|
||||
}
|
||||
|
||||
fn err(context: &str, detail: impl std::fmt::Display) -> String {
|
||||
crate::backend_error_with_detail("PROFILE_EXPORT_FAILED", format!("{context}: {detail}"))
|
||||
}
|
||||
|
||||
fn import_err(detail: impl std::fmt::Display) -> String {
|
||||
crate::backend_error_with_detail("PROFILE_IMPORT_FAILED", detail.to_string())
|
||||
}
|
||||
|
||||
/// The configuration an export carries: the profile as stored, minus
|
||||
/// everything that describes this machine or this moment.
|
||||
pub fn exportable_config(profile: &BrowserProfile) -> serde_json::Value {
|
||||
let mut value = serde_json::to_value(profile).unwrap_or(serde_json::Value::Null);
|
||||
if let Some(object) = value.as_object_mut() {
|
||||
for machine_local in [
|
||||
"id",
|
||||
"process_id",
|
||||
"last_launch",
|
||||
"last_sync",
|
||||
"encryption_salt",
|
||||
"created_by_id",
|
||||
"created_by_email",
|
||||
"proxy_id",
|
||||
"vpn_id",
|
||||
"group_id",
|
||||
"extension_group_id",
|
||||
"temporary",
|
||||
] {
|
||||
object.remove(machine_local);
|
||||
}
|
||||
// A password-protected profile's data cannot travel, so the flag must not
|
||||
// either: an imported profile with the flag set and no key is unopenable.
|
||||
object.insert("password_protected".to_string(), serde_json::json!(false));
|
||||
object.insert("sync_mode".to_string(), serde_json::json!("Disabled"));
|
||||
}
|
||||
value
|
||||
}
|
||||
|
||||
/// Every file under `dir`, relative to it, skipping the cache directories and
|
||||
/// the launcher's own per-launch documents.
|
||||
fn collect_files(dir: &Path) -> Result<Vec<(String, PathBuf)>, String> {
|
||||
fn walk(root: &Path, dir: &Path, out: &mut Vec<(String, PathBuf)>) -> Result<(), String> {
|
||||
let entries = fs::read_dir(dir).map_err(|e| err("could not read the profile directory", e))?;
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
let Ok(relative) = path.strip_prefix(root) else {
|
||||
continue;
|
||||
};
|
||||
let relative = relative.to_string_lossy().replace('\\', "/");
|
||||
if is_excluded(&relative) {
|
||||
continue;
|
||||
}
|
||||
let file_type = entry
|
||||
.file_type()
|
||||
.map_err(|e| err("could not stat a file", e))?;
|
||||
if file_type.is_symlink() {
|
||||
// A symlink in an archive is either useless on the other machine or a
|
||||
// way out of the extraction directory. Neither travels.
|
||||
continue;
|
||||
}
|
||||
if file_type.is_dir() {
|
||||
walk(root, &path, out)?;
|
||||
} else if file_type.is_file() {
|
||||
out.push((relative, path));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
let mut files = Vec::new();
|
||||
walk(dir, dir, &mut files)?;
|
||||
files.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
/// Whether a path inside the profile directory is left out of an export.
|
||||
pub fn is_excluded(relative: &str) -> bool {
|
||||
const CACHE_SEGMENTS: [&str; 10] = [
|
||||
"Cache",
|
||||
"Code Cache",
|
||||
"GPUCache",
|
||||
"GrShaderCache",
|
||||
"ShaderCache",
|
||||
"DawnCache",
|
||||
"DawnGraphiteCache",
|
||||
"GraphiteDawnCache",
|
||||
"CacheStorage",
|
||||
"ScriptCache",
|
||||
];
|
||||
const LAUNCH_FILES: [&str; 3] = [
|
||||
"wayfern-identity.json",
|
||||
"wayfern-persona.json",
|
||||
"window-icon.png",
|
||||
];
|
||||
const SINGLETONS: [&str; 3] = ["SingletonLock", "SingletonSocket", "SingletonCookie"];
|
||||
|
||||
let segments: Vec<&str> = relative.split('/').collect();
|
||||
if segments
|
||||
.iter()
|
||||
.any(|segment| CACHE_SEGMENTS.contains(segment))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
let Some(name) = segments.last() else {
|
||||
return true;
|
||||
};
|
||||
LAUNCH_FILES.contains(name) || SINGLETONS.contains(name) || name.ends_with(".tmp")
|
||||
}
|
||||
|
||||
/// Write an export archive for `profile` to `destination`.
|
||||
///
|
||||
/// `data_dir` is the profile's browser directory; `include_data` false writes
|
||||
/// a configuration-only archive, which is the small one worth emailing.
|
||||
pub fn export_to(
|
||||
profile: &BrowserProfile,
|
||||
data_dir: &Path,
|
||||
destination: &Path,
|
||||
include_data: bool,
|
||||
proxy_name: Option<String>,
|
||||
group_name: Option<String>,
|
||||
) -> Result<PortableManifest, String> {
|
||||
let data_omitted_reason = if !include_data {
|
||||
Some("the export was asked for without the browser data".to_string())
|
||||
} else if profile.password_protected {
|
||||
Some(
|
||||
"the profile is password protected, and its data is encrypted with a key held by the exporting machine".to_string(),
|
||||
)
|
||||
} else if !data_dir.is_dir() {
|
||||
Some("the profile has no browser data yet".to_string())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let carries_data = data_omitted_reason.is_none();
|
||||
|
||||
let manifest = PortableManifest {
|
||||
format_version: FORMAT_VERSION,
|
||||
exported_by: format!("Donut Browser {}", env!("CARGO_PKG_VERSION")),
|
||||
exported_at: crate::proxy_manager::now_secs(),
|
||||
profile_name: profile.name.clone(),
|
||||
browser: profile.browser.clone(),
|
||||
version: profile.version.clone(),
|
||||
includes_data: carries_data,
|
||||
data_omitted_reason,
|
||||
};
|
||||
|
||||
let mut preview = serde_json::to_value(&manifest).map_err(|e| err("manifest", e))?;
|
||||
if let Some(object) = preview.as_object_mut() {
|
||||
object.insert("proxy_name".to_string(), serde_json::json!(proxy_name));
|
||||
object.insert("group_name".to_string(), serde_json::json!(group_name));
|
||||
}
|
||||
|
||||
if let Some(parent) = destination.parent() {
|
||||
fs::create_dir_all(parent).map_err(|e| err("could not create the destination folder", e))?;
|
||||
}
|
||||
let file = fs::File::create(destination).map_err(|e| err("could not create the archive", e))?;
|
||||
let mut writer = zip::ZipWriter::new(file);
|
||||
let options: zip::write::FileOptions<'_, ()> =
|
||||
zip::write::FileOptions::default().compression_method(zip::CompressionMethod::Deflated);
|
||||
|
||||
writer
|
||||
.start_file(MANIFEST_ENTRY, options)
|
||||
.map_err(|e| err("manifest", e))?;
|
||||
writer
|
||||
.write_all(
|
||||
serde_json::to_string_pretty(&preview)
|
||||
.map_err(|e| err("manifest", e))?
|
||||
.as_bytes(),
|
||||
)
|
||||
.map_err(|e| err("manifest", e))?;
|
||||
|
||||
writer
|
||||
.start_file(PROFILE_ENTRY, options)
|
||||
.map_err(|e| err("profile", e))?;
|
||||
writer
|
||||
.write_all(
|
||||
serde_json::to_string_pretty(&exportable_config(profile))
|
||||
.map_err(|e| err("profile", e))?
|
||||
.as_bytes(),
|
||||
)
|
||||
.map_err(|e| err("profile", e))?;
|
||||
|
||||
if carries_data {
|
||||
let mut written = 0u64;
|
||||
for (relative, absolute) in collect_files(data_dir)? {
|
||||
let data = match fs::read(&absolute) {
|
||||
Ok(data) => data,
|
||||
// A browser file can vanish between the walk and the read; that is not
|
||||
// a reason to fail an export of everything else.
|
||||
Err(e) => {
|
||||
log::warn!("Skipping {} in the export: {e}", absolute.display());
|
||||
continue;
|
||||
}
|
||||
};
|
||||
written = written.saturating_add(data.len() as u64);
|
||||
if written > MAX_DATA_BYTES {
|
||||
return Err(crate::backend_error("PROFILE_EXPORT_TOO_LARGE"));
|
||||
}
|
||||
writer
|
||||
.start_file(format!("{DATA_PREFIX}{relative}"), options)
|
||||
.map_err(|e| err(&relative, e))?;
|
||||
writer.write_all(&data).map_err(|e| err(&relative, e))?;
|
||||
}
|
||||
}
|
||||
|
||||
writer
|
||||
.finish()
|
||||
.map_err(|e| err("could not finish the archive", e))?;
|
||||
Ok(manifest)
|
||||
}
|
||||
|
||||
fn open_archive(path: &Path) -> Result<zip::ZipArchive<fs::File>, String> {
|
||||
let file = fs::File::open(path).map_err(|e| import_err(format!("could not open it: {e}")))?;
|
||||
zip::ZipArchive::new(file).map_err(|e| import_err(format!("it is not a readable archive: {e}")))
|
||||
}
|
||||
|
||||
fn read_entry<R: Read + Seek>(
|
||||
archive: &mut zip::ZipArchive<R>,
|
||||
name: &str,
|
||||
) -> Result<String, String> {
|
||||
let mut entry = archive
|
||||
.by_name(name)
|
||||
.map_err(|_| import_err(format!("the archive has no {name}")))?;
|
||||
let mut body = String::new();
|
||||
entry
|
||||
.read_to_string(&mut body)
|
||||
.map_err(|e| import_err(format!("{name} could not be read: {e}")))?;
|
||||
Ok(body)
|
||||
}
|
||||
|
||||
/// What an archive holds, without creating anything.
|
||||
pub fn preview(path: &Path) -> Result<PortablePreview, String> {
|
||||
let mut archive = open_archive(path)?;
|
||||
let manifest_json = read_entry(&mut archive, MANIFEST_ENTRY)?;
|
||||
let manifest: PortableManifest = serde_json::from_str(&manifest_json)
|
||||
.map_err(|e| import_err(format!("its manifest is malformed: {e}")))?;
|
||||
if manifest.format_version > FORMAT_VERSION {
|
||||
return Err(crate::backend_error_with_detail(
|
||||
"PROFILE_IMPORT_TOO_NEW",
|
||||
manifest.format_version.to_string(),
|
||||
));
|
||||
}
|
||||
let extra: serde_json::Value = serde_json::from_str(&manifest_json).unwrap_or_default();
|
||||
let profile_json = read_entry(&mut archive, PROFILE_ENTRY)?;
|
||||
let stored: serde_json::Value = serde_json::from_str(&profile_json)
|
||||
.map_err(|e| import_err(format!("its profile is malformed: {e}")))?;
|
||||
|
||||
Ok(PortablePreview {
|
||||
manifest,
|
||||
proxy_name: extra["proxy_name"].as_str().map(str::to_string),
|
||||
group_name: extra["group_name"].as_str().map(str::to_string),
|
||||
tags: stored["tags"]
|
||||
.as_array()
|
||||
.map(|tags| {
|
||||
tags
|
||||
.iter()
|
||||
.filter_map(|tag| tag.as_str().map(str::to_string))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
})
|
||||
}
|
||||
|
||||
/// The profile an import should create: the archive's configuration under a
|
||||
/// fresh id and the given name, with nothing carried over from the exporting
|
||||
/// machine.
|
||||
pub fn imported_profile(
|
||||
archive_profile: &serde_json::Value,
|
||||
name: &str,
|
||||
) -> Result<BrowserProfile, String> {
|
||||
let mut value = archive_profile.clone();
|
||||
let object = value
|
||||
.as_object_mut()
|
||||
.ok_or_else(|| import_err("its profile is not an object"))?;
|
||||
object.insert(
|
||||
"id".to_string(),
|
||||
serde_json::json!(uuid::Uuid::new_v4().to_string()),
|
||||
);
|
||||
object.insert("name".to_string(), serde_json::json!(name));
|
||||
object.insert("process_id".to_string(), serde_json::Value::Null);
|
||||
object.insert("last_launch".to_string(), serde_json::Value::Null);
|
||||
object.insert("last_sync".to_string(), serde_json::Value::Null);
|
||||
object.insert("encryption_salt".to_string(), serde_json::Value::Null);
|
||||
object.insert("password_protected".to_string(), serde_json::json!(false));
|
||||
object.insert("temporary".to_string(), serde_json::json!(false));
|
||||
object.insert("sync_mode".to_string(), serde_json::json!("Disabled"));
|
||||
object.insert(
|
||||
"host_os".to_string(),
|
||||
serde_json::json!(crate::profile::types::get_host_os()),
|
||||
);
|
||||
object.insert(
|
||||
"created_at".to_string(),
|
||||
serde_json::json!(crate::proxy_manager::now_secs()),
|
||||
);
|
||||
object.insert(
|
||||
"updated_at".to_string(),
|
||||
serde_json::json!(crate::proxy_manager::now_secs()),
|
||||
);
|
||||
serde_json::from_value(value).map_err(|e| import_err(format!("its profile is unusable: {e}")))
|
||||
}
|
||||
|
||||
/// Extract the archive's `data/` into `data_dir`.
|
||||
///
|
||||
/// Every entry is checked to land inside `data_dir`: an archive is untrusted
|
||||
/// input, and `../` in a name is how an extraction writes over a user's files.
|
||||
pub fn extract_data(path: &Path, data_dir: &Path) -> Result<usize, String> {
|
||||
let mut archive = open_archive(path)?;
|
||||
fs::create_dir_all(data_dir)
|
||||
.map_err(|e| import_err(format!("could not create the profile directory: {e}")))?;
|
||||
let root = data_dir
|
||||
.canonicalize()
|
||||
.map_err(|e| import_err(format!("could not resolve the profile directory: {e}")))?;
|
||||
|
||||
let mut restored = 0;
|
||||
for index in 0..archive.len() {
|
||||
let mut entry = archive
|
||||
.by_index(index)
|
||||
.map_err(|e| import_err(format!("could not read entry {index}: {e}")))?;
|
||||
if entry.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let Some(name) = entry.enclosed_name() else {
|
||||
return Err(crate::backend_error("PROFILE_IMPORT_UNSAFE_ARCHIVE"));
|
||||
};
|
||||
let name = name.to_string_lossy().replace('\\', "/");
|
||||
let Some(relative) = name.strip_prefix(DATA_PREFIX) else {
|
||||
continue;
|
||||
};
|
||||
if relative.is_empty() || is_excluded(relative) {
|
||||
continue;
|
||||
}
|
||||
let destination = root.join(relative);
|
||||
if !destination.starts_with(&root) {
|
||||
return Err(crate::backend_error("PROFILE_IMPORT_UNSAFE_ARCHIVE"));
|
||||
}
|
||||
if let Some(parent) = destination.parent() {
|
||||
fs::create_dir_all(parent)
|
||||
.map_err(|e| import_err(format!("could not create {}: {e}", parent.display())))?;
|
||||
}
|
||||
let mut file = fs::File::create(&destination)
|
||||
.map_err(|e| import_err(format!("could not write {}: {e}", destination.display())))?;
|
||||
std::io::copy(&mut entry, &mut file)
|
||||
.map_err(|e| import_err(format!("could not write {}: {e}", destination.display())))?;
|
||||
restored += 1;
|
||||
}
|
||||
Ok(restored)
|
||||
}
|
||||
|
||||
/// Pick a name no live profile carries: the archive's own when it is free,
|
||||
/// otherwise `name (imported)`, `name (imported 2)`, and so on.
|
||||
pub fn unique_imported_name(name: &str, taken: &[String]) -> String {
|
||||
let normalized: Vec<String> = taken.iter().map(|n| n.trim().to_lowercase()).collect();
|
||||
let is_taken = |candidate: &str| normalized.contains(&candidate.trim().to_lowercase());
|
||||
if !is_taken(name) {
|
||||
return name.to_string();
|
||||
}
|
||||
let mut attempt = 1u32;
|
||||
loop {
|
||||
let candidate = if attempt == 1 {
|
||||
format!("{name} (imported)")
|
||||
} else {
|
||||
format!("{name} (imported {attempt})")
|
||||
};
|
||||
if !is_taken(&candidate) {
|
||||
return candidate;
|
||||
}
|
||||
attempt += 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// Write an export of `profile_id` to `destination`.
|
||||
#[tauri::command]
|
||||
pub async fn export_profile(
|
||||
profile_id: String,
|
||||
destination: String,
|
||||
include_data: Option<bool>,
|
||||
) -> Result<PortableManifest, String> {
|
||||
let manager = crate::profile::ProfileManager::instance();
|
||||
let profile = manager
|
||||
.list_profiles()
|
||||
.map_err(|e| err("could not read the profiles", e))?
|
||||
.into_iter()
|
||||
.find(|p| p.id.to_string() == profile_id)
|
||||
.ok_or_else(|| crate::backend_error("PROFILE_NOT_FOUND"))?;
|
||||
// An export reads the whole profile directory; a browser writing to it at
|
||||
// the same time produces an archive of half-written databases.
|
||||
if profile
|
||||
.process_id
|
||||
.is_some_and(crate::proxy_storage::is_process_running)
|
||||
{
|
||||
return Err(crate::backend_error("PROFILE_RUNNING"));
|
||||
}
|
||||
|
||||
let data_dir = manager
|
||||
.get_profiles_dir()
|
||||
.join(profile.id.to_string())
|
||||
.join("profile");
|
||||
let proxy_name = profile.proxy_id.as_deref().and_then(|id| {
|
||||
crate::proxy_manager::PROXY_MANAGER
|
||||
.get_stored_proxies()
|
||||
.into_iter()
|
||||
.find(|proxy| proxy.id == id)
|
||||
.map(|proxy| proxy.name)
|
||||
});
|
||||
let group_name = profile.group_id.as_deref().and_then(|id| {
|
||||
let manager = crate::group_manager::GROUP_MANAGER
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
manager
|
||||
.get_all_groups()
|
||||
.ok()?
|
||||
.into_iter()
|
||||
.find(|group| group.id == id)
|
||||
.map(|group| group.name)
|
||||
});
|
||||
|
||||
export_to(
|
||||
&profile,
|
||||
&data_dir,
|
||||
Path::new(&destination),
|
||||
include_data.unwrap_or(true),
|
||||
proxy_name,
|
||||
group_name,
|
||||
)
|
||||
}
|
||||
|
||||
/// What an archive holds, so the user can decide before anything is created.
|
||||
#[tauri::command]
|
||||
pub fn preview_profile_archive(path: String) -> Result<PortablePreview, String> {
|
||||
preview(Path::new(&path))
|
||||
}
|
||||
|
||||
/// Create a profile from an archive.
|
||||
#[tauri::command]
|
||||
pub async fn import_profile_archive(
|
||||
path: String,
|
||||
name: Option<String>,
|
||||
) -> Result<BrowserProfile, String> {
|
||||
let archive_path = Path::new(&path);
|
||||
let details = preview(archive_path)?;
|
||||
let manager = crate::profile::ProfileManager::instance();
|
||||
let existing = manager
|
||||
.list_profiles()
|
||||
.map_err(|e| import_err(format!("could not read the profiles: {e}")))?;
|
||||
let taken: Vec<String> = existing.iter().map(|p| p.name.clone()).collect();
|
||||
let wanted = name
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|n| !n.is_empty())
|
||||
.unwrap_or(&details.manifest.profile_name);
|
||||
if wanted.is_empty() {
|
||||
return Err(crate::backend_error("NAME_CANNOT_BE_EMPTY"));
|
||||
}
|
||||
|
||||
let mut archive = open_archive(archive_path)?;
|
||||
let stored: serde_json::Value =
|
||||
serde_json::from_str(&read_entry(&mut archive, PROFILE_ENTRY)?)
|
||||
.map_err(|e| import_err(format!("its profile is malformed: {e}")))?;
|
||||
drop(archive);
|
||||
|
||||
let profile = imported_profile(&stored, &unique_imported_name(wanted, &taken))?;
|
||||
let data_dir = manager
|
||||
.get_profiles_dir()
|
||||
.join(profile.id.to_string())
|
||||
.join("profile");
|
||||
fs::create_dir_all(&data_dir)
|
||||
.map_err(|e| import_err(format!("could not create the profile directory: {e}")))?;
|
||||
|
||||
if details.manifest.includes_data {
|
||||
if let Err(e) = extract_data(archive_path, &data_dir) {
|
||||
// Nothing half-imported is left behind: the profile was never saved, so
|
||||
// removing its directory removes every trace of the attempt.
|
||||
let _ = fs::remove_dir_all(data_dir.parent().unwrap_or(&data_dir));
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
|
||||
manager.save_profile(&profile).map_err(|e| {
|
||||
let _ = fs::remove_dir_all(data_dir.parent().unwrap_or(&data_dir));
|
||||
import_err(format!("could not save it: {e}"))
|
||||
})?;
|
||||
let _ = crate::events::emit("profiles-changed", serde_json::json!({}));
|
||||
Ok(profile)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn sample() -> BrowserProfile {
|
||||
BrowserProfile {
|
||||
id: uuid::Uuid::new_v4(),
|
||||
name: "Shop".to_string(),
|
||||
browser: "wayfern".to_string(),
|
||||
version: "152.0.7977.64".to_string(),
|
||||
proxy_id: Some("proxy-1".to_string()),
|
||||
group_id: Some("group-1".to_string()),
|
||||
tags: vec!["eu".to_string()],
|
||||
process_id: Some(4242),
|
||||
last_launch: Some(1000),
|
||||
created_by_email: Some("someone@example.com".to_string()),
|
||||
..BrowserProfile::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn seed_data(dir: &Path) {
|
||||
fs::create_dir_all(dir.join("Default/Network")).unwrap();
|
||||
fs::write(dir.join("Default/Network/Cookies"), b"cookie-db").unwrap();
|
||||
fs::write(dir.join("Local State"), b"{}").unwrap();
|
||||
fs::create_dir_all(dir.join("Default/Cache")).unwrap();
|
||||
fs::write(dir.join("Default/Cache/data_0"), vec![0u8; 4096]).unwrap();
|
||||
fs::write(dir.join("wayfern-identity.json"), b"{}").unwrap();
|
||||
fs::write(dir.join("SingletonLock"), b"lock").unwrap();
|
||||
}
|
||||
|
||||
fn entries(path: &Path) -> Vec<String> {
|
||||
let mut archive = open_archive(path).unwrap();
|
||||
(0..archive.len())
|
||||
.map(|i| archive.by_index(i).unwrap().name().to_string())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_export_carries_the_profile_and_its_data_but_not_the_machine() {
|
||||
let root = TempDir::new().unwrap();
|
||||
let data = root.path().join("profile");
|
||||
seed_data(&data);
|
||||
let archive = root.path().join("shop.donutprofile");
|
||||
let profile = sample();
|
||||
|
||||
let manifest = export_to(
|
||||
&profile,
|
||||
&data,
|
||||
&archive,
|
||||
true,
|
||||
Some("Residential EU".to_string()),
|
||||
Some("Clients".to_string()),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(manifest.includes_data);
|
||||
assert_eq!(manifest.profile_name, "Shop");
|
||||
|
||||
let names = entries(&archive);
|
||||
assert!(names.contains(&"manifest.json".to_string()));
|
||||
assert!(names.contains(&"profile.json".to_string()));
|
||||
assert!(names.contains(&"data/Default/Network/Cookies".to_string()));
|
||||
assert!(
|
||||
!names.iter().any(|n| n.contains("Cache")),
|
||||
"caches are rebuilt by the browser and must not travel: {names:?}"
|
||||
);
|
||||
assert!(
|
||||
!names.iter().any(|n| n.contains("wayfern-identity.json")),
|
||||
"the launcher rewrites its own documents every launch: {names:?}"
|
||||
);
|
||||
assert!(!names.iter().any(|n| n.contains("SingletonLock")));
|
||||
|
||||
let preview = preview(&archive).unwrap();
|
||||
assert_eq!(preview.proxy_name.as_deref(), Some("Residential EU"));
|
||||
assert_eq!(preview.group_name.as_deref(), Some("Clients"));
|
||||
assert_eq!(preview.tags, vec!["eu".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_exported_configuration_drops_what_belongs_to_this_machine() {
|
||||
let config = exportable_config(&sample());
|
||||
for gone in [
|
||||
"id",
|
||||
"process_id",
|
||||
"last_launch",
|
||||
"proxy_id",
|
||||
"group_id",
|
||||
"created_by_email",
|
||||
"encryption_salt",
|
||||
] {
|
||||
assert!(config.get(gone).is_none(), "{gone} must not travel");
|
||||
}
|
||||
assert_eq!(config["password_protected"], serde_json::json!(false));
|
||||
assert_eq!(config["sync_mode"], serde_json::json!("Disabled"));
|
||||
assert_eq!(config["version"], serde_json::json!("152.0.7977.64"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_password_protected_profile_exports_its_configuration_and_says_why_not_its_data() {
|
||||
let root = TempDir::new().unwrap();
|
||||
let data = root.path().join("profile");
|
||||
seed_data(&data);
|
||||
let archive = root.path().join("locked.donutprofile");
|
||||
let mut profile = sample();
|
||||
profile.password_protected = true;
|
||||
|
||||
let manifest = export_to(&profile, &data, &archive, true, None, None).unwrap();
|
||||
assert!(!manifest.includes_data);
|
||||
assert!(manifest
|
||||
.data_omitted_reason
|
||||
.as_deref()
|
||||
.unwrap()
|
||||
.contains("password protected"));
|
||||
assert!(!entries(&archive).iter().any(|n| n.starts_with("data/")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_import_is_a_new_profile_that_owes_nothing_to_the_exporter() {
|
||||
let source = sample();
|
||||
let config = exportable_config(&source);
|
||||
let imported = imported_profile(&config, "Shop (imported)").unwrap();
|
||||
|
||||
assert_ne!(imported.id, source.id);
|
||||
assert_eq!(imported.name, "Shop (imported)");
|
||||
assert_eq!(imported.version, source.version);
|
||||
assert_eq!(imported.tags, source.tags);
|
||||
assert_eq!(imported.process_id, None);
|
||||
assert_eq!(imported.proxy_id, None, "a proxy id is local to a machine");
|
||||
assert_eq!(imported.group_id, None);
|
||||
assert!(!imported.password_protected);
|
||||
assert!(!imported.temporary);
|
||||
assert!(imported.created_at.is_some());
|
||||
|
||||
// Twice from one archive gives two profiles, not a conflict.
|
||||
let again = imported_profile(&config, "Shop (imported)").unwrap();
|
||||
assert_ne!(again.id, imported.id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extraction_restores_the_data_and_refuses_to_escape_the_profile_directory() {
|
||||
let root = TempDir::new().unwrap();
|
||||
let data = root.path().join("profile");
|
||||
seed_data(&data);
|
||||
let archive = root.path().join("shop.donutprofile");
|
||||
export_to(&sample(), &data, &archive, true, None, None).unwrap();
|
||||
|
||||
let restored_dir = root.path().join("restored");
|
||||
let restored = extract_data(&archive, &restored_dir).unwrap();
|
||||
assert!(restored >= 2);
|
||||
assert_eq!(
|
||||
fs::read(restored_dir.join("Default/Network/Cookies")).unwrap(),
|
||||
b"cookie-db"
|
||||
);
|
||||
assert!(!restored_dir.join("Default/Cache/data_0").exists());
|
||||
|
||||
// A hand-made archive with a traversing entry is refused outright.
|
||||
let hostile = root.path().join("hostile.donutprofile");
|
||||
{
|
||||
let file = fs::File::create(&hostile).unwrap();
|
||||
let mut writer = zip::ZipWriter::new(file);
|
||||
let options: zip::write::FileOptions<'_, ()> = zip::write::FileOptions::default();
|
||||
writer.start_file("manifest.json", options).unwrap();
|
||||
writer.write_all(b"{}").unwrap();
|
||||
writer.start_file("data/../../escaped", options).unwrap();
|
||||
writer.write_all(b"nope").unwrap();
|
||||
writer.finish().unwrap();
|
||||
}
|
||||
let target = root.path().join("target");
|
||||
assert!(extract_data(&hostile, &target)
|
||||
.unwrap_err()
|
||||
.contains("PROFILE_IMPORT_UNSAFE_ARCHIVE"));
|
||||
assert!(!root.path().join("escaped").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_archive_from_a_newer_build_is_refused_by_name() {
|
||||
let root = TempDir::new().unwrap();
|
||||
let archive = root.path().join("future.donutprofile");
|
||||
{
|
||||
let file = fs::File::create(&archive).unwrap();
|
||||
let mut writer = zip::ZipWriter::new(file);
|
||||
let options: zip::write::FileOptions<'_, ()> = zip::write::FileOptions::default();
|
||||
writer.start_file("manifest.json", options).unwrap();
|
||||
writer
|
||||
.write_all(
|
||||
serde_json::json!({
|
||||
"format_version": FORMAT_VERSION + 1,
|
||||
"exported_by": "Donut Browser 99.0.0",
|
||||
"exported_at": 1,
|
||||
"profile_name": "Future",
|
||||
"browser": "wayfern",
|
||||
"version": "999",
|
||||
"includes_data": false,
|
||||
})
|
||||
.to_string()
|
||||
.as_bytes(),
|
||||
)
|
||||
.unwrap();
|
||||
writer.finish().unwrap();
|
||||
}
|
||||
assert!(preview(&archive)
|
||||
.unwrap_err()
|
||||
.contains("PROFILE_IMPORT_TOO_NEW"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_that_is_not_an_archive_is_a_coded_error_not_a_panic() {
|
||||
let root = TempDir::new().unwrap();
|
||||
let bogus = root.path().join("notes.txt");
|
||||
fs::write(&bogus, b"just some text").unwrap();
|
||||
assert!(preview(&bogus)
|
||||
.unwrap_err()
|
||||
.contains("PROFILE_IMPORT_FAILED"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,917 @@
|
||||
//! Recoverable delete for profiles.
|
||||
//!
|
||||
//! A deleted profile is moved to `<data root>/trash/<profile_id>/` instead of
|
||||
//! being destroyed, so an accidental delete of a profile that carries logins
|
||||
//! can be undone. Each entry holds:
|
||||
//!
|
||||
//! - `profile.json`: the full `BrowserProfile` at the moment of deletion.
|
||||
//! - `manifest.json`: when it was trashed, when it expires, how big it is.
|
||||
//! - `profile/`: the profile's own data directory, moved as is. Chromium's
|
||||
//! cache-only directories are pruned first; the browser rebuilds them on
|
||||
//! the next launch, so keeping them would only make the trash heavy.
|
||||
//!
|
||||
//! A password-protected profile is moved in its encrypted at-rest form and
|
||||
//! stays protected while it sits here. Ephemeral profiles never land here;
|
||||
//! their data lives in RAM and is gone the moment the browser exits.
|
||||
//!
|
||||
//! From the cloud's point of view a trashed profile is deleted: the sync
|
||||
//! tombstone is written by the same path a permanent delete uses. Restoring
|
||||
//! re-registers the profile under its original id and routes it through the
|
||||
//! normal sync-enable path so it wins the stale tombstone.
|
||||
|
||||
use crate::profile::types::BrowserProfile;
|
||||
use crate::profile::ProfileManager;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashSet;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Mutex, MutexGuard};
|
||||
|
||||
pub const DEFAULT_RETENTION_DAYS: u32 = 30;
|
||||
pub const MIN_RETENTION_DAYS: u32 = 1;
|
||||
pub const MAX_RETENTION_DAYS: u32 = 365;
|
||||
/// How often expired entries are swept while the app runs.
|
||||
pub const PURGE_INTERVAL_SECS: u64 = 6 * 60 * 60;
|
||||
|
||||
const SECS_PER_DAY: u64 = 24 * 60 * 60;
|
||||
const PROFILE_FILE: &str = "profile.json";
|
||||
const MANIFEST_FILE: &str = "manifest.json";
|
||||
const DATA_DIR: &str = "profile";
|
||||
const METADATA_FILE: &str = "metadata.json";
|
||||
const RESTORED_SUFFIX: &str = "(restored)";
|
||||
|
||||
/// Chromium directories that only ever hold caches, relative to the profile
|
||||
/// data directory. Every one of them is recreated by the browser on demand.
|
||||
const CACHE_DIRS: [&str; 8] = [
|
||||
"Cache",
|
||||
"Code Cache",
|
||||
"GPUCache",
|
||||
"GrShaderCache",
|
||||
"ShaderCache",
|
||||
"DawnCache",
|
||||
"Service Worker/CacheStorage",
|
||||
"Service Worker/ScriptCache",
|
||||
];
|
||||
|
||||
/// Serialises every trash mutation so a restore cannot interleave with a
|
||||
/// purge of the same entry.
|
||||
static TRASH_MUTATION: Mutex<()> = Mutex::new(());
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TrashManifest {
|
||||
pub deleted_at: u64,
|
||||
pub expires_at: u64,
|
||||
pub size_bytes: u64,
|
||||
pub original_name: String,
|
||||
}
|
||||
|
||||
/// What the Trash page shows for one entry.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TrashedProfileSummary {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub browser: String,
|
||||
pub version: String,
|
||||
pub deleted_at: u64,
|
||||
pub expires_at: u64,
|
||||
pub size_bytes: u64,
|
||||
#[serde(default)]
|
||||
pub group_id: Option<String>,
|
||||
pub password_protected: bool,
|
||||
}
|
||||
|
||||
pub fn trash_dir() -> PathBuf {
|
||||
crate::app_dirs::data_dir().join("trash")
|
||||
}
|
||||
|
||||
pub fn mutation_lock() -> MutexGuard<'static, ()> {
|
||||
TRASH_MUTATION
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||
}
|
||||
|
||||
pub fn clamp_retention_days(days: u32) -> u32 {
|
||||
days.clamp(MIN_RETENTION_DAYS, MAX_RETENTION_DAYS)
|
||||
}
|
||||
|
||||
/// The retention the user configured, already clamped to the allowed range.
|
||||
pub fn configured_retention_days() -> u32 {
|
||||
crate::settings_manager::SettingsManager::instance()
|
||||
.load_settings()
|
||||
.map(|settings| clamp_retention_days(settings.trash_retention_days))
|
||||
.unwrap_or(DEFAULT_RETENTION_DAYS)
|
||||
}
|
||||
|
||||
fn err_internal(e: impl std::fmt::Display) -> String {
|
||||
crate::backend_error_with_detail("INTERNAL_ERROR", e)
|
||||
}
|
||||
|
||||
fn write_json<T: Serialize>(path: &Path, value: &T) -> Result<(), String> {
|
||||
let json = serde_json::to_string_pretty(value).map_err(err_internal)?;
|
||||
let tmp = path.with_extension("json.tmp");
|
||||
fs::write(&tmp, json).map_err(err_internal)?;
|
||||
fs::rename(&tmp, path).map_err(err_internal)
|
||||
}
|
||||
|
||||
fn read_json<T: for<'de> Deserialize<'de>>(path: &Path) -> Result<T, String> {
|
||||
let content = fs::read_to_string(path).map_err(err_internal)?;
|
||||
serde_json::from_str(&content).map_err(err_internal)
|
||||
}
|
||||
|
||||
/// Remove the cache-only directories from a profile data directory. Returns
|
||||
/// the directories that were actually removed.
|
||||
pub fn prune_cache_dirs(data_dir: &Path) -> Vec<PathBuf> {
|
||||
let mut removed = Vec::new();
|
||||
for relative in CACHE_DIRS {
|
||||
let dir = data_dir.join(relative);
|
||||
if !dir.is_dir() {
|
||||
continue;
|
||||
}
|
||||
match fs::remove_dir_all(&dir) {
|
||||
Ok(()) => removed.push(dir),
|
||||
Err(e) => log::warn!("Could not prune cache dir {}: {e}", dir.display()),
|
||||
}
|
||||
}
|
||||
removed
|
||||
}
|
||||
|
||||
/// Total size of every regular file under `path`. Symlinks are not followed.
|
||||
pub fn dir_size(path: &Path) -> u64 {
|
||||
let Ok(entries) = fs::read_dir(path) else {
|
||||
return 0;
|
||||
};
|
||||
entries
|
||||
.flatten()
|
||||
.map(|entry| {
|
||||
let path = entry.path();
|
||||
match fs::symlink_metadata(&path) {
|
||||
Ok(meta) if meta.is_dir() => dir_size(&path),
|
||||
Ok(meta) if meta.is_file() => meta.len(),
|
||||
_ => 0,
|
||||
}
|
||||
})
|
||||
.sum()
|
||||
}
|
||||
|
||||
fn copy_dir_recursive(from: &Path, to: &Path) -> std::io::Result<()> {
|
||||
fs::create_dir_all(to)?;
|
||||
for entry in fs::read_dir(from)? {
|
||||
let entry = entry?;
|
||||
let source = entry.path();
|
||||
let target = to.join(entry.file_name());
|
||||
let meta = fs::symlink_metadata(&source)?;
|
||||
if meta.is_dir() {
|
||||
copy_dir_recursive(&source, &target)?;
|
||||
} else if meta.is_file() {
|
||||
fs::copy(&source, &target)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Move a directory: a rename when both sides share a volume, otherwise a
|
||||
/// copy followed by removal of the source. A failed copy leaves the source
|
||||
/// untouched and no half-written target behind.
|
||||
pub fn move_dir(from: &Path, to: &Path) -> std::io::Result<()> {
|
||||
if let Some(parent) = to.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
match fs::rename(from, to) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(rename_error) => {
|
||||
log::info!(
|
||||
"Rename of {} failed ({rename_error}); copying instead",
|
||||
from.display()
|
||||
);
|
||||
if let Err(copy_error) = copy_dir_recursive(from, to) {
|
||||
let _ = fs::remove_dir_all(to);
|
||||
return Err(copy_error);
|
||||
}
|
||||
fs::remove_dir_all(from)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Move a profile's directory into the trash and record when it expires.
|
||||
///
|
||||
/// `profiles_dir/<id>/` becomes `trash_root/<id>/`; `metadata.json` is
|
||||
/// replaced by `profile.json` (the struct handed in, with any process id
|
||||
/// cleared) and `manifest.json` is added. An older trash entry under the same
|
||||
/// id is dropped: ids survive a restore, so the profile being trashed now is
|
||||
/// the newer copy.
|
||||
pub fn trash_profile(
|
||||
profiles_dir: &Path,
|
||||
trash_root: &Path,
|
||||
profile: &BrowserProfile,
|
||||
retention_days: u32,
|
||||
now: u64,
|
||||
) -> Result<TrashManifest, String> {
|
||||
let id = profile.id.to_string();
|
||||
let source_dir = profiles_dir.join(&id);
|
||||
let target_dir = trash_root.join(&id);
|
||||
|
||||
fs::create_dir_all(trash_root).map_err(err_internal)?;
|
||||
if target_dir.exists() {
|
||||
fs::remove_dir_all(&target_dir).map_err(err_internal)?;
|
||||
}
|
||||
|
||||
if !profile.password_protected {
|
||||
let removed = prune_cache_dirs(&source_dir.join(DATA_DIR));
|
||||
if !removed.is_empty() {
|
||||
log::info!(
|
||||
"Pruned {} cache director{} from profile {id} before trashing",
|
||||
removed.len(),
|
||||
if removed.len() == 1 { "y" } else { "ies" }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if source_dir.exists() {
|
||||
move_dir(&source_dir, &target_dir).map_err(err_internal)?;
|
||||
} else {
|
||||
fs::create_dir_all(&target_dir).map_err(err_internal)?;
|
||||
}
|
||||
let _ = fs::remove_file(target_dir.join(METADATA_FILE));
|
||||
|
||||
let mut stored = profile.clone();
|
||||
stored.process_id = None;
|
||||
write_json(&target_dir.join(PROFILE_FILE), &stored)?;
|
||||
|
||||
let manifest = TrashManifest {
|
||||
deleted_at: now,
|
||||
expires_at: now.saturating_add(u64::from(clamp_retention_days(retention_days)) * SECS_PER_DAY),
|
||||
size_bytes: dir_size(&target_dir.join(DATA_DIR)),
|
||||
original_name: profile.name.clone(),
|
||||
};
|
||||
write_json(&target_dir.join(MANIFEST_FILE), &manifest)?;
|
||||
Ok(manifest)
|
||||
}
|
||||
|
||||
/// Read one entry. `TRASH_ENTRY_NOT_FOUND` when there is no such entry.
|
||||
pub fn read_entry(
|
||||
trash_root: &Path,
|
||||
profile_id: &str,
|
||||
) -> Result<(BrowserProfile, TrashManifest), String> {
|
||||
let entry_dir = trash_root.join(profile_id);
|
||||
let profile_file = entry_dir.join(PROFILE_FILE);
|
||||
let manifest_file = entry_dir.join(MANIFEST_FILE);
|
||||
if !profile_file.is_file() || !manifest_file.is_file() {
|
||||
return Err(crate::backend_error("TRASH_ENTRY_NOT_FOUND"));
|
||||
}
|
||||
Ok((read_json(&profile_file)?, read_json(&manifest_file)?))
|
||||
}
|
||||
|
||||
/// Every readable entry, newest deletion first. Unreadable entries are
|
||||
/// skipped with a warning rather than hiding the whole trash.
|
||||
pub fn list_entries(trash_root: &Path) -> Vec<(BrowserProfile, TrashManifest)> {
|
||||
let Ok(entries) = fs::read_dir(trash_root) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut listed: Vec<(BrowserProfile, TrashManifest)> = entries
|
||||
.flatten()
|
||||
.filter(|entry| entry.path().is_dir())
|
||||
.filter_map(|entry| {
|
||||
let name = entry.file_name();
|
||||
let id = name.to_string_lossy();
|
||||
match read_entry(trash_root, &id) {
|
||||
Ok(found) => Some(found),
|
||||
Err(e) => {
|
||||
log::warn!("Skipping unreadable trash entry {id}: {e}");
|
||||
None
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
listed.sort_by_key(|(_, manifest)| std::cmp::Reverse(manifest.deleted_at));
|
||||
listed
|
||||
}
|
||||
|
||||
pub fn summaries(trash_root: &Path) -> Vec<TrashedProfileSummary> {
|
||||
list_entries(trash_root)
|
||||
.into_iter()
|
||||
.map(|(profile, manifest)| TrashedProfileSummary {
|
||||
id: profile.id.to_string(),
|
||||
name: profile.name,
|
||||
browser: profile.browser,
|
||||
version: profile.version,
|
||||
deleted_at: manifest.deleted_at,
|
||||
expires_at: manifest.expires_at,
|
||||
size_bytes: manifest.size_bytes,
|
||||
group_id: profile.group_id,
|
||||
password_protected: profile.password_protected,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn normalized_name(name: &str) -> String {
|
||||
name.trim().to_lowercase()
|
||||
}
|
||||
|
||||
/// Pick a name that no live profile carries: the original when it is free,
|
||||
/// otherwise `name (restored)`, `name (restored 2)`, and so on.
|
||||
pub fn unique_restored_name(name: &str, taken: &HashSet<String>) -> String {
|
||||
if !taken.contains(&normalized_name(name)) {
|
||||
return name.to_string();
|
||||
}
|
||||
let mut attempt = 1u32;
|
||||
loop {
|
||||
let candidate = if attempt == 1 {
|
||||
format!("{name} {RESTORED_SUFFIX}")
|
||||
} else {
|
||||
format!(
|
||||
"{name} {} {attempt})",
|
||||
RESTORED_SUFFIX.trim_end_matches(')')
|
||||
)
|
||||
};
|
||||
if !taken.contains(&normalized_name(&candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
attempt += 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// Move a trashed profile back under `profiles_dir` and return the profile as
|
||||
/// it must be saved: same id, identity, proxy and tags; the group only when it
|
||||
/// still exists; a fresh `updated_at` so it wins any stale sync tombstone.
|
||||
///
|
||||
/// `TRASH_RESTORE_CONFLICT` when a live profile already carries the id.
|
||||
pub fn restore_profile(
|
||||
profiles_dir: &Path,
|
||||
trash_root: &Path,
|
||||
profile_id: &str,
|
||||
live_profiles: &[BrowserProfile],
|
||||
group_exists: &dyn Fn(&str) -> bool,
|
||||
now: u64,
|
||||
) -> Result<BrowserProfile, String> {
|
||||
let (mut profile, _manifest) = read_entry(trash_root, profile_id)?;
|
||||
|
||||
if live_profiles.iter().any(|live| live.id == profile.id) {
|
||||
return Err(crate::backend_error("TRASH_RESTORE_CONFLICT"));
|
||||
}
|
||||
|
||||
let target_dir = profiles_dir.join(profile_id);
|
||||
if target_dir.exists() {
|
||||
// Nothing registered lives here (a registered profile has metadata.json
|
||||
// and would have been caught above), so this is leftover garbage.
|
||||
log::warn!(
|
||||
"Removing stale directory {} before restoring profile {profile_id}",
|
||||
target_dir.display()
|
||||
);
|
||||
fs::remove_dir_all(&target_dir).map_err(err_internal)?;
|
||||
}
|
||||
|
||||
let taken: HashSet<String> = live_profiles
|
||||
.iter()
|
||||
.map(|live| normalized_name(&live.name))
|
||||
.collect();
|
||||
profile.name = unique_restored_name(&profile.name, &taken);
|
||||
if let Some(group_id) = profile.group_id.clone() {
|
||||
if !group_exists(&group_id) {
|
||||
profile.group_id = None;
|
||||
}
|
||||
}
|
||||
profile.process_id = None;
|
||||
profile.updated_at = Some(now);
|
||||
|
||||
let entry_dir = trash_root.join(profile_id);
|
||||
move_dir(&entry_dir, &target_dir).map_err(err_internal)?;
|
||||
let _ = fs::remove_file(target_dir.join(PROFILE_FILE));
|
||||
let _ = fs::remove_file(target_dir.join(MANIFEST_FILE));
|
||||
write_json(&target_dir.join(METADATA_FILE), &profile)?;
|
||||
Ok(profile)
|
||||
}
|
||||
|
||||
/// Destroy one entry for good. `TRASH_ENTRY_NOT_FOUND` when absent.
|
||||
pub fn purge_entry(trash_root: &Path, profile_id: &str) -> Result<(), String> {
|
||||
let entry_dir = trash_root.join(profile_id);
|
||||
if !entry_dir.is_dir() {
|
||||
return Err(crate::backend_error("TRASH_ENTRY_NOT_FOUND"));
|
||||
}
|
||||
fs::remove_dir_all(&entry_dir).map_err(err_internal)
|
||||
}
|
||||
|
||||
/// Destroy every entry. Returns the ids that were removed.
|
||||
pub fn purge_all(trash_root: &Path) -> Result<Vec<String>, String> {
|
||||
let ids: Vec<String> = list_entries(trash_root)
|
||||
.into_iter()
|
||||
.map(|(profile, _)| profile.id.to_string())
|
||||
.collect();
|
||||
for id in &ids {
|
||||
purge_entry(trash_root, id)?;
|
||||
}
|
||||
Ok(ids)
|
||||
}
|
||||
|
||||
/// Destroy every entry whose expiry has passed. Returns the ids removed.
|
||||
pub fn purge_expired(trash_root: &Path, now: u64) -> Vec<String> {
|
||||
list_entries(trash_root)
|
||||
.into_iter()
|
||||
.filter(|(_, manifest)| manifest.expires_at <= now)
|
||||
.filter_map(|(profile, _)| {
|
||||
let id = profile.id.to_string();
|
||||
match purge_entry(trash_root, &id) {
|
||||
Ok(()) => Some(id),
|
||||
Err(e) => {
|
||||
log::warn!("Could not purge expired trash entry {id}: {e}");
|
||||
None
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A profile whose browser process is alive on this machine cannot be
|
||||
/// trashed: its data directory is in use. A stale process id (the browser
|
||||
/// crashed) does not count, and a cross-OS profile can never be running here.
|
||||
pub fn is_running_locally(profile: &BrowserProfile) -> bool {
|
||||
profile
|
||||
.process_id
|
||||
.is_some_and(crate::proxy_storage::is_process_running)
|
||||
&& !profile.is_cross_os()
|
||||
}
|
||||
|
||||
fn command_error(e: Box<dyn std::error::Error>, context: &str) -> String {
|
||||
let msg = e.to_string();
|
||||
if msg.starts_with('{') {
|
||||
msg
|
||||
} else {
|
||||
format!("{context}: {msg}")
|
||||
}
|
||||
}
|
||||
|
||||
/// Sweep expired entries now and again every `PURGE_INTERVAL_SECS`.
|
||||
pub fn start_expiry_sweeper() {
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(PURGE_INTERVAL_SECS));
|
||||
loop {
|
||||
interval.tick().await;
|
||||
let purged = ProfileManager::instance().purge_expired_trash();
|
||||
if purged > 0 {
|
||||
log::info!(
|
||||
"Purged {purged} expired trash entr{}",
|
||||
if purged == 1 { "y" } else { "ies" }
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn list_trashed_profiles() -> Result<Vec<TrashedProfileSummary>, String> {
|
||||
Ok(summaries(&trash_dir()))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn restore_trashed_profile(
|
||||
app_handle: tauri::AppHandle,
|
||||
profile_id: String,
|
||||
) -> Result<BrowserProfile, String> {
|
||||
let manager = ProfileManager::instance();
|
||||
let mut profile = manager
|
||||
.restore_trashed_profile(&profile_id)
|
||||
.map_err(|e| command_error(e, "Failed to restore profile"))?;
|
||||
|
||||
if profile.is_sync_enabled() {
|
||||
// The cloud saw a delete (a tombstone was written when the profile was
|
||||
// trashed). Re-enabling through the normal path clears that tombstone
|
||||
// and queues the re-upload. When that path refuses (sync no longer
|
||||
// configured, a cross-OS copy), sync is switched off on the restored
|
||||
// profile so the next reconcile keeps the local copy instead of
|
||||
// honouring the tombstone.
|
||||
let mode = if profile.is_encrypted_sync() {
|
||||
"Encrypted"
|
||||
} else {
|
||||
"Regular"
|
||||
};
|
||||
if let Err(e) =
|
||||
crate::sync::set_profile_sync_mode(app_handle.clone(), profile_id.clone(), mode.to_string())
|
||||
.await
|
||||
{
|
||||
log::warn!("Restored profile {profile_id} could not re-enable sync ({e}); leaving sync off");
|
||||
profile.sync_mode = crate::profile::types::SyncMode::Disabled;
|
||||
manager
|
||||
.save_profile(&profile)
|
||||
.map_err(|e| command_error(e, "Failed to save restored profile"))?;
|
||||
let _ = crate::events::emit_empty("profiles-changed");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(profile)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn purge_trashed_profile(profile_id: String) -> Result<(), String> {
|
||||
ProfileManager::instance()
|
||||
.purge_trashed_profile(&profile_id)
|
||||
.map_err(|e| command_error(e, "Failed to delete trashed profile"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn empty_trash() -> Result<usize, String> {
|
||||
ProfileManager::instance()
|
||||
.empty_trash()
|
||||
.map_err(|e| command_error(e, "Failed to empty trash"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::wayfern_manager::WayfernConfig;
|
||||
use tempfile::TempDir;
|
||||
|
||||
const NOW: u64 = 1_700_000_000;
|
||||
|
||||
fn sample_profile(name: &str) -> BrowserProfile {
|
||||
BrowserProfile {
|
||||
id: uuid::Uuid::new_v4(),
|
||||
name: name.to_string(),
|
||||
browser: "wayfern".to_string(),
|
||||
version: "150.0.7871.100".to_string(),
|
||||
proxy_id: Some("proxy-1".to_string()),
|
||||
group_id: Some("group-1".to_string()),
|
||||
tags: vec!["shop".to_string(), "eu".to_string()],
|
||||
release_type: "stable".to_string(),
|
||||
wayfern_config: Some(WayfernConfig {
|
||||
identity_id: Some("identity-42".to_string()),
|
||||
identity_overrides: Some(r#"{"userAgent":"custom"}"#.to_string()),
|
||||
location: Some(r#"{"timezone":"Europe/Berlin"}"#.to_string()),
|
||||
..WayfernConfig::default()
|
||||
}),
|
||||
updated_at: Some(NOW - 1000),
|
||||
..BrowserProfile::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Lay out `profiles/<id>/{metadata.json, profile/...}` the way the app does.
|
||||
fn seed_profile(root: &Path, profile: &BrowserProfile, with_caches: bool) -> PathBuf {
|
||||
let profiles_dir = root.join("profiles");
|
||||
let uuid_dir = profiles_dir.join(profile.id.to_string());
|
||||
let data_dir = uuid_dir.join("profile");
|
||||
fs::create_dir_all(data_dir.join("Default")).unwrap();
|
||||
fs::write(data_dir.join("Default").join("Cookies"), b"cookie-db").unwrap();
|
||||
fs::write(data_dir.join("Local State"), b"{}").unwrap();
|
||||
if with_caches {
|
||||
for relative in CACHE_DIRS {
|
||||
let dir = data_dir.join(relative);
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
fs::write(dir.join("blob"), vec![0u8; 512]).unwrap();
|
||||
}
|
||||
}
|
||||
fs::write(
|
||||
uuid_dir.join("metadata.json"),
|
||||
serde_json::to_string_pretty(profile).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
profiles_dir
|
||||
}
|
||||
|
||||
fn group_exists(_: &str) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trash_and_restore_round_trip_keeps_identity_and_data() {
|
||||
let root = TempDir::new().unwrap();
|
||||
let profile = sample_profile("Shop Account");
|
||||
let profiles_dir = seed_profile(root.path(), &profile, true);
|
||||
let trash_root = root.path().join("trash");
|
||||
|
||||
let manifest = trash_profile(&profiles_dir, &trash_root, &profile, 30, NOW).unwrap();
|
||||
assert_eq!(manifest.deleted_at, NOW);
|
||||
assert_eq!(manifest.expires_at, NOW + 30 * SECS_PER_DAY);
|
||||
assert_eq!(manifest.original_name, "Shop Account");
|
||||
assert!(manifest.size_bytes > 0);
|
||||
|
||||
let uuid_dir = profiles_dir.join(profile.id.to_string());
|
||||
assert!(!uuid_dir.exists(), "the live directory must be gone");
|
||||
let entry_dir = trash_root.join(profile.id.to_string());
|
||||
assert!(entry_dir.join("profile.json").is_file());
|
||||
assert!(entry_dir.join("manifest.json").is_file());
|
||||
assert!(!entry_dir.join("metadata.json").exists());
|
||||
assert_eq!(
|
||||
fs::read(entry_dir.join("profile").join("Default").join("Cookies")).unwrap(),
|
||||
b"cookie-db"
|
||||
);
|
||||
for relative in CACHE_DIRS {
|
||||
assert!(
|
||||
!entry_dir.join("profile").join(relative).exists(),
|
||||
"{relative} must be pruned before the move"
|
||||
);
|
||||
}
|
||||
|
||||
let listed = summaries(&trash_root);
|
||||
assert_eq!(listed.len(), 1);
|
||||
assert_eq!(listed[0].id, profile.id.to_string());
|
||||
assert_eq!(listed[0].name, "Shop Account");
|
||||
assert_eq!(listed[0].group_id.as_deref(), Some("group-1"));
|
||||
assert!(!listed[0].password_protected);
|
||||
|
||||
let restored = restore_profile(
|
||||
&profiles_dir,
|
||||
&trash_root,
|
||||
&profile.id.to_string(),
|
||||
&[],
|
||||
&group_exists,
|
||||
NOW + 60,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(restored.id, profile.id);
|
||||
assert_eq!(restored.name, "Shop Account");
|
||||
assert_eq!(restored.proxy_id.as_deref(), Some("proxy-1"));
|
||||
assert_eq!(restored.group_id.as_deref(), Some("group-1"));
|
||||
assert_eq!(restored.tags, vec!["shop", "eu"]);
|
||||
assert_eq!(restored.updated_at, Some(NOW + 60));
|
||||
let config = restored.wayfern_config.as_ref().unwrap();
|
||||
assert_eq!(config.identity_id.as_deref(), Some("identity-42"));
|
||||
assert_eq!(
|
||||
config.identity_overrides.as_deref(),
|
||||
Some(r#"{"userAgent":"custom"}"#)
|
||||
);
|
||||
assert_eq!(
|
||||
config.location.as_deref(),
|
||||
Some(r#"{"timezone":"Europe/Berlin"}"#)
|
||||
);
|
||||
|
||||
assert!(!entry_dir.exists(), "the trash entry must be gone");
|
||||
assert!(uuid_dir.join("metadata.json").is_file());
|
||||
assert!(!uuid_dir.join("profile.json").exists());
|
||||
assert!(!uuid_dir.join("manifest.json").exists());
|
||||
assert_eq!(
|
||||
fs::read(uuid_dir.join("profile").join("Default").join("Cookies")).unwrap(),
|
||||
b"cookie-db"
|
||||
);
|
||||
let on_disk: BrowserProfile =
|
||||
serde_json::from_str(&fs::read_to_string(uuid_dir.join("metadata.json")).unwrap()).unwrap();
|
||||
assert_eq!(on_disk.id, profile.id);
|
||||
assert_eq!(on_disk.updated_at, Some(NOW + 60));
|
||||
assert!(summaries(&trash_root).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restore_appends_suffix_when_a_live_profile_has_the_name() {
|
||||
let root = TempDir::new().unwrap();
|
||||
let profile = sample_profile("Shop Account");
|
||||
let profiles_dir = seed_profile(root.path(), &profile, false);
|
||||
let trash_root = root.path().join("trash");
|
||||
trash_profile(&profiles_dir, &trash_root, &profile, 7, NOW).unwrap();
|
||||
|
||||
let mut twin = sample_profile("shop account");
|
||||
twin.id = uuid::Uuid::new_v4();
|
||||
let mut second_twin = sample_profile("Shop Account (restored)");
|
||||
second_twin.id = uuid::Uuid::new_v4();
|
||||
|
||||
let restored = restore_profile(
|
||||
&profiles_dir,
|
||||
&trash_root,
|
||||
&profile.id.to_string(),
|
||||
&[twin, second_twin],
|
||||
&group_exists,
|
||||
NOW,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(restored.name, "Shop Account (restored 2)");
|
||||
assert_eq!(restored.id, profile.id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unique_restored_name_prefers_the_original() {
|
||||
let taken: HashSet<String> = ["other".to_string()].into_iter().collect();
|
||||
assert_eq!(unique_restored_name("Mine", &taken), "Mine");
|
||||
let taken: HashSet<String> = ["mine".to_string()].into_iter().collect();
|
||||
assert_eq!(unique_restored_name("Mine", &taken), "Mine (restored)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restore_refuses_when_a_live_profile_has_the_same_id() {
|
||||
let root = TempDir::new().unwrap();
|
||||
let profile = sample_profile("Shop Account");
|
||||
let profiles_dir = seed_profile(root.path(), &profile, false);
|
||||
let trash_root = root.path().join("trash");
|
||||
trash_profile(&profiles_dir, &trash_root, &profile, 7, NOW).unwrap();
|
||||
|
||||
let err = restore_profile(
|
||||
&profiles_dir,
|
||||
&trash_root,
|
||||
&profile.id.to_string(),
|
||||
std::slice::from_ref(&profile),
|
||||
&group_exists,
|
||||
NOW,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(err.contains("TRASH_RESTORE_CONFLICT"), "{err}");
|
||||
assert_eq!(summaries(&trash_root).len(), 1, "the entry must survive");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restore_and_purge_of_a_missing_entry_report_not_found() {
|
||||
let root = TempDir::new().unwrap();
|
||||
let trash_root = root.path().join("trash");
|
||||
let err = restore_profile(
|
||||
&root.path().join("profiles"),
|
||||
&trash_root,
|
||||
"does-not-exist",
|
||||
&[],
|
||||
&group_exists,
|
||||
NOW,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(err.contains("TRASH_ENTRY_NOT_FOUND"), "{err}");
|
||||
let err = purge_entry(&trash_root, "does-not-exist").unwrap_err();
|
||||
assert!(err.contains("TRASH_ENTRY_NOT_FOUND"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restore_clears_the_group_when_it_no_longer_exists() {
|
||||
let root = TempDir::new().unwrap();
|
||||
let profile = sample_profile("Grouped");
|
||||
let profiles_dir = seed_profile(root.path(), &profile, false);
|
||||
let trash_root = root.path().join("trash");
|
||||
trash_profile(&profiles_dir, &trash_root, &profile, 7, NOW).unwrap();
|
||||
|
||||
let restored = restore_profile(
|
||||
&profiles_dir,
|
||||
&trash_root,
|
||||
&profile.id.to_string(),
|
||||
&[],
|
||||
&|_| false,
|
||||
NOW,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(restored.group_id, None);
|
||||
assert_eq!(restored.proxy_id.as_deref(), Some("proxy-1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn password_protected_entry_is_moved_as_is() {
|
||||
let root = TempDir::new().unwrap();
|
||||
let mut profile = sample_profile("Vault");
|
||||
profile.password_protected = true;
|
||||
profile.encryption_salt = Some("salt".to_string());
|
||||
let profiles_dir = seed_profile(root.path(), &profile, true);
|
||||
let trash_root = root.path().join("trash");
|
||||
trash_profile(&profiles_dir, &trash_root, &profile, 7, NOW).unwrap();
|
||||
|
||||
let entry_dir = trash_root.join(profile.id.to_string());
|
||||
for relative in CACHE_DIRS {
|
||||
assert!(
|
||||
entry_dir.join("profile").join(relative).exists(),
|
||||
"an encrypted tree is never pruned ({relative})"
|
||||
);
|
||||
}
|
||||
assert!(summaries(&trash_root)[0].password_protected);
|
||||
|
||||
let restored = restore_profile(
|
||||
&profiles_dir,
|
||||
&trash_root,
|
||||
&profile.id.to_string(),
|
||||
&[],
|
||||
&group_exists,
|
||||
NOW,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(restored.password_protected);
|
||||
assert_eq!(restored.encryption_salt.as_deref(), Some("salt"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expiry_purge_removes_only_expired_entries() {
|
||||
let root = TempDir::new().unwrap();
|
||||
let old = sample_profile("Old");
|
||||
let fresh = sample_profile("Fresh");
|
||||
let profiles_dir = seed_profile(root.path(), &old, false);
|
||||
seed_profile(root.path(), &fresh, false);
|
||||
let trash_root = root.path().join("trash");
|
||||
trash_profile(&profiles_dir, &trash_root, &old, 1, NOW).unwrap();
|
||||
trash_profile(&profiles_dir, &trash_root, &fresh, 30, NOW).unwrap();
|
||||
assert_eq!(summaries(&trash_root).len(), 2);
|
||||
|
||||
assert!(purge_expired(&trash_root, NOW + SECS_PER_DAY - 1).is_empty());
|
||||
let purged = purge_expired(&trash_root, NOW + SECS_PER_DAY);
|
||||
assert_eq!(purged, vec![old.id.to_string()]);
|
||||
let remaining = summaries(&trash_root);
|
||||
assert_eq!(remaining.len(), 1);
|
||||
assert_eq!(remaining[0].id, fresh.id.to_string());
|
||||
assert!(!trash_root.join(old.id.to_string()).exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retention_is_clamped_to_the_allowed_range() {
|
||||
assert_eq!(clamp_retention_days(0), MIN_RETENTION_DAYS);
|
||||
assert_eq!(clamp_retention_days(30), 30);
|
||||
assert_eq!(clamp_retention_days(10_000), MAX_RETENTION_DAYS);
|
||||
let root = TempDir::new().unwrap();
|
||||
let profile = sample_profile("Clamped");
|
||||
let profiles_dir = seed_profile(root.path(), &profile, false);
|
||||
let manifest =
|
||||
trash_profile(&profiles_dir, &root.path().join("trash"), &profile, 0, NOW).unwrap();
|
||||
assert_eq!(manifest.expires_at, NOW + SECS_PER_DAY);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_trash_removes_every_entry() {
|
||||
let root = TempDir::new().unwrap();
|
||||
let first = sample_profile("First");
|
||||
let second = sample_profile("Second");
|
||||
let profiles_dir = seed_profile(root.path(), &first, false);
|
||||
seed_profile(root.path(), &second, false);
|
||||
let trash_root = root.path().join("trash");
|
||||
trash_profile(&profiles_dir, &trash_root, &first, 7, NOW).unwrap();
|
||||
trash_profile(&profiles_dir, &trash_root, &second, 7, NOW + 1).unwrap();
|
||||
|
||||
let listed = summaries(&trash_root);
|
||||
assert_eq!(listed[0].name, "Second", "newest deletion is listed first");
|
||||
let mut purged = purge_all(&trash_root).unwrap();
|
||||
purged.sort();
|
||||
let mut expected = vec![first.id.to_string(), second.id.to_string()];
|
||||
expected.sort();
|
||||
assert_eq!(purged, expected);
|
||||
assert!(summaries(&trash_root).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trashing_a_profile_again_replaces_the_older_entry() {
|
||||
let root = TempDir::new().unwrap();
|
||||
let profile = sample_profile("Twice");
|
||||
let profiles_dir = seed_profile(root.path(), &profile, false);
|
||||
let trash_root = root.path().join("trash");
|
||||
trash_profile(&profiles_dir, &trash_root, &profile, 7, NOW).unwrap();
|
||||
restore_profile(
|
||||
&profiles_dir,
|
||||
&trash_root,
|
||||
&profile.id.to_string(),
|
||||
&[],
|
||||
&group_exists,
|
||||
NOW,
|
||||
)
|
||||
.unwrap();
|
||||
let uuid_dir = profiles_dir.join(profile.id.to_string());
|
||||
fs::write(uuid_dir.join("profile").join("Local State"), b"newer").unwrap();
|
||||
// Simulate a leftover entry that a crash left behind under the same id.
|
||||
fs::create_dir_all(trash_root.join(profile.id.to_string())).unwrap();
|
||||
fs::write(trash_root.join(profile.id.to_string()).join("stale"), b"x").unwrap();
|
||||
|
||||
trash_profile(&profiles_dir, &trash_root, &profile, 7, NOW + 5).unwrap();
|
||||
let entry_dir = trash_root.join(profile.id.to_string());
|
||||
assert!(!entry_dir.join("stale").exists());
|
||||
assert_eq!(
|
||||
fs::read(entry_dir.join("profile").join("Local State")).unwrap(),
|
||||
b"newer"
|
||||
);
|
||||
assert_eq!(summaries(&trash_root).len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unreadable_entries_are_skipped_not_fatal() {
|
||||
let root = TempDir::new().unwrap();
|
||||
let profile = sample_profile("Good");
|
||||
let profiles_dir = seed_profile(root.path(), &profile, false);
|
||||
let trash_root = root.path().join("trash");
|
||||
trash_profile(&profiles_dir, &trash_root, &profile, 7, NOW).unwrap();
|
||||
let broken = trash_root.join("broken-entry");
|
||||
fs::create_dir_all(&broken).unwrap();
|
||||
fs::write(broken.join("profile.json"), b"not json").unwrap();
|
||||
fs::write(broken.join("manifest.json"), b"{}").unwrap();
|
||||
|
||||
let listed = summaries(&trash_root);
|
||||
assert_eq!(listed.len(), 1);
|
||||
assert_eq!(listed[0].name, "Good");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_dir_copies_when_a_rename_is_impossible() {
|
||||
let root = TempDir::new().unwrap();
|
||||
let from = root.path().join("from");
|
||||
fs::create_dir_all(from.join("nested")).unwrap();
|
||||
fs::write(from.join("nested").join("file"), b"payload").unwrap();
|
||||
let to = root.path().join("to");
|
||||
copy_dir_recursive(&from, &to).unwrap();
|
||||
assert_eq!(
|
||||
fs::read(to.join("nested").join("file")).unwrap(),
|
||||
b"payload"
|
||||
);
|
||||
assert_eq!(dir_size(&to), 7);
|
||||
move_dir(&from, &root.path().join("moved")).unwrap();
|
||||
assert!(!from.exists());
|
||||
assert_eq!(
|
||||
fs::read(root.path().join("moved").join("nested").join("file")).unwrap(),
|
||||
b"payload"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn running_check_uses_a_live_process() {
|
||||
let mut profile = sample_profile("Running");
|
||||
profile.process_id = Some(std::process::id());
|
||||
assert!(is_running_locally(&profile));
|
||||
// A cross-OS profile can never be running on this machine.
|
||||
profile.host_os = Some(if cfg!(target_os = "macos") {
|
||||
"linux".to_string()
|
||||
} else {
|
||||
"macos".to_string()
|
||||
});
|
||||
assert!(!is_running_locally(&profile));
|
||||
let mut idle = sample_profile("Idle");
|
||||
idle.process_id = None;
|
||||
assert!(!is_running_locally(&idle));
|
||||
}
|
||||
}
|
||||
@@ -58,6 +58,13 @@ pub struct BrowserProfile {
|
||||
pub host_os: Option<String>, // OS where profile was created ("macos", "windows", "linux")
|
||||
#[serde(default)]
|
||||
pub ephemeral: bool,
|
||||
/// A profile that exists for one automation run. REST and MCP create it,
|
||||
/// the browser stopping destroys it, and a startup sweep destroys any that
|
||||
/// outlived a crash. Always ephemeral as well, so nothing it browses ever
|
||||
/// reaches real disk. Never trashed: a disposable profile has nothing to
|
||||
/// restore, and keeping one would defeat the point of asking for it.
|
||||
#[serde(default)]
|
||||
pub temporary: bool,
|
||||
#[serde(default)]
|
||||
pub extension_group_id: Option<String>,
|
||||
#[serde(default)]
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
//! Key material for profile import.
|
||||
//!
|
||||
//! Wayfern deliberately does not use the OS keyring. Every `os_crypt_async`
|
||||
//! key provider is patched to read (or mint) `<user-data-dir>/os_crypt_key`
|
||||
//! instead, so a profile directory is self-contained and portable. See
|
||||
//! `wayfern/patches/extra/fingerprint/components-os_crypt-async-browser-*`.
|
||||
//! Wayfern keeps os_crypt key material in `<user-data-dir>/os_crypt_key`
|
||||
//! rather than the OS keyring, so a profile directory is self-contained and
|
||||
//! portable.
|
||||
//!
|
||||
//! That portability is exactly why an imported Chrome profile carries nothing:
|
||||
//! its secrets are sealed with a key held in the macOS Keychain / Windows DPAPI
|
||||
//! / the Freedesktop secret service, and Wayfern never looks there. Import has
|
||||
//! to open the source's lock and re-seal everything with Wayfern's.
|
||||
//!
|
||||
//! The on-disk format is per-platform and NOT interchangeable, matching the
|
||||
//! provider that owns each tag in the patched Chromium 151 tree:
|
||||
//! The on-disk format is per-platform and NOT interchangeable. The tag in each
|
||||
//! record selects the derivation:
|
||||
//!
|
||||
//! | Host | `os_crypt_key` | Derivation | Cipher | Tag |
|
||||
//! |---------|---------------------|-------------------------------------|--------------|-------|
|
||||
@@ -248,10 +247,10 @@ impl TargetKey {
|
||||
/// Read the existing `os_crypt_key`, or mint and persist one.
|
||||
///
|
||||
/// Writing eagerly at import time — rather than letting the first launch do
|
||||
/// it — is deliberate. The mac and Linux patches have no `else` branch when
|
||||
/// the write fails, so the browser would run on an in-memory key that dies
|
||||
/// with the process and orphans everything it wrote. Failing here instead
|
||||
/// turns that silent data loss into a visible import error.
|
||||
/// it — is deliberate: a key the browser cannot persist would live only in
|
||||
/// memory, die with the process, and orphan everything written with it.
|
||||
/// Failing here instead turns that silent data loss into a visible import
|
||||
/// error.
|
||||
pub fn ensure(user_data_dir: &Path) -> Result<Self, String> {
|
||||
let key_file = user_data_dir.join(KEY_FILE_NAME);
|
||||
|
||||
@@ -476,7 +475,7 @@ mod tests {
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
// Wayfern writes base64(16 random bytes) = 24 ASCII chars.
|
||||
// The non-Windows key file is base64(16 random bytes) = 24 ASCII chars.
|
||||
assert_eq!(contents.len(), 24);
|
||||
let text = String::from_utf8(contents).expect("ascii");
|
||||
assert!(
|
||||
|
||||
@@ -983,6 +983,7 @@ impl ProfileImporter {
|
||||
last_sync: None,
|
||||
host_os: None,
|
||||
ephemeral: false,
|
||||
temporary: false,
|
||||
extension_group_id: None,
|
||||
proxy_bypass_rules: Vec::new(),
|
||||
created_by_id: None,
|
||||
@@ -1063,6 +1064,7 @@ impl ProfileImporter {
|
||||
last_sync: None,
|
||||
host_os: Some(get_host_os()),
|
||||
ephemeral: false,
|
||||
temporary: false,
|
||||
extension_group_id: None,
|
||||
proxy_bypass_rules: Vec::new(),
|
||||
created_by_id: None,
|
||||
|
||||
@@ -0,0 +1,442 @@
|
||||
//! Handing a fleet of profiles one proxy each.
|
||||
//!
|
||||
//! Fifty profiles and fifty residential proxies is fifty dialogs by hand. This
|
||||
//! pairs them positionally instead — profile 1 to proxy 1, profile 2 to proxy
|
||||
//! 2 — and it never wraps around: when the two lists differ in length the
|
||||
//! remainder is reported rather than reused, because silently giving two
|
||||
//! profiles the same exit is the one outcome a fleet owner is buying separate
|
||||
//! proxies to avoid.
|
||||
//!
|
||||
//! The pairing rule lives here as one pure function so the dialog's preview,
|
||||
//! the counts it shows, and the assignment that is finally applied all come
|
||||
//! from the same code. The apply step takes explicit pairs, so a caller that
|
||||
//! wants a different arrangement — REST, MCP, or a user who ticked boxes by
|
||||
//! hand — is not forced through the default.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashSet;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
/// One profile and the proxy it should end up on.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||
pub struct ProxyPair {
|
||||
pub profile_id: String,
|
||||
pub proxy_id: String,
|
||||
}
|
||||
|
||||
/// A profile as the pairing rule sees it.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ProfileCandidate {
|
||||
pub id: String,
|
||||
/// Its browser is alive on this machine, so its proxy cannot be changed.
|
||||
pub running: bool,
|
||||
}
|
||||
|
||||
/// What a distribution would do, before anything is written.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct DistributionPlan {
|
||||
/// The assignments, in the order the profiles were given.
|
||||
pub pairs: Vec<ProxyPair>,
|
||||
/// Chosen profiles that no proxy was left for.
|
||||
pub unpaired_profile_ids: Vec<String>,
|
||||
/// Chosen proxies that no profile was left for.
|
||||
pub unused_proxy_ids: Vec<String>,
|
||||
/// Chosen profiles refused because their browser is running.
|
||||
pub running_profile_ids: Vec<String>,
|
||||
/// Chosen proxies withheld because a profile outside this distribution
|
||||
/// already uses them and sharing was not allowed.
|
||||
pub shared_proxy_ids: Vec<String>,
|
||||
}
|
||||
|
||||
/// Pair profiles to proxies one to one.
|
||||
///
|
||||
/// `assigned_elsewhere` is the set of proxies held by profiles that are not
|
||||
/// part of this distribution. With `allow_sharing` off those proxies are taken
|
||||
/// out of the pool, so a run cannot quietly put a second profile behind an exit
|
||||
/// that is already in use. A proxy listed twice by the caller is the same
|
||||
/// hazard and is deduplicated the same way.
|
||||
pub fn plan(
|
||||
profiles: &[ProfileCandidate],
|
||||
proxy_ids: &[String],
|
||||
allow_sharing: bool,
|
||||
assigned_elsewhere: &HashSet<String>,
|
||||
) -> DistributionPlan {
|
||||
let mut plan = DistributionPlan::default();
|
||||
|
||||
let mut eligible = Vec::with_capacity(profiles.len());
|
||||
for profile in profiles {
|
||||
if profile.running {
|
||||
plan.running_profile_ids.push(profile.id.clone());
|
||||
} else {
|
||||
eligible.push(profile.id.clone());
|
||||
}
|
||||
}
|
||||
|
||||
let mut pool: Vec<String> = Vec::with_capacity(proxy_ids.len());
|
||||
let mut seen: HashSet<&str> = HashSet::new();
|
||||
for proxy_id in proxy_ids {
|
||||
if !seen.insert(proxy_id.as_str()) {
|
||||
// The same proxy twice in one list is sharing spelled differently.
|
||||
if !allow_sharing {
|
||||
plan.shared_proxy_ids.push(proxy_id.clone());
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if !allow_sharing && assigned_elsewhere.contains(proxy_id) {
|
||||
plan.shared_proxy_ids.push(proxy_id.clone());
|
||||
continue;
|
||||
}
|
||||
pool.push(proxy_id.clone());
|
||||
}
|
||||
|
||||
let paired = eligible.len().min(pool.len());
|
||||
for index in 0..paired {
|
||||
plan.pairs.push(ProxyPair {
|
||||
profile_id: eligible[index].clone(),
|
||||
proxy_id: pool[index].clone(),
|
||||
});
|
||||
}
|
||||
plan.unpaired_profile_ids = eligible[paired..].to_vec();
|
||||
plan.unused_proxy_ids = pool[paired..].to_vec();
|
||||
plan
|
||||
}
|
||||
|
||||
/// What happened to one profile in an apply.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||
pub struct ProxyAssignmentResult {
|
||||
pub profile_id: String,
|
||||
pub proxy_id: String,
|
||||
pub ok: bool,
|
||||
/// A `{"code": ...}` payload when `ok` is false, otherwise null.
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
fn failed(pair: &ProxyPair, code: &str) -> ProxyAssignmentResult {
|
||||
ProxyAssignmentResult {
|
||||
profile_id: pair.profile_id.clone(),
|
||||
proxy_id: pair.proxy_id.clone(),
|
||||
ok: false,
|
||||
error: Some(serde_json::json!({ "code": code }).to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply explicit pairs, one profile at a time, and report each outcome.
|
||||
///
|
||||
/// A profile that cannot be moved never stops the rest: fifty assignments in
|
||||
/// which the one running profile fails is a useful answer, and an all-or-
|
||||
/// nothing abort halfway through a fleet is not.
|
||||
pub async fn apply_pairs(
|
||||
app_handle: tauri::AppHandle,
|
||||
pairs: &[ProxyPair],
|
||||
) -> Vec<ProxyAssignmentResult> {
|
||||
let manager = crate::profile::ProfileManager::instance();
|
||||
let known_proxies: HashSet<String> = crate::proxy_manager::PROXY_MANAGER
|
||||
.get_stored_proxies()
|
||||
.into_iter()
|
||||
.map(|proxy| proxy.id)
|
||||
.collect();
|
||||
|
||||
let mut results = Vec::with_capacity(pairs.len());
|
||||
let mut already_paired: HashSet<&str> = HashSet::new();
|
||||
|
||||
for pair in pairs {
|
||||
if !already_paired.insert(pair.profile_id.as_str()) {
|
||||
// Two proxies for one profile is not an assignment, it is a mistake in
|
||||
// the request, and quietly applying the last one hides it.
|
||||
results.push(failed(pair, "PROFILE_PAIRED_TWICE"));
|
||||
continue;
|
||||
}
|
||||
if !known_proxies.contains(&pair.proxy_id) {
|
||||
results.push(failed(pair, "PROXY_NOT_FOUND"));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Re-read on every pair: an earlier assignment in this same batch, or a
|
||||
// browser someone started while the dialog was open, has to be visible.
|
||||
let profile = match manager.list_profiles() {
|
||||
Ok(profiles) => profiles
|
||||
.into_iter()
|
||||
.find(|profile| profile.id.to_string() == pair.profile_id),
|
||||
Err(e) => {
|
||||
log::warn!("Could not list profiles while distributing proxies: {e}");
|
||||
None
|
||||
}
|
||||
};
|
||||
let Some(profile) = profile else {
|
||||
results.push(failed(pair, "PROFILE_NOT_FOUND"));
|
||||
continue;
|
||||
};
|
||||
if crate::profile::trash::is_running_locally(&profile) {
|
||||
results.push(failed(pair, "PROFILE_RUNNING"));
|
||||
continue;
|
||||
}
|
||||
|
||||
match manager
|
||||
.update_profile_proxy(
|
||||
app_handle.clone(),
|
||||
&pair.profile_id,
|
||||
Some(pair.proxy_id.clone()),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => results.push(ProxyAssignmentResult {
|
||||
profile_id: pair.profile_id.clone(),
|
||||
proxy_id: pair.proxy_id.clone(),
|
||||
ok: true,
|
||||
error: None,
|
||||
}),
|
||||
Err(e) => results.push(ProxyAssignmentResult {
|
||||
profile_id: pair.profile_id.clone(),
|
||||
proxy_id: pair.proxy_id.clone(),
|
||||
ok: false,
|
||||
error: Some(e.to_string()),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
results
|
||||
}
|
||||
|
||||
/// Build the pairing rule's view of the world from what is on disk.
|
||||
fn candidates(
|
||||
profile_ids: &[String],
|
||||
) -> Result<(Vec<ProfileCandidate>, HashSet<String>), Box<dyn std::error::Error>> {
|
||||
let profiles = crate::profile::ProfileManager::instance().list_profiles()?;
|
||||
let chosen: HashSet<&str> = profile_ids.iter().map(String::as_str).collect();
|
||||
|
||||
let assigned_elsewhere = profiles
|
||||
.iter()
|
||||
.filter(|profile| !chosen.contains(profile.id.to_string().as_str()))
|
||||
.filter_map(|profile| profile.proxy_id.clone())
|
||||
.collect();
|
||||
|
||||
let ordered = profile_ids
|
||||
.iter()
|
||||
.map(|id| {
|
||||
let found = profiles.iter().find(|p| p.id.to_string() == *id);
|
||||
ProfileCandidate {
|
||||
id: id.clone(),
|
||||
// A profile that is not on disk cannot be launched either, so it is
|
||||
// simply never paired; the plan reports it as unpaired.
|
||||
running: found.is_none_or(crate::profile::trash::is_running_locally),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok((ordered, assigned_elsewhere))
|
||||
}
|
||||
|
||||
/// Tauri command: what would happen, without touching anything.
|
||||
#[tauri::command]
|
||||
pub async fn plan_proxy_distribution(
|
||||
profile_ids: Vec<String>,
|
||||
proxy_ids: Vec<String>,
|
||||
allow_sharing: bool,
|
||||
) -> Result<DistributionPlan, String> {
|
||||
let (profiles, assigned_elsewhere) = candidates(&profile_ids).map_err(|e| e.to_string())?;
|
||||
Ok(plan(
|
||||
&profiles,
|
||||
&proxy_ids,
|
||||
allow_sharing,
|
||||
&assigned_elsewhere,
|
||||
))
|
||||
}
|
||||
|
||||
/// Tauri command: apply the pairs and report every profile's outcome.
|
||||
#[tauri::command]
|
||||
pub async fn distribute_proxies_to_profiles(
|
||||
app_handle: tauri::AppHandle,
|
||||
pairs: Vec<ProxyPair>,
|
||||
) -> Result<Vec<ProxyAssignmentResult>, String> {
|
||||
Ok(apply_pairs(app_handle, &pairs).await)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn free(id: &str) -> ProfileCandidate {
|
||||
ProfileCandidate {
|
||||
id: id.to_string(),
|
||||
running: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn running(id: &str) -> ProfileCandidate {
|
||||
ProfileCandidate {
|
||||
id: id.to_string(),
|
||||
running: true,
|
||||
}
|
||||
}
|
||||
|
||||
fn ids(values: &[&str]) -> Vec<String> {
|
||||
values.iter().map(|v| v.to_string()).collect()
|
||||
}
|
||||
|
||||
fn assigned(values: &[&str]) -> HashSet<String> {
|
||||
values.iter().map(|v| v.to_string()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn equal_lists_pair_one_to_one_in_order() {
|
||||
let plan = plan(
|
||||
&[free("p1"), free("p2"), free("p3")],
|
||||
&ids(&["x1", "x2", "x3"]),
|
||||
false,
|
||||
&HashSet::new(),
|
||||
);
|
||||
assert_eq!(
|
||||
plan.pairs,
|
||||
vec![
|
||||
ProxyPair {
|
||||
profile_id: "p1".into(),
|
||||
proxy_id: "x1".into()
|
||||
},
|
||||
ProxyPair {
|
||||
profile_id: "p2".into(),
|
||||
proxy_id: "x2".into()
|
||||
},
|
||||
ProxyPair {
|
||||
profile_id: "p3".into(),
|
||||
proxy_id: "x3".into()
|
||||
},
|
||||
]
|
||||
);
|
||||
assert!(plan.unpaired_profile_ids.is_empty());
|
||||
assert!(plan.unused_proxy_ids.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn more_profiles_than_proxies_leaves_the_remainder_alone() {
|
||||
// The failure this guards: wrapping around, which would put p3 and p4 on
|
||||
// the same exits as p1 and p2 without anyone asking for it.
|
||||
let plan = plan(
|
||||
&[free("p1"), free("p2"), free("p3"), free("p4")],
|
||||
&ids(&["x1", "x2"]),
|
||||
false,
|
||||
&HashSet::new(),
|
||||
);
|
||||
assert_eq!(plan.pairs.len(), 2);
|
||||
assert_eq!(plan.unpaired_profile_ids, ids(&["p3", "p4"]));
|
||||
assert!(plan.unused_proxy_ids.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn more_proxies_than_profiles_reports_the_leftovers() {
|
||||
let plan = plan(
|
||||
&[free("p1")],
|
||||
&ids(&["x1", "x2", "x3"]),
|
||||
false,
|
||||
&HashSet::new(),
|
||||
);
|
||||
assert_eq!(plan.pairs.len(), 1);
|
||||
assert_eq!(plan.unused_proxy_ids, ids(&["x2", "x3"]));
|
||||
assert!(plan.unpaired_profile_ids.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_proxy_another_profile_holds_is_withheld_until_sharing_is_allowed() {
|
||||
let profiles = [free("p1"), free("p2")];
|
||||
let proxies = ids(&["x1", "x2"]);
|
||||
let elsewhere = assigned(&["x1"]);
|
||||
|
||||
let strict = plan(&profiles, &proxies, false, &elsewhere);
|
||||
assert_eq!(strict.shared_proxy_ids, ids(&["x1"]));
|
||||
assert_eq!(
|
||||
strict.pairs,
|
||||
vec![ProxyPair {
|
||||
profile_id: "p1".into(),
|
||||
proxy_id: "x2".into()
|
||||
}]
|
||||
);
|
||||
assert_eq!(strict.unpaired_profile_ids, ids(&["p2"]));
|
||||
|
||||
let permissive = plan(&profiles, &proxies, true, &elsewhere);
|
||||
assert!(permissive.shared_proxy_ids.is_empty());
|
||||
assert_eq!(permissive.pairs.len(), 2);
|
||||
assert_eq!(permissive.pairs[0].proxy_id, "x1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_same_proxy_listed_twice_is_sharing_too() {
|
||||
let strict = plan(
|
||||
&[free("p1"), free("p2")],
|
||||
&ids(&["x1", "x1"]),
|
||||
false,
|
||||
&HashSet::new(),
|
||||
);
|
||||
assert_eq!(strict.pairs.len(), 1);
|
||||
assert_eq!(strict.shared_proxy_ids, ids(&["x1"]));
|
||||
assert_eq!(strict.unpaired_profile_ids, ids(&["p2"]));
|
||||
|
||||
let permissive = plan(
|
||||
&[free("p1"), free("p2")],
|
||||
&ids(&["x1", "x1"]),
|
||||
true,
|
||||
&HashSet::new(),
|
||||
);
|
||||
assert_eq!(permissive.pairs.len(), 2);
|
||||
assert_eq!(permissive.pairs[1].proxy_id, "x1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_running_profile_is_named_and_never_paired() {
|
||||
let plan = plan(
|
||||
&[free("p1"), running("p2"), free("p3")],
|
||||
&ids(&["x1", "x2"]),
|
||||
false,
|
||||
&HashSet::new(),
|
||||
);
|
||||
assert_eq!(plan.running_profile_ids, ids(&["p2"]));
|
||||
assert_eq!(
|
||||
plan.pairs,
|
||||
vec![
|
||||
ProxyPair {
|
||||
profile_id: "p1".into(),
|
||||
proxy_id: "x1".into()
|
||||
},
|
||||
// p3 takes the second proxy: the running profile is skipped, it does
|
||||
// not consume a proxy and leave a hole behind it.
|
||||
ProxyPair {
|
||||
profile_id: "p3".into(),
|
||||
proxy_id: "x2".into()
|
||||
},
|
||||
]
|
||||
);
|
||||
assert!(plan.unpaired_profile_ids.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_profile_running_pairs_nothing_and_frees_every_proxy() {
|
||||
let plan = plan(
|
||||
&[running("p1"), running("p2")],
|
||||
&ids(&["x1", "x2"]),
|
||||
false,
|
||||
&HashSet::new(),
|
||||
);
|
||||
assert!(plan.pairs.is_empty());
|
||||
assert_eq!(plan.running_profile_ids, ids(&["p1", "p2"]));
|
||||
assert_eq!(plan.unused_proxy_ids, ids(&["x1", "x2"]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_profiles_own_proxy_is_not_treated_as_someone_elses() {
|
||||
// p1 already sits on x1. Because p1 is part of this distribution, x1 is
|
||||
// not "assigned elsewhere", so it stays in the pool and the run can
|
||||
// reshuffle it rather than refusing to touch it.
|
||||
let plan = plan(
|
||||
&[free("p1"), free("p2")],
|
||||
&ids(&["x1", "x2"]),
|
||||
false,
|
||||
&HashSet::new(),
|
||||
);
|
||||
assert!(plan.shared_proxy_ids.is_empty());
|
||||
assert_eq!(plan.pairs.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nothing_chosen_produces_an_empty_plan() {
|
||||
let plan = plan(&[], &[], false, &HashSet::new());
|
||||
assert_eq!(plan, DistributionPlan::default());
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user