mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-08-08 20:26:08 +02:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3b1feb3f1b | ||
|
|
bc2b93d902 | ||
|
|
5c24e84eaf | ||
|
|
ffbbaa732a | ||
|
|
f12a84e18f | ||
|
|
39bbdcb547 | ||
|
|
29cb83d063 |
@@ -34,7 +34,7 @@ jobs:
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 #v7.0.1
|
||||
|
||||
- name: Set up pnpm package manager
|
||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 #v6.0.9
|
||||
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 #v6.0.10
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
|
||||
@@ -659,7 +659,7 @@ jobs:
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 #v7.0.1
|
||||
|
||||
- name: Run opencode
|
||||
uses: anomalyco/opencode/github@4da7bb44c84e013fa53e9c5d02ac753d1435c81a #v1.18.9
|
||||
uses: anomalyco/opencode/github@65cf14df16c191f3e9684f0d9a8bae69103ced6d #v1.18.14
|
||||
env:
|
||||
ZHIPU_API_KEY: ${{ secrets.ZHIPU_API_KEY }}
|
||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 #v7.0.1
|
||||
|
||||
- name: Set up pnpm package manager
|
||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 #v6.0.9
|
||||
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 #v6.0.10
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ jobs:
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 #v7.0.1
|
||||
|
||||
- name: Set up pnpm package manager
|
||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 #v6.0.9
|
||||
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 #v6.0.10
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
|
||||
@@ -104,7 +104,7 @@ jobs:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 #v7.0.1
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 #v6.0.9
|
||||
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 #v6.0.10
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
|
||||
@@ -111,7 +111,7 @@ jobs:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 #v7.0.1
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 #v6.0.9
|
||||
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 #v6.0.10
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
|
||||
@@ -23,4 +23,4 @@ jobs:
|
||||
- name: Checkout Actions Repository
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 #v7.0.1
|
||||
- name: Spell Check Repo
|
||||
uses: crate-ci/typos@bee27e3a4fd1ea2111cf90ab89cd076c870fce14 #v1.48.0
|
||||
uses: crate-ci/typos@8a48f81b6c64dcfea44b3633223084c4be58ac5f #v1.49.0
|
||||
|
||||
@@ -64,3 +64,10 @@ nodecar/nodecar-bin
|
||||
|
||||
# claude
|
||||
.claude/
|
||||
# Claude Code session-recovery runtime state
|
||||
HANDOFF.md
|
||||
.claude/settings.local.json
|
||||
.claude/rate-limit-state.json
|
||||
.claude/stop-failure-events.jsonl
|
||||
.claude/quota-blocked.json
|
||||
session-recover.yaml
|
||||
|
||||
@@ -1,6 +1,71 @@
|
||||
# Changelog
|
||||
|
||||
|
||||
## v0.29.0 (2026-08-08)
|
||||
|
||||
### Features
|
||||
|
||||
- prevent launch with inconsistent geodata
|
||||
- cookie bot
|
||||
- remote sessions
|
||||
- xray support
|
||||
- mass import via gui, api, and mcp
|
||||
- add Turkish (tr) language support
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- properly handle x-amz-meta-updated-at
|
||||
- improve UI interactions and page consistency
|
||||
|
||||
### Refactoring
|
||||
|
||||
- cleanup
|
||||
- cleanup
|
||||
- improve proxy lifetime management
|
||||
- cleanup
|
||||
- remote cleanup
|
||||
- cleanup cloud sync
|
||||
- cleanup
|
||||
- harden tests
|
||||
- block windows app update if the browser is running
|
||||
- ui refresh
|
||||
|
||||
### Documentation
|
||||
|
||||
- contrib-readme-action has updated readme
|
||||
- contrib-readme-action has updated readme
|
||||
|
||||
### Maintenance
|
||||
|
||||
- ci(deps): bump the github-actions group with 3 updates (#538)
|
||||
- chore: linting
|
||||
- chore: linting
|
||||
- chore: linting
|
||||
- chore: ci
|
||||
- chore: upload sidecars to cdn
|
||||
- chore: linting
|
||||
- ci(deps): bump the github-actions group with 4 updates
|
||||
- chore: linting
|
||||
- chore: disable e2e in ci
|
||||
- chore: linting
|
||||
- chore: linting
|
||||
- chore: ai compliance
|
||||
- chore: linting
|
||||
- ci(deps): bump the github-actions group across 1 directory with 3 updates (#514)
|
||||
- chore: linting
|
||||
- chore: linting
|
||||
- chore: add cross-platform webdriver tests
|
||||
- ci(deps): bump the github-actions group with 2 updates
|
||||
- chore: update flake.nix for v0.28.2 [skip ci] (#501)
|
||||
|
||||
### Other
|
||||
|
||||
- deps(deps): bump next from 16.2.10 to 16.2.11 (#515)
|
||||
- refactors: animations cleanup
|
||||
- restore settings redirect
|
||||
- fix group create translation key
|
||||
|
||||
|
||||
## v0.28.2 (2026-07-12)
|
||||
|
||||
### Features
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
|
||||
| | Apple Silicon | Intel |
|
||||
|---|---|---|
|
||||
| **DMG** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.28.2/Donut_0.28.2_aarch64.dmg) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.28.2/Donut_0.28.2_x64.dmg) |
|
||||
| **DMG** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.29.0/Donut_0.29.0_aarch64.dmg) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.29.0/Donut_0.29.0_x64.dmg) |
|
||||
|
||||
Or install via Homebrew:
|
||||
|
||||
@@ -56,15 +56,15 @@ brew install --cask donut
|
||||
|
||||
### Windows
|
||||
|
||||
[Download Windows Installer (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.28.2/Donut_0.28.2_x64-setup.exe) · [Portable (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.28.2/Donut_0.28.2_x64-portable.zip)
|
||||
[Download Windows Installer (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.29.0/Donut_0.29.0_x64-setup.exe) · [Portable (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.29.0/Donut_0.29.0_x64-portable.zip)
|
||||
|
||||
### Linux
|
||||
|
||||
| Format | x86_64 | ARM64 |
|
||||
|---|---|---|
|
||||
| **deb** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.28.2/Donut_0.28.2_amd64.deb) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.28.2/Donut_0.28.2_arm64.deb) |
|
||||
| **rpm** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.28.2/Donut-0.28.2-1.x86_64.rpm) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.28.2/Donut-0.28.2-1.aarch64.rpm) |
|
||||
| **AppImage** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.28.2/Donut_0.28.2_amd64.AppImage) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.28.2/Donut_0.28.2_aarch64.AppImage) |
|
||||
| **deb** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.29.0/Donut_0.29.0_amd64.deb) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.29.0/Donut_0.29.0_arm64.deb) |
|
||||
| **rpm** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.29.0/Donut-0.29.0-1.x86_64.rpm) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.29.0/Donut-0.29.0-1.aarch64.rpm) |
|
||||
| **AppImage** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.29.0/Donut_0.29.0_amd64.AppImage) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.29.0/Donut_0.29.0_aarch64.AppImage) |
|
||||
<!-- install-links-end -->
|
||||
|
||||
Or install via package manager:
|
||||
|
||||
Generated
+1
@@ -1809,6 +1809,7 @@ dependencies = [
|
||||
"flate2",
|
||||
"futures-util",
|
||||
"globset",
|
||||
"gtk",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
|
||||
@@ -25,6 +25,7 @@ export const commandCoverage = {
|
||||
"get_system_info",
|
||||
"dismiss_window_resize_warning",
|
||||
"get_window_resize_warning_dismissed",
|
||||
"window_decorations::get_window_decoration_layout",
|
||||
"get_onboarding_completed",
|
||||
"complete_onboarding",
|
||||
],
|
||||
@@ -71,6 +72,7 @@ export const commandCoverage = {
|
||||
"update_stored_proxy",
|
||||
"delete_stored_proxy",
|
||||
"check_proxy_validity",
|
||||
"validate_vless_uri",
|
||||
"get_cached_proxy_check",
|
||||
"export_proxies",
|
||||
"import_proxies_json",
|
||||
@@ -174,8 +176,9 @@ export const commandCoverage = {
|
||||
"generate_sample_fingerprint",
|
||||
"is_geoip_database_available",
|
||||
"download_geoip_database",
|
||||
"fingerprint_consistency::check_profile_fingerprint_consistency",
|
||||
"fingerprint_consistency::match_profile_fingerprint_to_exit",
|
||||
"launch_gate::get_profile_pre_launch_checks",
|
||||
"launch_gate::ack_launch_gate",
|
||||
"check_wayfern_terms_accepted",
|
||||
"check_wayfern_downloaded",
|
||||
"accept_wayfern_terms",
|
||||
@@ -255,6 +258,7 @@ export const commandCoverage = {
|
||||
"list_remote_sessions",
|
||||
"get_remote_session",
|
||||
"stop_remote_session",
|
||||
"get_remote_handoff_states",
|
||||
"start_remote_session_events",
|
||||
"stop_remote_session_events",
|
||||
"get_remote_session_events_status",
|
||||
@@ -275,6 +279,10 @@ export const commandCoverage = {
|
||||
"get_cookie_bot_presets",
|
||||
"get_remote_hours_quota",
|
||||
"get_cookie_bot_usage",
|
||||
"cookie_bot::get_cookie_bot_user_templates",
|
||||
"cookie_bot::create_cookie_bot_user_template",
|
||||
"cookie_bot::update_cookie_bot_user_template",
|
||||
"cookie_bot::delete_cookie_bot_user_template",
|
||||
],
|
||||
},
|
||||
updateContracts: {
|
||||
|
||||
@@ -244,13 +244,49 @@ test("real Wayfern fingerprinting, terms, API automation, CDP, cookies, and proc
|
||||
profileId: profile.id,
|
||||
exitIp: "8.8.8.8",
|
||||
});
|
||||
const consistency = await app.invoke(
|
||||
"check_profile_fingerprint_consistency",
|
||||
{
|
||||
profileId: profile.id,
|
||||
},
|
||||
// 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", {
|
||||
profileId: profile.id,
|
||||
});
|
||||
assert.ok(Array.isArray(checks.vpn_extensions));
|
||||
assert.equal(
|
||||
typeof checks.scan_state,
|
||||
"string",
|
||||
"the scan must report whether it saw the whole profile",
|
||||
);
|
||||
assert.equal(typeof checks.consistency, "object");
|
||||
assert.equal(typeof checks.exit_probe_pending, "boolean");
|
||||
assert.equal(typeof checks.exit_measurement_unreliable, "boolean");
|
||||
// This profile has no VPN extension, so nothing may block its launch.
|
||||
assert.equal(
|
||||
checks.vpn_extensions.length,
|
||||
0,
|
||||
"a clean profile must not report a VPN extension",
|
||||
);
|
||||
assert.equal(
|
||||
checks.consent_token,
|
||||
null,
|
||||
"a consent token is only minted when a cached mismatch is blocking",
|
||||
);
|
||||
|
||||
// Acknowledgements are per-profile and must be accepted for both kinds.
|
||||
await app.invoke("ack_launch_gate", {
|
||||
profileId: profile.id,
|
||||
ackFingerprint: false,
|
||||
ackExtensionKeys: ["crx:e2e-nonexistent-extension"],
|
||||
});
|
||||
await app.invoke("ack_launch_gate", {
|
||||
profileId: profile.id,
|
||||
ackFingerprint: true,
|
||||
ackExtensionKeys: [],
|
||||
});
|
||||
assert.match(
|
||||
await app.invokeError("get_profile_pre_launch_checks", {
|
||||
profileId: "00000000-0000-0000-0000-000000000000",
|
||||
}),
|
||||
/PROFILE_NOT_FOUND/,
|
||||
);
|
||||
assert.equal(typeof consistency, "object");
|
||||
|
||||
const directProfile = (await app.invoke("list_browser_profiles")).find(
|
||||
(item) => item.id === profile.id,
|
||||
|
||||
@@ -93,6 +93,34 @@ test("profile, group, proxy, tag, metadata, clone, and bulk-delete lifecycle", a
|
||||
});
|
||||
assert.ok(cachedValidity === null || cachedValidity.is_valid === false);
|
||||
|
||||
// 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.
|
||||
const goodVless =
|
||||
"vless://6d6e21a1-4829-4d2b-bc7f-1b25707b61e4@example.com:443" +
|
||||
"?security=reality&flow=xtls-rprx-vision&encryption=none&type=tcp" +
|
||||
"&sni=a.com&pbk=mQB9jxUDHO7g49VaNXLEdcNQ_jLhTbLolUsMUNwb6W4&sid=00&fp=chrome";
|
||||
assert.equal(
|
||||
await app.invoke("validate_vless_uri", { uri: goodVless }),
|
||||
null,
|
||||
);
|
||||
|
||||
for (const [uri, reason] of [
|
||||
[goodVless.replace("security=reality", "security=tls"), "security"],
|
||||
[goodVless.replace("type=tcp", "type=ws"), "transport"],
|
||||
[goodVless.replace("flow=xtls-rprx-vision", "flow=none"), "flow"],
|
||||
]) {
|
||||
// invokeError returns the command's error wrapped in a message, so match
|
||||
// rather than JSON.parse the whole string.
|
||||
const error = await app.invokeError("validate_vless_uri", { uri });
|
||||
assert.match(error, /VLESS_CONFIG_INVALID/);
|
||||
assert.match(
|
||||
error,
|
||||
new RegExp(`"reason":"${reason}"`),
|
||||
`expected reason ${reason} for ${uri}, got: ${error}`,
|
||||
);
|
||||
}
|
||||
|
||||
const exported = JSON.parse(
|
||||
await app.invoke("export_proxies", { format: "json" }),
|
||||
);
|
||||
|
||||
@@ -316,6 +316,13 @@ test("MCP Streamable HTTP initialization, auth, discovery, calls, and isolated a
|
||||
"update_proxy",
|
||||
"get_page_content",
|
||||
"get_interactive_elements",
|
||||
// The remote loop has to be complete from MCP alone: start a session,
|
||||
// watch it become usable, drive it with the interaction tools above, stop
|
||||
// it. Any one of these missing leaves an agent able to lease a host it
|
||||
// cannot use, or unable to lease one at all.
|
||||
"run_profile_remote",
|
||||
"get_remote_session",
|
||||
"stop_remote_session",
|
||||
]) {
|
||||
assert.ok(names.includes(name), `MCP is missing ${name}`);
|
||||
}
|
||||
@@ -671,6 +678,15 @@ test("offline cloud, update, team-lock, trial, and synchronizer contracts are de
|
||||
}),
|
||||
notSignedIn,
|
||||
);
|
||||
// The local-launch gate. Nothing has run remotely in this session, so it
|
||||
// is empty — but it must answer, because a UI that cannot read it shows
|
||||
// an enabled Run button over a profile the backend will refuse.
|
||||
const handoff = await app.invoke("get_remote_handoff_states");
|
||||
assert.ok(
|
||||
handoff && typeof handoff === "object" && !Array.isArray(handoff),
|
||||
"the handoff gate must answer with a profile-keyed object",
|
||||
);
|
||||
assert.equal(Object.keys(handoff).length, 0);
|
||||
|
||||
// The transition stream is what the desktop uses instead of polling, so
|
||||
// its subscriber has to start, report itself, and stop on demand. Both
|
||||
@@ -702,6 +718,33 @@ test("offline cloud, update, team-lock, trial, and synchronizer contracts are de
|
||||
}),
|
||||
notSignedIn,
|
||||
);
|
||||
// Saved site lists are cloud-backed like the schedules above, so they
|
||||
// must refuse the same way rather than appearing to work offline.
|
||||
assert.match(
|
||||
await app.invokeError("get_cookie_bot_user_templates", {}),
|
||||
notSignedIn,
|
||||
);
|
||||
assert.match(
|
||||
await app.invokeError("create_cookie_bot_user_template", {
|
||||
name: "e2e list",
|
||||
sites: ["example.com"],
|
||||
}),
|
||||
notSignedIn,
|
||||
);
|
||||
assert.match(
|
||||
await app.invokeError("update_cookie_bot_user_template", {
|
||||
id: "00000000-0000-0000-0000-000000000000",
|
||||
name: "renamed",
|
||||
sites: null,
|
||||
}),
|
||||
notSignedIn,
|
||||
);
|
||||
assert.match(
|
||||
await app.invokeError("delete_cookie_bot_user_template", {
|
||||
id: "00000000-0000-0000-0000-000000000000",
|
||||
}),
|
||||
notSignedIn,
|
||||
);
|
||||
assert.match(
|
||||
await app.invokeError("check_cookie_bot_conflicts", {
|
||||
profileId: missingProfileId,
|
||||
|
||||
@@ -22,6 +22,30 @@ test("fresh app renders, completes onboarding, persists settings, and never touc
|
||||
true,
|
||||
);
|
||||
|
||||
// Where the app draws its own titlebar it also owns the window controls,
|
||||
// so it needs the desktop's button layout to know which side they go on.
|
||||
const decorations = await app.invoke("get_window_decoration_layout");
|
||||
assert.equal(typeof decorations?.client_side, "boolean");
|
||||
if (decorations.client_side) {
|
||||
// Only reported where decorations were actually dropped, which is
|
||||
// every Linux session except KDE on Wayland.
|
||||
assert.equal(process.platform, "linux");
|
||||
// `layout` may be null when GtkSettings is unavailable; the frontend
|
||||
// falls back to the default arrangement rather than drawing nothing,
|
||||
// so asserting a string here would be stricter than the contract.
|
||||
if (decorations.layout !== null) {
|
||||
assert.equal(typeof decorations.layout, "string");
|
||||
assert.match(
|
||||
decorations.layout,
|
||||
/close|minimize|maximize/,
|
||||
`layout must name a drawable control, got: ${decorations.layout}`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// The platform still draws a titlebar; the app must not draw a second.
|
||||
assert.equal(decorations.layout, null);
|
||||
}
|
||||
|
||||
const saved = await app.invoke("save_app_settings", {
|
||||
settings: {
|
||||
...initial,
|
||||
@@ -113,8 +137,12 @@ test("keyboard command palette and major navigation surfaces are operable throug
|
||||
assert.match(body, /Settings/i);
|
||||
|
||||
// Exercise native WebDriver element marshalling and click, not just script execution.
|
||||
// Scoped to the open dialog on purpose: on Linux the app draws its own
|
||||
// titlebar, whose "Close window" control appears earlier in the DOM, and
|
||||
// clicking that would exercise the window lifecycle instead of the palette.
|
||||
const close = await app.execute(
|
||||
`return [...document.querySelectorAll("button")].find(
|
||||
`const dialog = document.querySelector("[role='dialog']") ?? document;
|
||||
return [...dialog.querySelectorAll("button")].find(
|
||||
(button) => /close/i.test(button.getAttribute("aria-label") || button.textContent || "")
|
||||
) ?? null;`,
|
||||
);
|
||||
|
||||
+17
-1
@@ -474,7 +474,7 @@ test("VLESS proxy form keeps the share URI as one clear, validated input", async
|
||||
await app.clickSelector('[aria-label="New proxy"]');
|
||||
await app.waitForText("Add Proxy");
|
||||
await app.fillSelector("#proxy-name", "E2E VLESS");
|
||||
await chooseSelectOption(app, "#proxy-type", "VLESS · Vision · REALITY");
|
||||
await chooseSelectOption(app, "#proxy-type", "VLESS");
|
||||
|
||||
assert.equal(
|
||||
await app.execute(
|
||||
@@ -512,6 +512,22 @@ test("VLESS proxy form keeps the share URI as one clear, validated input", async
|
||||
true,
|
||||
);
|
||||
|
||||
// A well-formed URI for a setup Donut cannot use must say WHICH part is
|
||||
// unsupported, rather than implying the user mistyped it.
|
||||
await app.fillSelector(
|
||||
"#proxy-vless-uri",
|
||||
`${uri.replace("type=tcp", "type=ws")}&path=%2Fray`,
|
||||
);
|
||||
await app.waitFor(
|
||||
() =>
|
||||
app.execute(
|
||||
`return /only|TCP|transport/i.test(
|
||||
document.querySelector("#proxy-vless-uri-help")?.textContent || ""
|
||||
);`,
|
||||
),
|
||||
{ description: "transport-specific unsupported message" },
|
||||
);
|
||||
|
||||
await app.fillSelector("#proxy-vless-uri", uri);
|
||||
await app.waitFor(
|
||||
() =>
|
||||
|
||||
+3
-2
@@ -2,7 +2,7 @@
|
||||
"name": "donutbrowser",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0",
|
||||
"version": "0.28.2",
|
||||
"version": "0.29.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"predev": "pnpm licenses:generate",
|
||||
@@ -10,8 +10,9 @@
|
||||
"prebuild": "pnpm licenses:generate",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"test": "pnpm test:themes && pnpm test:cookie-bot-limits && 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: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: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",
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
# cargo-audit configuration.
|
||||
#
|
||||
# `cargo audit` reads Cargo.lock, which records optional dependencies even when
|
||||
# no enabled feature pulls them into the build. An advisory against such a
|
||||
# package fails CI while the vulnerable code is never compiled into the binary.
|
||||
# Entries here are for exactly that case and must each carry the evidence.
|
||||
#
|
||||
# Before adding an ignore, prove the crate is genuinely not built:
|
||||
# cd src-tauri
|
||||
# cargo tree -i <crate> --target all # must print "nothing to print"
|
||||
# cargo tree --target all | grep <crate> # must find nothing
|
||||
# If either finds it, the crate IS in the build and the advisory must be fixed,
|
||||
# not ignored.
|
||||
|
||||
[advisories]
|
||||
ignore = [
|
||||
# RUSTSEC-2026-0235 — rkyv: insufficient archive validation can cause
|
||||
# out-of-bounds reads in archives containing Rc/Arc. Fixed in rkyv >= 0.8.17.
|
||||
#
|
||||
# Not reachable here. rkyv is an OPTIONAL dependency of rust_decimal, which
|
||||
# arrives via tauri-plugin-log -> byte-unit -> rust_decimal. No enabled
|
||||
# feature activates it, so it is present in Cargo.lock but absent from the
|
||||
# build graph. Verified with the two commands above (both find nothing) and
|
||||
# by there being no rkyv artifact in target/.
|
||||
#
|
||||
# There is no upgrade path: rust_decimal 1.42.1 is the newest release and
|
||||
# still pins rkyv 0.7.x, so `cargo update` cannot reach 0.8.17.
|
||||
#
|
||||
# REMOVE THIS as soon as either becomes true:
|
||||
# - rust_decimal ships a release depending on rkyv >= 0.8.17, or
|
||||
# - `cargo tree -i rkyv --target all` starts printing a path, which would
|
||||
# mean the crate is now genuinely compiled and the advisory applies.
|
||||
"RUSTSEC-2026-0235",
|
||||
]
|
||||
Generated
+4
-3
@@ -1797,7 +1797,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "donutbrowser"
|
||||
version = "0.28.2"
|
||||
version = "0.29.0"
|
||||
dependencies = [
|
||||
"aes 0.9.1",
|
||||
"aes-gcm 0.11.0",
|
||||
@@ -1821,6 +1821,7 @@ dependencies = [
|
||||
"flate2",
|
||||
"futures-util",
|
||||
"globset",
|
||||
"gtk",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
@@ -4092,7 +4093,7 @@ version = "0.7.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8"
|
||||
dependencies = [
|
||||
"proc-macro-crate 3.5.0",
|
||||
"proc-macro-crate 1.3.1",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.118",
|
||||
@@ -6860,7 +6861,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom 0.4.3",
|
||||
"getrandom 0.3.4",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys 0.61.2",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "donutbrowser"
|
||||
version = "0.28.2"
|
||||
version = "0.29.0"
|
||||
description = "Simple Yet Powerful Anti-Detect Browser"
|
||||
authors = ["zhom@github"]
|
||||
edition = "2021"
|
||||
@@ -116,6 +116,10 @@ sys-locale = "0.3"
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
nix = { version = "0.31", features = ["signal", "process"] }
|
||||
|
||||
# Reading the desktop's titlebar button layout for the in-app window controls.
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
gtk = "0.18"
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
core-foundation = "0.10"
|
||||
objc2 = "0.6.4"
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"core:event:allow-emit-to",
|
||||
"core:event:allow-unlisten",
|
||||
"core:window:allow-start-dragging",
|
||||
"core:window:allow-start-resize-dragging",
|
||||
"core:window:allow-close",
|
||||
"core:window:allow-is-maximized",
|
||||
"core:window:allow-minimize",
|
||||
|
||||
+358
-44
@@ -5,7 +5,10 @@ use crate::profile::manager::ProfileManager;
|
||||
use crate::proxy_manager::PROXY_MANAGER;
|
||||
use crate::tag_manager::TAG_MANAGER;
|
||||
use axum::{
|
||||
extract::{Path, Query, State},
|
||||
extract::{
|
||||
ws::{Message as WsMessage, WebSocket, WebSocketUpgrade},
|
||||
Path, Query, State,
|
||||
},
|
||||
http::{header, HeaderMap, Method, StatusCode},
|
||||
middleware::{self, Next},
|
||||
response::{IntoResponse, Json, Response},
|
||||
@@ -509,6 +512,7 @@ struct ImportProxiesResponse {
|
||||
run_profile,
|
||||
run_profile_remote,
|
||||
stop_remote_session,
|
||||
remote_session_cdp,
|
||||
list_remote_sessions_api,
|
||||
get_remote_session_api,
|
||||
get_remote_hours,
|
||||
@@ -794,6 +798,7 @@ fn build_v1_router() -> Router<ApiServerState> {
|
||||
// `/v1/remote-sessions/{id}`, and registering them separately would have
|
||||
// the second overwrite the first.
|
||||
.routes(routes!(get_remote_session_api, stop_remote_session))
|
||||
.routes(routes!(remote_session_cdp))
|
||||
.routes(routes!(list_remote_sessions_api))
|
||||
.routes(routes!(get_remote_hours))
|
||||
.routes(routes!(set_profile_cloud_sync))
|
||||
@@ -1061,6 +1066,20 @@ pub async fn get_api_server_status() -> Result<Option<u16>, String> {
|
||||
/// bare status code. Matching is on message content because the managers
|
||||
/// return plain strings (some are the JSON `{"code": ...}` strings shared
|
||||
/// with the Tauri commands).
|
||||
/// Codes meaning "this profile is held by someone else right now".
|
||||
///
|
||||
/// Kept as one list so the REST layer, which has no other way to tell a refusal
|
||||
/// apart from a validation failure, cannot drift from the guards that produce
|
||||
/// them. `PROFILE_REMOTE_SYNC_PENDING` in particular is temporary by nature: the
|
||||
/// pull that clears it is already running.
|
||||
const LAUNCH_CONFLICT_CODES: [&str; 5] = [
|
||||
"PROFILE_RUNNING",
|
||||
"PROFILE_RUNNING_REMOTELY",
|
||||
"PROFILE_REMOTE_SYNC_PENDING",
|
||||
"PROFILE_LOCKED_BY_MEMBER",
|
||||
"PROFILE_LOCKED_ELSEWHERE",
|
||||
];
|
||||
|
||||
fn manager_error_response(err: impl std::fmt::Display) -> (StatusCode, String) {
|
||||
let msg = err.to_string();
|
||||
|
||||
@@ -1069,8 +1088,19 @@ fn manager_error_response(err: impl std::fmt::Display) -> (StatusCode, String) {
|
||||
if let Some(code) = value.get("code").and_then(|c| c.as_str()) {
|
||||
let status = if code.ends_with("_NOT_FOUND") {
|
||||
StatusCode::NOT_FOUND
|
||||
} else if LAUNCH_CONFLICT_CODES.contains(&code) {
|
||||
// Someone or something else holds this profile: another team member, a
|
||||
// browser already open, or a remote session whose work has not been
|
||||
// pulled back yet. All of them are "try again later", not "your request
|
||||
// was malformed", and 400 would tell an automation client to give up.
|
||||
StatusCode::CONFLICT
|
||||
} else if code == "INTERNAL_ERROR" {
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
} else if code == "PROFILE_LOCK_UNAVAILABLE" {
|
||||
// The lock service could not be reached. The launch is refused because
|
||||
// it cannot be proven safe, which is an upstream failure, not the
|
||||
// caller's fault.
|
||||
StatusCode::SERVICE_UNAVAILABLE
|
||||
} else if code.ends_with("_REQUIRES_PRO") || code.ends_with("_PAYMENT_REQUIRED") {
|
||||
// Paid-feature gates (FINGERPRINT_REQUIRES_PRO, PROXY_PAYMENT_REQUIRED).
|
||||
// Mapping them here lets the gate live in the shared manager instead of
|
||||
@@ -2295,8 +2325,9 @@ async fn delete_extension_group_api(
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 402, description = "Active paid plan with browser automation required"),
|
||||
(status = 404, description = "Profile not found"),
|
||||
(status = 409, description = "Profile is locked by another team member"),
|
||||
(status = 409, description = "Profile is locked by another team member, running on the remote fleet, or waiting for a finished remote session to be pulled back"),
|
||||
(status = 429, description = "Automation request rate limit exceeded"),
|
||||
(status = 503, description = "The profile lock service could not be reached"),
|
||||
(status = 500, description = "Internal server error")
|
||||
),
|
||||
security(
|
||||
@@ -2308,12 +2339,12 @@ async fn run_profile(
|
||||
Path(id): Path<String>,
|
||||
State(state): State<ApiServerState>,
|
||||
Json(request): Json<RunProfileRequest>,
|
||||
) -> Result<Json<RunProfileResponse>, StatusCode> {
|
||||
) -> Result<Json<RunProfileResponse>, (StatusCode, String)> {
|
||||
if !crate::cloud_auth::CLOUD_AUTH
|
||||
.can_use_browser_automation()
|
||||
.await
|
||||
{
|
||||
return Err(StatusCode::PAYMENT_REQUIRED);
|
||||
return Err((StatusCode::PAYMENT_REQUIRED, String::new()));
|
||||
}
|
||||
|
||||
let headless = request.headless.unwrap_or(false);
|
||||
@@ -2322,29 +2353,34 @@ async fn run_profile(
|
||||
let profile_manager = ProfileManager::instance();
|
||||
let profiles = profile_manager
|
||||
.list_profiles()
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
.map_err(manager_error_response)?;
|
||||
|
||||
let profile = profiles
|
||||
.iter()
|
||||
.find(|p| p.id.to_string() == id)
|
||||
.ok_or(StatusCode::NOT_FOUND)?;
|
||||
.ok_or((StatusCode::NOT_FOUND, "profile not found".to_string()))?;
|
||||
|
||||
if profile.is_cross_os() {
|
||||
return Err(StatusCode::BAD_REQUEST);
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
"cannot launch a cross-OS profile locally; use /run-remote".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// Team lock check
|
||||
// Team lock check. Routed through the shared mapper so a profile held by the
|
||||
// user's OWN remote session is a 409 that says so, rather than a bare status
|
||||
// with no body, which is what an automation client had to guess from.
|
||||
crate::team_lock::acquire_team_lock_if_needed(profile)
|
||||
.await
|
||||
.map_err(|_| StatusCode::CONFLICT)?;
|
||||
.map_err(manager_error_response)?;
|
||||
|
||||
let remote_debugging_port = {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
.map_err(manager_error_response)?;
|
||||
let port = listener
|
||||
.local_addr()
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
|
||||
.map_err(manager_error_response)?
|
||||
.port();
|
||||
drop(listener);
|
||||
port
|
||||
@@ -2352,23 +2388,20 @@ async fn run_profile(
|
||||
|
||||
// Use the same launch path as the main app, but force a fresh instance with
|
||||
// remote debugging enabled so the returned port is the one the browser binds.
|
||||
match crate::browser_runner::launch_browser_profile_impl(
|
||||
let updated_profile = crate::browser_runner::launch_browser_profile_impl(
|
||||
state.app_handle.clone(),
|
||||
profile.clone(),
|
||||
url,
|
||||
Some(remote_debugging_port),
|
||||
headless,
|
||||
true,
|
||||
crate::browser_runner::LaunchOptions::automation(Some(remote_debugging_port), headless),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(updated_profile) => Ok(Json(RunProfileResponse {
|
||||
profile_id: updated_profile.id.to_string(),
|
||||
remote_debugging_port,
|
||||
headless,
|
||||
})),
|
||||
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
|
||||
}
|
||||
.map_err(manager_error_response)?;
|
||||
|
||||
Ok(Json(RunProfileResponse {
|
||||
profile_id: updated_profile.id.to_string(),
|
||||
remote_debugging_port,
|
||||
headless,
|
||||
}))
|
||||
}
|
||||
|
||||
// API Handler - Launch this profile on a REMOTE VM of its own operating system
|
||||
@@ -2734,6 +2767,157 @@ fn status_for_code(code: &str) -> StatusCode {
|
||||
}
|
||||
}
|
||||
|
||||
// API Handler - Attach a CDP client (Playwright, Puppeteer, chrome-remote-interface)
|
||||
// to a remote session.
|
||||
//
|
||||
// This is what makes `run-remote` usable. Without it the endpoint hands back a
|
||||
// session id that nothing outside this app can do anything with: the fleet's
|
||||
// relay only accepts the user's Donut cloud credential, an automation client
|
||||
// does not have one, and it must not be given one — an API token is scoped to
|
||||
// "drive my browsers", not "act as my account".
|
||||
//
|
||||
// So the socket is opened here with the credential this process already holds
|
||||
// and the frames are pumped verbatim in both directions. The caller presents
|
||||
// the ordinary API bearer token and gets a browser-level CDP endpoint at
|
||||
// `ws://127.0.0.1:<api port>/v1/remote-sessions/{id}/cdp`:
|
||||
//
|
||||
// const browser = await chromium.connectOverCDP({
|
||||
// endpointURL: `ws://127.0.0.1:10108/v1/remote-sessions/${id}/cdp`,
|
||||
// headers: { Authorization: `Bearer ${API_TOKEN}` },
|
||||
// });
|
||||
//
|
||||
// Nothing is attached to a page first, deliberately: Playwright drives
|
||||
// `Target.setAutoAttach` and builds its own session map, and a socket already
|
||||
// bound to one page would hide every other target from it.
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/v1/remote-sessions/{id}/cdp",
|
||||
params(
|
||||
("id" = String, Path, description = "Remote session ID from run-remote")
|
||||
),
|
||||
responses(
|
||||
(status = 101, description = "Switching Protocols; a browser-level CDP WebSocket follows"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 402, description = "Active paid plan with browser automation required"),
|
||||
(status = 404, description = "No such remote session, or it is not attachable yet"),
|
||||
(status = 502, description = "The relay could not be reached"),
|
||||
(status = 426, description = "Not a WebSocket upgrade request")
|
||||
),
|
||||
security(
|
||||
("bearer_auth" = [])
|
||||
),
|
||||
tag = "remote-sessions"
|
||||
)]
|
||||
async fn remote_session_cdp(
|
||||
Path(id): Path<String>,
|
||||
upgrade: WebSocketUpgrade,
|
||||
) -> Result<Response, (StatusCode, String)> {
|
||||
if !crate::cloud_auth::CLOUD_AUTH
|
||||
.can_use_browser_automation()
|
||||
.await
|
||||
{
|
||||
return Err((StatusCode::PAYMENT_REQUIRED, String::new()));
|
||||
}
|
||||
|
||||
// Dialled BEFORE the upgrade is accepted, so a session that is not attachable
|
||||
// is an HTTP status the client can read. Accepting the upgrade first would
|
||||
// turn every such failure into a socket that opens and immediately closes,
|
||||
// which is what a CDP client reports as "browser closed unexpectedly".
|
||||
let upstream = crate::cdp_target::open_relay_socket(&id)
|
||||
.await
|
||||
.map_err(cdp_error_response)?;
|
||||
|
||||
Ok(
|
||||
upgrade
|
||||
.max_message_size(crate::cdp_target::MAX_RELAY_MESSAGE_BYTES)
|
||||
.max_frame_size(crate::cdp_target::MAX_RELAY_MESSAGE_BYTES)
|
||||
.on_upgrade(move |client| pump_cdp(id, client, upstream)),
|
||||
)
|
||||
}
|
||||
|
||||
fn cdp_error_response(err: crate::cdp_target::CdpError) -> (StatusCode, String) {
|
||||
use crate::cdp_target::CdpError;
|
||||
let status = match err {
|
||||
CdpError::Unauthorized(_) => StatusCode::UNAUTHORIZED,
|
||||
// "Not drivable" covers a session that is still provisioning and one that
|
||||
// is not the caller's. Both are 404 to a CDP client: there is no browser at
|
||||
// this address right now.
|
||||
CdpError::NotDrivable(_) => StatusCode::NOT_FOUND,
|
||||
CdpError::Unreachable(_) => StatusCode::BAD_GATEWAY,
|
||||
CdpError::Transport(_) | CdpError::Protocol(_) => StatusCode::BAD_GATEWAY,
|
||||
};
|
||||
(status, err.to_string())
|
||||
}
|
||||
|
||||
/// Copy CDP frames between the local client and the fleet relay until either
|
||||
/// side hangs up.
|
||||
///
|
||||
/// Verbatim in both directions. This proxy deliberately understands nothing
|
||||
/// about CDP: a client that speaks a newer protocol, or a target type this
|
||||
/// build has never heard of, must keep working without a Donut release.
|
||||
async fn pump_cdp(session_id: String, client: WebSocket, upstream: crate::cdp_target::RelaySocket) {
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use tokio_tungstenite::tungstenite::Message as RelayMessage;
|
||||
|
||||
let (mut client_tx, mut client_rx) = client.split();
|
||||
let (mut relay_tx, mut relay_rx) = upstream.split();
|
||||
|
||||
let to_relay = async {
|
||||
while let Some(Ok(message)) = client_rx.next().await {
|
||||
let forwarded = match message {
|
||||
WsMessage::Text(text) => RelayMessage::Text(text.as_str().into()),
|
||||
WsMessage::Binary(bytes) => RelayMessage::Binary(bytes),
|
||||
WsMessage::Ping(bytes) => RelayMessage::Ping(bytes),
|
||||
WsMessage::Pong(bytes) => RelayMessage::Pong(bytes),
|
||||
WsMessage::Close(_) => break,
|
||||
};
|
||||
if relay_tx.send(forwarded).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let _ = relay_tx.close().await;
|
||||
};
|
||||
|
||||
let to_client = async {
|
||||
while let Some(Ok(message)) = relay_rx.next().await {
|
||||
let forwarded = match message {
|
||||
RelayMessage::Text(text) => WsMessage::Text(text.as_str().into()),
|
||||
RelayMessage::Binary(bytes) => WsMessage::Binary(bytes),
|
||||
RelayMessage::Ping(bytes) => WsMessage::Ping(bytes),
|
||||
RelayMessage::Pong(bytes) => WsMessage::Pong(bytes),
|
||||
// A relay close carries the only diagnosis the server gives (1008 is a
|
||||
// rejected credential, 1013 is "not up yet"), so it is passed through
|
||||
// rather than swallowed into a bare disconnect.
|
||||
RelayMessage::Close(frame) => {
|
||||
let _ = client_tx
|
||||
.send(WsMessage::Close(frame.map(|f| {
|
||||
axum::extract::ws::CloseFrame {
|
||||
code: u16::from(f.code),
|
||||
reason: f.reason.as_str().into(),
|
||||
}
|
||||
})))
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
RelayMessage::Frame(_) => continue,
|
||||
};
|
||||
if client_tx.send(forwarded).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let _ = client_tx.close().await;
|
||||
};
|
||||
|
||||
// Either direction ending means the conversation is over. Waiting for both
|
||||
// would hold a relay socket open — and one of the session's four allowed
|
||||
// attachments with it — after the client had gone.
|
||||
tokio::select! {
|
||||
() = to_relay => {}
|
||||
() = to_client => {}
|
||||
}
|
||||
log::info!("CDP proxy for remote session {session_id} closed");
|
||||
}
|
||||
|
||||
// API Handler - Every remote session this account currently owns
|
||||
#[utoipa::path(
|
||||
get,
|
||||
@@ -2849,7 +3033,7 @@ fn cookie_bot_eligible_profile(
|
||||
.find(|p| p.id.to_string() == profile_id)
|
||||
.ok_or((StatusCode::NOT_FOUND, "profile not found".to_string()))?;
|
||||
|
||||
crate::cookie_bot::bot_precondition(&profile)
|
||||
crate::cookie_bot::bot_precondition(&profile, &crate::cookie_bot::exit_reachability(&profile))
|
||||
.map_err(|reason| (StatusCode::BAD_REQUEST, reason))?;
|
||||
Ok(profile)
|
||||
}
|
||||
@@ -3131,10 +3315,7 @@ async fn list_cookie_bot_runs(
|
||||
async fn start_cookie_bot_run(
|
||||
Json(request): Json<StartCookieBotRunRequest>,
|
||||
) -> Result<(StatusCode, Json<crate::cookie_bot::CookieBotRunStarted>), (StatusCode, String)> {
|
||||
if !crate::cloud_auth::CLOUD_AUTH
|
||||
.can_use_browser_automation()
|
||||
.await
|
||||
{
|
||||
if !crate::cloud_auth::CLOUD_AUTH.can_use_cookie_bot().await {
|
||||
return Err((StatusCode::PAYMENT_REQUIRED, String::new()));
|
||||
}
|
||||
|
||||
@@ -3240,6 +3421,11 @@ async fn get_cookie_bot_usage(
|
||||
}
|
||||
|
||||
// API Handler - Open URL in existing browser
|
||||
//
|
||||
// Works against a profile running here OR one running on the leased fleet: a
|
||||
// remote session is navigated over the same CDP path the automation tools use,
|
||||
// so a caller does not have to know where the browser is. The cross-OS refusal
|
||||
// therefore only applies to a profile that would have to be launched locally.
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/v1/profiles/{id}/open-url",
|
||||
@@ -3248,12 +3434,14 @@ async fn get_cookie_bot_usage(
|
||||
),
|
||||
request_body = OpenUrlRequest,
|
||||
responses(
|
||||
(status = 200, description = "URL opened successfully"),
|
||||
(status = 400, description = "Cannot open URL with a cross-OS profile"),
|
||||
(status = 200, description = "URL opened successfully, locally or on the profile's remote session"),
|
||||
(status = 400, description = "Cannot open URL with a cross-OS profile that is not running remotely"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 402, description = "Active paid plan with browser automation required"),
|
||||
(status = 404, description = "Profile not found"),
|
||||
(status = 409, description = "Profile is locked by another team member, or waiting for a finished remote session to be pulled back"),
|
||||
(status = 429, description = "Automation request rate limit exceeded"),
|
||||
(status = 503, description = "The profile lock service could not be reached"),
|
||||
(status = 500, description = "Internal server error")
|
||||
),
|
||||
security(
|
||||
@@ -3276,7 +3464,12 @@ async fn open_url_in_profile(
|
||||
let browser_runner = crate::browser_runner::BrowserRunner::instance();
|
||||
|
||||
browser_runner
|
||||
.open_url_with_profile(state.app_handle.clone(), id, request.url)
|
||||
.open_url_with_profile(
|
||||
state.app_handle.clone(),
|
||||
id,
|
||||
request.url,
|
||||
crate::launch_gate::FingerprintGate::Advisory,
|
||||
)
|
||||
.await
|
||||
.map_err(manager_error_response)?;
|
||||
|
||||
@@ -3284,6 +3477,12 @@ async fn open_url_in_profile(
|
||||
}
|
||||
|
||||
// API Handler - Kill browser process
|
||||
//
|
||||
// Stops the browser wherever it is. A profile open on the leased fleet is ended
|
||||
// through the backend, which is what makes this endpoint mean "stop this
|
||||
// profile" rather than "stop this profile if it happens to be on this machine" —
|
||||
// the latter reported success, killed nothing, and left the session billing to
|
||||
// its two-hour cap.
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/v1/profiles/{id}/kill",
|
||||
@@ -3291,11 +3490,12 @@ async fn open_url_in_profile(
|
||||
("id" = String, Path, description = "Profile ID")
|
||||
),
|
||||
responses(
|
||||
(status = 204, description = "Browser process killed successfully"),
|
||||
(status = 204, description = "Browser stopped, locally or on the profile's remote session"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 402, description = "Active paid plan required"),
|
||||
(status = 404, description = "Profile not found"),
|
||||
(status = 429, description = "Automation request rate limit exceeded"),
|
||||
(status = 503, description = "The fleet could not be reached; the remote browser is still running"),
|
||||
(status = 500, description = "Internal server error")
|
||||
),
|
||||
security(
|
||||
@@ -3306,31 +3506,41 @@ async fn open_url_in_profile(
|
||||
async fn kill_profile(
|
||||
Path(id): Path<String>,
|
||||
State(state): State<ApiServerState>,
|
||||
) -> Result<StatusCode, StatusCode> {
|
||||
) -> Result<StatusCode, (StatusCode, String)> {
|
||||
// Programmatically launching and stopping profiles is a paid feature; the
|
||||
// run/open-url handlers gate the same way.
|
||||
if !crate::cloud_auth::CLOUD_AUTH
|
||||
.can_use_browser_automation()
|
||||
.await
|
||||
{
|
||||
return Err(StatusCode::PAYMENT_REQUIRED);
|
||||
return Err((StatusCode::PAYMENT_REQUIRED, String::new()));
|
||||
}
|
||||
|
||||
let profile_manager = ProfileManager::instance();
|
||||
let profiles = profile_manager
|
||||
.list_profiles()
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
.map_err(manager_error_response)?;
|
||||
|
||||
let profile = profiles
|
||||
.iter()
|
||||
.find(|p| p.id.to_string() == id)
|
||||
.ok_or(StatusCode::NOT_FOUND)?;
|
||||
.ok_or((StatusCode::NOT_FOUND, "profile not found".to_string()))?;
|
||||
|
||||
let browser_runner = crate::browser_runner::BrowserRunner::instance();
|
||||
browser_runner
|
||||
.kill_browser_process(state.app_handle.clone(), profile)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
.map_err(|e| {
|
||||
let message = e.to_string();
|
||||
// The backend refuses to retire a session it could not stop on the fleet.
|
||||
// Reporting that as a 500 invites a retry loop against a browser that is
|
||||
// still running; 503 says "it is still up, try again".
|
||||
if message.contains("REMOTE_") {
|
||||
(StatusCode::SERVICE_UNAVAILABLE, message)
|
||||
} else {
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, message)
|
||||
}
|
||||
})?;
|
||||
|
||||
crate::team_lock::release_team_lock_if_needed(profile).await;
|
||||
|
||||
@@ -3416,9 +3626,7 @@ async fn batch_run_profiles(
|
||||
state.app_handle.clone(),
|
||||
profile.clone(),
|
||||
request.url.clone(),
|
||||
Some(port),
|
||||
headless,
|
||||
true,
|
||||
crate::browser_runner::LaunchOptions::automation(Some(port), headless),
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -4023,14 +4231,22 @@ mod tests {
|
||||
let mut local_only = profile_with(SyncMode::Disabled, Some("macos"));
|
||||
local_only.proxy_id = Some("proxy-1".to_string());
|
||||
assert!(
|
||||
crate::cookie_bot::bot_precondition(&local_only).is_err(),
|
||||
crate::cookie_bot::bot_precondition(
|
||||
&local_only,
|
||||
&crate::remote_exit::ExitReachability::Remote
|
||||
)
|
||||
.is_err(),
|
||||
"a profile with no cloud copy has nothing for a host to open"
|
||||
);
|
||||
|
||||
let mut encrypted = profile_with(SyncMode::Encrypted, Some("macos"));
|
||||
encrypted.proxy_id = Some("proxy-1".to_string());
|
||||
assert!(
|
||||
crate::cookie_bot::bot_precondition(&encrypted).is_err(),
|
||||
crate::cookie_bot::bot_precondition(
|
||||
&encrypted,
|
||||
&crate::remote_exit::ExitReachability::Remote
|
||||
)
|
||||
.is_err(),
|
||||
"a host cannot decrypt a profile whose key never leaves this machine"
|
||||
);
|
||||
|
||||
@@ -4038,13 +4254,21 @@ mod tests {
|
||||
datacenter_egress.proxy_id = None;
|
||||
datacenter_egress.vpn_id = None;
|
||||
assert!(
|
||||
crate::cookie_bot::bot_precondition(&datacenter_egress).is_err(),
|
||||
crate::cookie_bot::bot_precondition(
|
||||
&datacenter_egress,
|
||||
&crate::remote_exit::ExitReachability::None
|
||||
)
|
||||
.is_err(),
|
||||
"hours of traffic from a hosting ASN damages the identity being warmed"
|
||||
);
|
||||
|
||||
let mut eligible = profile_with(SyncMode::Regular, Some("macos"));
|
||||
eligible.proxy_id = Some("proxy-1".to_string());
|
||||
assert!(crate::cookie_bot::bot_precondition(&eligible).is_ok());
|
||||
assert!(crate::cookie_bot::bot_precondition(
|
||||
&eligible,
|
||||
&crate::remote_exit::ExitReachability::Remote
|
||||
)
|
||||
.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -4331,6 +4555,96 @@ mod tests {
|
||||
// list, not from the router — endpoints registered on the router but missing
|
||||
// from ApiDoc silently disappear from the spec. Lock in the ones that were
|
||||
// once dropped, and that removed endpoints stay gone.
|
||||
#[test]
|
||||
fn a_profile_held_elsewhere_is_a_conflict_not_a_bad_request() {
|
||||
// These four refusals all mean "come back in a moment". Answering 400 tells
|
||||
// an automation client its request was malformed and to stop retrying, and
|
||||
// that is what every one of them did before they had codes at all.
|
||||
for code in [
|
||||
"PROFILE_RUNNING_REMOTELY",
|
||||
"PROFILE_REMOTE_SYNC_PENDING",
|
||||
"PROFILE_LOCKED_BY_MEMBER",
|
||||
"PROFILE_LOCKED_ELSEWHERE",
|
||||
] {
|
||||
let (status, body) = manager_error_response(serde_json::json!({ "code": code }).to_string());
|
||||
assert_eq!(status, StatusCode::CONFLICT, "{code} must be a 409");
|
||||
assert!(body.contains(code), "{code} must reach the caller");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unreachable_lock_service_is_not_the_callers_fault() {
|
||||
let (status, _) =
|
||||
manager_error_response(serde_json::json!({ "code": "PROFILE_LOCK_UNAVAILABLE" }).to_string());
|
||||
assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_remote_session_exposes_a_cdp_endpoint_an_external_client_can_attach_to() {
|
||||
// Without this route `run-remote` hands back a session id that nothing
|
||||
// outside the app can use: the fleet relay accepts only the user's cloud
|
||||
// credential, which an API consumer does not have and must not be given.
|
||||
// A Playwright user reads the spec to find this, so it has to be in it.
|
||||
let spec = serde_json::to_value(ApiDoc::openapi()).expect("spec serializes");
|
||||
let operation = &spec["paths"]["/v1/remote-sessions/{id}/cdp"]["get"];
|
||||
assert!(
|
||||
operation.is_object(),
|
||||
"the CDP attach endpoint must be in the served spec"
|
||||
);
|
||||
assert!(
|
||||
operation["responses"].get("101").is_some(),
|
||||
"a WebSocket endpoint must document its upgrade"
|
||||
);
|
||||
assert_eq!(operation["tags"][0], "remote-sessions");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cdp_attach_failure_is_not_reported_as_a_broken_relay() {
|
||||
// A CDP client retries a 502 and gives up on a 404. Reporting "this session
|
||||
// is not up yet" as a gateway failure sends it into a loop against a
|
||||
// session that is doing exactly what it should.
|
||||
use crate::cdp_target::CdpError;
|
||||
assert_eq!(
|
||||
cdp_error_response(CdpError::NotDrivable("provisioning".into())).0,
|
||||
StatusCode::NOT_FOUND
|
||||
);
|
||||
assert_eq!(
|
||||
cdp_error_response(CdpError::Unauthorized("no token".into())).0,
|
||||
StatusCode::UNAUTHORIZED
|
||||
);
|
||||
assert_eq!(
|
||||
cdp_error_response(CdpError::Unreachable("dns".into())).0,
|
||||
StatusCode::BAD_GATEWAY
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_kill_route_documents_that_it_can_fail_to_stop_a_remote_browser() {
|
||||
// The backend refuses to retire a session it could not stop on the fleet, so
|
||||
// stopping can genuinely fail with the browser still running. A spec that
|
||||
// only lists 204 tells a client that never happens.
|
||||
let spec = serde_json::to_value(ApiDoc::openapi()).expect("spec serializes");
|
||||
let responses = &spec["paths"]["/v1/profiles/{id}/kill"]["post"]["responses"];
|
||||
assert!(
|
||||
responses.get("503").is_some(),
|
||||
"kill must document that the fleet may be unreachable"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_local_launch_routes_document_their_conflict() {
|
||||
// A profile waiting on a finished remote session refuses a local launch.
|
||||
// Undocumented, that reaches an integrator as an unexplained 409.
|
||||
let spec = serde_json::to_value(ApiDoc::openapi()).expect("spec serializes");
|
||||
for path in ["/v1/profiles/{id}/run", "/v1/profiles/{id}/open-url"] {
|
||||
let responses = &spec["paths"][path]["post"]["responses"];
|
||||
assert!(
|
||||
responses.get("409").is_some(),
|
||||
"{path} must document its conflict"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openapi_spec_covers_registered_routes() {
|
||||
let spec = serde_json::to_value(ApiDoc::openapi()).expect("spec serializes");
|
||||
|
||||
@@ -231,7 +231,7 @@ mod windows {
|
||||
pub fn is_wayfern_version_downloaded(install_dir: &Path) -> bool {
|
||||
if wayfern_executable_candidates(install_dir)
|
||||
.iter()
|
||||
.any(|exe_path| exe_path.exists() && exe_path.is_file())
|
||||
.any(|exe_path| exe_path.exists() && exe_path.is_file() && has_sibling_dll(exe_path))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
@@ -239,7 +239,8 @@ mod windows {
|
||||
// Check for any .exe file that looks like the browser
|
||||
if let Ok(entries) = std::fs::read_dir(install_dir) {
|
||||
for entry in entries.flatten() {
|
||||
if is_wayfern_exe(&entry.path()) {
|
||||
let path = entry.path();
|
||||
if is_wayfern_exe(&path) && has_sibling_dll(&path) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -380,6 +381,33 @@ impl BrowserFactory {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the directory holding `exe_path` also contains at least one `.dll`.
|
||||
///
|
||||
/// A Chromium build on Windows cannot start without its sibling libraries
|
||||
/// (`chrome.dll` and friends) and its `.manifest`; a lone `.exe` is a gutted
|
||||
/// install, and launching it fails inside the Windows loader with os error
|
||||
/// 14001 (`ERROR_SXS_CANT_GEN_ACTCTX`, "side-by-side configuration is
|
||||
/// incorrect"). Treating such a directory as downloaded is what made that state
|
||||
/// permanent: the registry rescan re-added it as a healthy install, so no
|
||||
/// re-download was ever offered. The check is scoped to the executable's own
|
||||
/// directory because the payload may sit at the version root or in a `bin/`,
|
||||
/// `wayfern/`, `wayfern-win/` or `chrome-win/` subdirectory.
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
fn has_sibling_dll(exe_path: &Path) -> bool {
|
||||
let Some(dir) = exe_path.parent() else {
|
||||
return false;
|
||||
};
|
||||
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||
return false;
|
||||
};
|
||||
entries.flatten().any(|entry| {
|
||||
entry
|
||||
.path()
|
||||
.extension()
|
||||
.is_some_and(|ext| ext.eq_ignore_ascii_case("dll"))
|
||||
})
|
||||
}
|
||||
|
||||
/// Check if a file is a valid PE executable by reading its magic bytes (MZ).
|
||||
/// Returns false for archive files (.zip starts with PK, etc.) that were
|
||||
/// incorrectly named with a .exe extension.
|
||||
@@ -575,6 +603,58 @@ mod tests {
|
||||
assert!(exe.ends_with(std::path::Path::new("wayfern-win").join("wayfern.exe")));
|
||||
}
|
||||
|
||||
/// A gutted Windows install (the `.exe` survived a cleanup pass that deleted
|
||||
/// every `.dll` and the `.manifest`) must not read as downloaded, otherwise it
|
||||
/// is re-registered as healthy and launching it fails with os error 14001.
|
||||
/// Runs on every platform because the predicate is platform-independent.
|
||||
#[test]
|
||||
fn test_lone_exe_is_not_a_valid_windows_install() {
|
||||
use tempfile::TempDir;
|
||||
let temp = TempDir::new().unwrap();
|
||||
let install_dir = temp.path();
|
||||
|
||||
let exe = install_dir.join("chrome.exe");
|
||||
std::fs::File::create(&exe).unwrap();
|
||||
assert!(
|
||||
!has_sibling_dll(&exe),
|
||||
"an .exe with no sibling .dll is a gutted install"
|
||||
);
|
||||
|
||||
std::fs::File::create(install_dir.join("chrome.dll")).unwrap();
|
||||
assert!(
|
||||
has_sibling_dll(&exe),
|
||||
"an .exe next to its libraries is a complete install"
|
||||
);
|
||||
}
|
||||
|
||||
/// The DLL check is scoped to the executable's own directory, so the nested
|
||||
/// `chrome-win/` and `wayfern-win/` layouts are not falsely rejected because
|
||||
/// the version root happens to hold no libraries.
|
||||
#[test]
|
||||
fn test_sibling_dll_check_is_scoped_to_the_executable_directory() {
|
||||
use tempfile::TempDir;
|
||||
let temp = TempDir::new().unwrap();
|
||||
let install_dir = temp.path();
|
||||
|
||||
let subdir = install_dir.join("chrome-win");
|
||||
std::fs::create_dir_all(&subdir).unwrap();
|
||||
let exe = subdir.join("chrome.exe");
|
||||
std::fs::File::create(&exe).unwrap();
|
||||
std::fs::File::create(subdir.join("CHROME.DLL")).unwrap();
|
||||
|
||||
assert!(
|
||||
has_sibling_dll(&exe),
|
||||
"libraries beside the executable count regardless of case or nesting"
|
||||
);
|
||||
|
||||
let root_exe = install_dir.join("chrome.exe");
|
||||
std::fs::File::create(&root_exe).unwrap();
|
||||
assert!(
|
||||
!has_sibling_dll(&root_exe),
|
||||
"libraries in a sibling subdirectory must not validate a bare root .exe"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_proxy_settings_serialization() {
|
||||
let proxy = ProxySettings {
|
||||
|
||||
+368
-64
@@ -15,6 +15,13 @@ static PROFILE_LAUNCH_LOCKS: LazyLock<
|
||||
tokio::sync::Mutex<HashMap<String, Arc<tokio::sync::Mutex<()>>>>,
|
||||
> = LazyLock::new(|| tokio::sync::Mutex::new(HashMap::new()));
|
||||
|
||||
/// How long a remote navigation waits for the page to settle.
|
||||
///
|
||||
/// A relayed round trip crosses two networks and the page load itself happens
|
||||
/// on hardware in another country, so this is deliberately the same budget the
|
||||
/// automation tools give a navigation rather than a loopback-sized one.
|
||||
const REMOTE_NAVIGATE_TIMEOUT_SECS: u64 = 30;
|
||||
|
||||
async fn lock_profile_launch(profile_id: &str) -> tokio::sync::OwnedMutexGuard<()> {
|
||||
let lock = {
|
||||
let mut locks = PROFILE_LAUNCH_LOCKS.lock().await;
|
||||
@@ -165,12 +172,15 @@ impl BrowserRunner {
|
||||
});
|
||||
}
|
||||
|
||||
/// Resolve the upstream a launch will use.
|
||||
///
|
||||
/// Deliberately does NOT fire the launch hook: that moved below the gate, so
|
||||
/// a launch the user blocks and then retries calls the user's webhook once
|
||||
/// rather than once per attempt.
|
||||
async fn resolve_launch_proxy(
|
||||
&self,
|
||||
profile: &BrowserProfile,
|
||||
) -> Result<Option<ProxySettings>, String> {
|
||||
Self::fire_launch_hook(profile);
|
||||
|
||||
self
|
||||
.resolve_proxy_with_refresh(profile.proxy_id.as_ref(), Some(&profile.id.to_string()))
|
||||
.await
|
||||
@@ -203,9 +213,9 @@ impl BrowserRunner {
|
||||
app_handle: tauri::AppHandle,
|
||||
profile: &BrowserProfile,
|
||||
url: Option<String>,
|
||||
_local_proxy_settings: Option<&ProxySettings>,
|
||||
remote_debugging_port: Option<u16>,
|
||||
headless: bool,
|
||||
gate: &crate::launch_gate::FingerprintGate,
|
||||
) -> Result<BrowserProfile, Box<dyn std::error::Error + Send + Sync>> {
|
||||
// Handle Wayfern profiles using WayfernManager
|
||||
if profile.browser == "wayfern" {
|
||||
@@ -270,12 +280,61 @@ impl BrowserRunner {
|
||||
upstream_proxy = Some(worker.local_proxy_settings());
|
||||
}
|
||||
|
||||
/// Stops a VPN worker this launch started, if the launch then fails.
|
||||
///
|
||||
/// `created` is the whole point: `start_vpn_worker` reuses a live worker
|
||||
/// for the same VPN, so an unconditional stop would sever the tunnel a
|
||||
/// *different* profile is browsing through the moment this one is
|
||||
/// cancelled. The in-use check is a second belt for a worker adopted by a
|
||||
/// browser that started between the two points.
|
||||
struct VpnLaunchGuard {
|
||||
worker_id: Option<String>,
|
||||
vpn_id: String,
|
||||
created: bool,
|
||||
profile_name: String,
|
||||
}
|
||||
impl Drop for VpnLaunchGuard {
|
||||
fn drop(&mut self) {
|
||||
let Some(worker_id) = self.worker_id.take() else {
|
||||
return;
|
||||
};
|
||||
if !self.created {
|
||||
return;
|
||||
}
|
||||
log::warn!(
|
||||
"Launch failed after VPN worker start for profile {}; stopping worker",
|
||||
self.profile_name
|
||||
);
|
||||
let vpn_id = self.vpn_id.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
// Serialize against worker startup for the whole check-then-stop.
|
||||
// Without it another launch can adopt this worker between the
|
||||
// in-use check and the kill, and lose its tunnel a moment later.
|
||||
let _adopt_guard = crate::vpn_worker_runner::lock_vpn_starts().await;
|
||||
if crate::vpn_worker_runner::vpn_id_in_use_by_running_browser(&vpn_id) {
|
||||
log::info!("VPN {vpn_id} is still in use by a running browser; leaving it up");
|
||||
return;
|
||||
}
|
||||
if let Err(error) = crate::vpn_worker_runner::stop_vpn_worker(&worker_id).await {
|
||||
log::warn!("Failed to stop VPN worker after failed launch: {error}");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
let mut vpn_launch_guard: Option<VpnLaunchGuard> = None;
|
||||
|
||||
// If profile has a VPN instead of proxy, start VPN worker and use it as upstream
|
||||
if upstream_proxy.is_none() {
|
||||
if let Some(ref vpn_id) = profile.vpn_id {
|
||||
match crate::vpn_worker_runner::start_vpn_worker(vpn_id).await {
|
||||
Ok(vpn_worker) => {
|
||||
if let Some(port) = vpn_worker.local_port {
|
||||
match crate::vpn_worker_runner::start_vpn_worker_tracked(vpn_id).await {
|
||||
Ok(started) => {
|
||||
vpn_launch_guard = Some(VpnLaunchGuard {
|
||||
worker_id: Some(started.config.id.clone()),
|
||||
vpn_id: vpn_id.clone(),
|
||||
created: started.created,
|
||||
profile_name: profile.name.clone(),
|
||||
});
|
||||
if let Some(port) = started.config.local_port {
|
||||
upstream_proxy = Some(ProxySettings {
|
||||
proxy_type: "socks5".to_string(),
|
||||
host: "127.0.0.1".to_string(),
|
||||
@@ -288,12 +347,33 @@ impl BrowserRunner {
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
return Err(format!("Failed to start VPN worker: {e}").into());
|
||||
return Err(crate::backend_error_with_detail("VPN_WORKER_START_FAILED", e).into());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The gate sits exactly here on purpose. By this line the upstream is
|
||||
// fully normalized across all three transports — VLESS and VPN are
|
||||
// authenticated loopback workers, a stored proxy is its resolved
|
||||
// settings — so one probe covers every profile. And it is still ahead of
|
||||
// the local proxy worker, the decrypted profile copy, the extension
|
||||
// unpack, and the browser process, so a blocked launch has nothing to
|
||||
// undo beyond the two workers whose guards are already armed above.
|
||||
//
|
||||
// Run concurrently with the blocklist compile so the added wall clock is
|
||||
// max(), not sum().
|
||||
let (blocklist, gate_result) = tokio::join!(
|
||||
Self::resolve_blocklist_file(profile),
|
||||
crate::launch_gate::enforce_fingerprint_gate(profile, upstream_proxy.as_ref(), gate),
|
||||
);
|
||||
gate_result.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { e.into() })?;
|
||||
let (blocklist_file, dns_allowlist_mode) = blocklist?;
|
||||
|
||||
// Past the gate: this launch is really happening, so tell the user's
|
||||
// webhook exactly once.
|
||||
Self::fire_launch_hook(profile);
|
||||
|
||||
log::info!(
|
||||
"Starting local proxy for Wayfern profile: {} (upstream: {})",
|
||||
profile.name,
|
||||
@@ -306,7 +386,6 @@ impl BrowserRunner {
|
||||
// Start the proxy and get local proxy settings
|
||||
// If proxy startup fails, DO NOT launch Wayfern - it requires local proxy
|
||||
let profile_id_str = profile.id.to_string();
|
||||
let (blocklist_file, dns_allowlist_mode) = Self::resolve_blocklist_file(profile).await?;
|
||||
// Unique per-launch key: a shared constant here would let concurrent
|
||||
// launches overwrite each other's active_proxies entry, ending with one
|
||||
// browser's worker tracked under another browser's PID.
|
||||
@@ -546,11 +625,14 @@ impl BrowserRunner {
|
||||
}
|
||||
}
|
||||
|
||||
// The browser and both detached routing workers now share one verified
|
||||
// The browser and every detached routing worker now share one verified
|
||||
// process identity, so later profile-persistence failures must not tear
|
||||
// down a live route.
|
||||
proxy_launch_guard.armed = false;
|
||||
xray_launch_guard.worker_id = None;
|
||||
if let Some(guard) = vpn_launch_guard.as_mut() {
|
||||
guard.worker_id = None;
|
||||
}
|
||||
|
||||
// Wayfern.setFingerprint echoes back the fingerprint the browser actually
|
||||
// applied, which may be UPGRADED from the stored one (e.g. when the
|
||||
@@ -687,6 +769,7 @@ impl BrowserRunner {
|
||||
url: Option<String>,
|
||||
remote_debugging_port: Option<u16>,
|
||||
headless: bool,
|
||||
gate: &crate::launch_gate::FingerprintGate,
|
||||
) -> Result<BrowserProfile, Box<dyn std::error::Error + Send + Sync>> {
|
||||
// Wayfern starts (and PID-reconciles) its own local proxy
|
||||
// inside `launch_browser_internal`, so we hand it None here rather than
|
||||
@@ -696,9 +779,9 @@ impl BrowserRunner {
|
||||
app_handle,
|
||||
profile,
|
||||
url,
|
||||
None,
|
||||
remote_debugging_port,
|
||||
headless,
|
||||
gate,
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -709,6 +792,7 @@ impl BrowserRunner {
|
||||
profile: &BrowserProfile,
|
||||
url: Option<String>,
|
||||
internal_proxy_settings: Option<&ProxySettings>,
|
||||
gate: &crate::launch_gate::FingerprintGate,
|
||||
) -> Result<BrowserProfile, Box<dyn std::error::Error + Send + Sync>> {
|
||||
log::info!(
|
||||
"launch_or_open_url called for profile: {} (ID: {})",
|
||||
@@ -789,14 +873,7 @@ impl BrowserRunner {
|
||||
} else {
|
||||
log::info!("Launching new browser instance - browser not running");
|
||||
self
|
||||
.launch_browser_internal(
|
||||
app_handle.clone(),
|
||||
&final_profile,
|
||||
url,
|
||||
internal_proxy_settings,
|
||||
None,
|
||||
false,
|
||||
)
|
||||
.launch_browser_internal(app_handle.clone(), &final_profile, url, None, false, gate)
|
||||
.await
|
||||
}
|
||||
}
|
||||
@@ -829,11 +906,64 @@ impl BrowserRunner {
|
||||
profile: &BrowserProfile,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let _profile_launch_guard = lock_profile_launch(&profile.id.to_string()).await;
|
||||
|
||||
// "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
|
||||
// and holding their profile lock the whole time.
|
||||
if self.stop_remote_session_for(&app_handle, profile).await? {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
self
|
||||
.kill_browser_process_unlocked(app_handle, profile)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Stop this profile's fleet session, if it has one. Returns whether it did.
|
||||
///
|
||||
/// Guarded on there being no local process so a locally running profile never
|
||||
/// pays for the lookup, exactly as the open-URL path is: the profile lock
|
||||
/// makes a local and a remote browser mutually exclusive.
|
||||
async fn stop_remote_session_for(
|
||||
&self,
|
||||
app_handle: &tauri::AppHandle,
|
||||
profile: &BrowserProfile,
|
||||
) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> {
|
||||
if profile.process_id.is_some() {
|
||||
return Ok(false);
|
||||
}
|
||||
let profile_id = profile.id.to_string();
|
||||
let Some(session_id) = crate::remote_handoff::running_session_for_profile(&profile_id) else {
|
||||
return Ok(false);
|
||||
};
|
||||
|
||||
log::info!(
|
||||
"Stopping remote session {session_id} for profile {} ({profile_id})",
|
||||
profile.name
|
||||
);
|
||||
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.
|
||||
log::warn!("Failed to stop remote session {session_id}: {e}");
|
||||
e.to_error_json().into()
|
||||
})?;
|
||||
|
||||
// The session is down and its work is in cloud storage. This is what puts
|
||||
// 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.
|
||||
crate::remote_session::note_session_stopped(app_handle, &session_id);
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
async fn kill_browser_process_unlocked(
|
||||
&self,
|
||||
app_handle: tauri::AppHandle,
|
||||
@@ -1210,6 +1340,7 @@ impl BrowserRunner {
|
||||
app_handle: tauri::AppHandle,
|
||||
profile_id: String,
|
||||
url: String,
|
||||
gate: crate::launch_gate::FingerprintGate,
|
||||
) -> Result<(), String> {
|
||||
// Get the profile by name
|
||||
let profiles = self
|
||||
@@ -1222,6 +1353,29 @@ impl BrowserRunner {
|
||||
.ok_or_else(|| format!("Profile '{profile_id}' not found"))?;
|
||||
let _profile_launch_guard = lock_profile_launch(&profile.id.to_string()).await;
|
||||
|
||||
// A profile already open on the leased fleet is driven, not launched. This
|
||||
// sits above the cross-OS guard on purpose: a Windows profile cannot run on
|
||||
// this Mac, which is the whole reason it is running remotely, and refusing
|
||||
// to point it at a URL for that reason would make the remote session
|
||||
// unusable from the one endpoint that exists to use it.
|
||||
//
|
||||
// Guarded on there being no local process, so a profile running here never
|
||||
// pays for the lookup: a local launch records a pid, and the profile lock
|
||||
// keeps a local and a remote session mutually exclusive.
|
||||
if profile.process_id.is_none() {
|
||||
if let Ok(target) = crate::cdp_target::resolve(&profile).await {
|
||||
if target.is_remote() {
|
||||
log::info!("Opening URL through {}", target.describe());
|
||||
return crate::cdp_target::navigate(&target, &url, REMOTE_NAVIGATE_TIMEOUT_SECS)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
log::warn!("Failed to open a URL on the remote browser: {e}");
|
||||
format!("Failed to open URL with profile: {e}")
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if profile.is_cross_os() {
|
||||
return Err(format!(
|
||||
"Cannot open URL with profile '{}': this profile was created on {} and cannot be used on a different operating system",
|
||||
@@ -1230,19 +1384,36 @@ impl BrowserRunner {
|
||||
));
|
||||
}
|
||||
|
||||
// Past this point a local browser is about to be launched, and until now
|
||||
// this was the ONE launch path that took neither the profile lock nor any
|
||||
// notice of the fleet. A remote session whose state could not be read (a
|
||||
// dropped event stream plus an unreachable backend) fell straight through
|
||||
// to a local launch on a profile a host was writing to.
|
||||
crate::remote_handoff::ensure_local_launch_allowed(&profile.id.to_string())?;
|
||||
let acquired_team_lock = crate::team_lock::acquire_team_lock_if_needed(&profile).await?;
|
||||
|
||||
log::info!("Opening URL with selected profile");
|
||||
|
||||
// Use launch_or_open_url which handles both launching new instances and opening in existing ones
|
||||
self
|
||||
.launch_or_open_url(app_handle, &profile, Some(url.clone()), None)
|
||||
if let Err(e) = self
|
||||
.launch_or_open_url(app_handle, &profile, Some(url.clone()), None, &gate)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
log::info!(
|
||||
"Failed to open URL with selected profile: {}",
|
||||
crate::log_redaction::text(&e.to_string())
|
||||
);
|
||||
format!("Failed to open URL with profile: {e}")
|
||||
})?;
|
||||
{
|
||||
log::info!(
|
||||
"Failed to open URL with selected profile: {}",
|
||||
crate::log_redaction::text(&e.to_string())
|
||||
);
|
||||
// This path takes the team lock too, and a blocked launch never records a
|
||||
// process_id for the status sweep to release it from.
|
||||
unwind_launch(&profile, acquired_team_lock).await;
|
||||
// Pass structured errors through untouched: the gate's block carries the
|
||||
// mismatch detail the dialog renders, and wrapping it in English would
|
||||
// reach the user as raw JSON.
|
||||
return Err(crate::wrap_backend_error(
|
||||
e,
|
||||
"Failed to open URL with profile",
|
||||
));
|
||||
}
|
||||
|
||||
log::info!("Successfully opened URL with selected profile");
|
||||
Ok(())
|
||||
@@ -1254,18 +1425,115 @@ pub async fn launch_browser_profile(
|
||||
app_handle: tauri::AppHandle,
|
||||
profile: BrowserProfile,
|
||||
url: Option<String>,
|
||||
consent_token: Option<String>,
|
||||
) -> Result<BrowserProfile, String> {
|
||||
launch_browser_profile_impl(app_handle, profile, url, None, false, false).await
|
||||
let options = LaunchOptions {
|
||||
gate: match consent_token {
|
||||
Some(token) => crate::launch_gate::FingerprintGate::Consented(token),
|
||||
None => crate::launch_gate::FingerprintGate::Enforce,
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
launch_browser_profile_impl(app_handle, profile, url, options).await
|
||||
}
|
||||
|
||||
/// How one launch should behave.
|
||||
///
|
||||
/// A struct rather than four trailing positional arguments: `headless` and
|
||||
/// `force_new` are already passed adjacently as bare booleans, so a fifth would
|
||||
/// compile everywhere while silently inverting behavior wherever the order was
|
||||
/// got wrong.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct LaunchOptions {
|
||||
pub remote_debugging_port: Option<u16>,
|
||||
pub headless: bool,
|
||||
pub force_new: bool,
|
||||
pub gate: crate::launch_gate::FingerprintGate,
|
||||
}
|
||||
|
||||
impl LaunchOptions {
|
||||
/// Automation defaults: report, never block, never probe. A headless client
|
||||
/// has no dialog to answer and cannot regenerate its fingerprint mid-run, so
|
||||
/// a hard failure would turn a warning into an outage for a whole fleet.
|
||||
pub fn automation(remote_debugging_port: Option<u16>, headless: bool) -> Self {
|
||||
Self {
|
||||
remote_debugging_port,
|
||||
headless,
|
||||
force_new: true,
|
||||
gate: crate::launch_gate::FingerprintGate::Advisory,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Release the team lock a launch attempt took before it failed.
|
||||
///
|
||||
/// Until the gate existed, failing here was rare enough that leaking was merely
|
||||
/// untidy. Cancelling a blocked launch is now an ordinary outcome, and the lock
|
||||
/// renews itself on a 30s heartbeat while only ever being released via a stored
|
||||
/// `process_id` — which a launch that never spawned does not have. So a leak
|
||||
/// leaves the profile reading as locked to the whole team until the app quits.
|
||||
///
|
||||
/// `acquired` is threaded from `acquire_team_lock_if_needed` so this releases
|
||||
/// only what this call took, never a lock a REST handler up the stack owns.
|
||||
///
|
||||
/// Several of these error paths are reachable while a browser for the profile
|
||||
/// is genuinely still running — `PROFILE_RUNNING`, or a failure to open a URL
|
||||
/// in an existing window. That browser owns the lock and the running mark, so
|
||||
/// releasing either would strand it: the team would see the profile as free
|
||||
/// while someone is typing in it, and `mark_profile_stopped` would queue a sync
|
||||
/// of a profile directory being written to. Hence the liveness check.
|
||||
async fn unwind_launch(profile: &BrowserProfile, acquired_team_lock: bool) {
|
||||
if browser_is_running_for(&profile.id.to_string()) {
|
||||
log::debug!(
|
||||
"Not unwinding launch state for {}: a browser is still running for it",
|
||||
profile.name
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if acquired_team_lock {
|
||||
crate::team_lock::release_team_lock_if_needed(profile).await;
|
||||
}
|
||||
// Otherwise this mark sticks for the rest of the session and silently defers
|
||||
// every sync of the profile. Safe here precisely because nothing is running.
|
||||
if let Some(scheduler) = crate::sync::get_global_scheduler() {
|
||||
scheduler
|
||||
.mark_profile_stopped(&profile.id.to_string())
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a live browser process is recorded for this profile right now.
|
||||
/// Re-read from disk: the caller's copy predates the launch attempt.
|
||||
fn browser_is_running_for(profile_id: &str) -> bool {
|
||||
BrowserRunner::instance()
|
||||
.profile_manager
|
||||
.list_profiles()
|
||||
.ok()
|
||||
.and_then(|profiles| {
|
||||
profiles
|
||||
.into_iter()
|
||||
.find(|p| p.id.to_string() == profile_id)
|
||||
.map(|p| {
|
||||
p.process_id
|
||||
.is_some_and(crate::proxy_storage::is_process_running)
|
||||
})
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
pub async fn launch_browser_profile_impl(
|
||||
app_handle: tauri::AppHandle,
|
||||
profile: BrowserProfile,
|
||||
url: Option<String>,
|
||||
remote_debugging_port: Option<u16>,
|
||||
headless: bool,
|
||||
force_new: bool,
|
||||
options: LaunchOptions,
|
||||
) -> Result<BrowserProfile, String> {
|
||||
let LaunchOptions {
|
||||
remote_debugging_port,
|
||||
headless,
|
||||
force_new,
|
||||
gate,
|
||||
} = options;
|
||||
log::info!(
|
||||
"Launch request received for profile: {} (ID: {})",
|
||||
profile.name,
|
||||
@@ -1281,8 +1549,14 @@ pub async fn launch_browser_profile_impl(
|
||||
));
|
||||
}
|
||||
|
||||
// Refuse a launch that would run over work a remote session has not handed
|
||||
// back yet. Checked before the profile lock because it answers without a
|
||||
// round trip and because it stays true after the session's lock is released:
|
||||
// the lock protects the browser, this protects the bytes it wrote.
|
||||
crate::remote_handoff::ensure_local_launch_allowed(&profile.id.to_string())?;
|
||||
|
||||
// Team lock check: if profile is sync-enabled and user is on a team, acquire lock
|
||||
crate::team_lock::acquire_team_lock_if_needed(&profile).await?;
|
||||
let acquired_team_lock = crate::team_lock::acquire_team_lock_if_needed(&profile).await?;
|
||||
|
||||
// Notify sync scheduler that profile is now running and queue sync for when it stops
|
||||
if let Some(scheduler) = crate::sync::get_global_scheduler() {
|
||||
@@ -1306,6 +1580,7 @@ pub async fn launch_browser_profile_impl(
|
||||
.find(|p| p.id == profile.id)
|
||||
.unwrap_or_else(|| profile.clone()),
|
||||
Err(e) => {
|
||||
unwind_launch(&profile, acquired_team_lock).await;
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
@@ -1322,15 +1597,24 @@ pub async fn launch_browser_profile_impl(
|
||||
profile_for_launch.id
|
||||
);
|
||||
|
||||
if force_new
|
||||
&& browser_runner
|
||||
if force_new {
|
||||
let already_running = match browser_runner
|
||||
.check_browser_status(app_handle.clone(), &profile_for_launch)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
crate::wrap_backend_error(error, "Failed to check browser status before launch")
|
||||
})?
|
||||
{
|
||||
return Err(crate::backend_error("PROFILE_RUNNING"));
|
||||
{
|
||||
Ok(running) => running,
|
||||
Err(error) => {
|
||||
unwind_launch(&profile, acquired_team_lock).await;
|
||||
return Err(crate::wrap_backend_error(
|
||||
error,
|
||||
"Failed to check browser status before launch",
|
||||
));
|
||||
}
|
||||
};
|
||||
if already_running {
|
||||
unwind_launch(&profile, acquired_team_lock).await;
|
||||
return Err(crate::backend_error("PROFILE_RUNNING"));
|
||||
}
|
||||
}
|
||||
|
||||
// Launch browser or open URL in existing instance. Wayfern starts its
|
||||
@@ -1348,39 +1632,54 @@ pub async fn launch_browser_profile_impl(
|
||||
url,
|
||||
remote_debugging_port,
|
||||
headless,
|
||||
&gate,
|
||||
)
|
||||
.await
|
||||
} else {
|
||||
browser_runner
|
||||
.launch_or_open_url(app_handle.clone(), &profile_for_launch, url, None)
|
||||
.launch_or_open_url(app_handle.clone(), &profile_for_launch, url, None, &gate)
|
||||
.await
|
||||
};
|
||||
let updated_profile = launch_result.map_err(|e| {
|
||||
log::info!("Browser launch failed for profile: {}, error: {}", profile_for_launch.name, e);
|
||||
let updated_profile = match launch_result {
|
||||
Ok(updated) => updated,
|
||||
Err(e) => {
|
||||
log::info!(
|
||||
"Browser launch failed for profile: {}, error: {}",
|
||||
profile_for_launch.name,
|
||||
e
|
||||
);
|
||||
|
||||
// Emit a failure event to clear loading states in the frontend
|
||||
#[derive(serde::Serialize)]
|
||||
struct RunningChangedPayload {
|
||||
id: String,
|
||||
is_running: bool,
|
||||
}
|
||||
let payload = RunningChangedPayload {
|
||||
id: profile_for_launch.id.to_string(),
|
||||
is_running: false,
|
||||
};
|
||||
|
||||
if let Err(e) = events::emit("profile-running-changed", &payload) {
|
||||
log::warn!("Warning: Failed to emit profile running changed event: {e}");
|
||||
}
|
||||
|
||||
// Check if this is an architecture compatibility issue
|
||||
if let Some(io_error) = e.downcast_ref::<std::io::Error>() {
|
||||
if io_error.kind() == std::io::ErrorKind::Other && io_error.to_string().contains("Exec format error") {
|
||||
return format!("Failed to launch browser: Executable format error. This browser version is not compatible with your system architecture ({}). Please try a different browser or version that supports your platform.", std::env::consts::ARCH);
|
||||
// Emit a failure event to clear loading states in the frontend
|
||||
#[derive(serde::Serialize)]
|
||||
struct RunningChangedPayload {
|
||||
id: String,
|
||||
is_running: bool,
|
||||
}
|
||||
let payload = RunningChangedPayload {
|
||||
id: profile_for_launch.id.to_string(),
|
||||
is_running: false,
|
||||
};
|
||||
|
||||
if let Err(e) = events::emit("profile-running-changed", &payload) {
|
||||
log::warn!("Warning: Failed to emit profile running changed event: {e}");
|
||||
}
|
||||
|
||||
unwind_launch(&profile, acquired_team_lock).await;
|
||||
|
||||
// Check if this is an architecture compatibility issue
|
||||
if let Some(io_error) = e.downcast_ref::<std::io::Error>() {
|
||||
if io_error.kind() == std::io::ErrorKind::Other
|
||||
&& io_error.to_string().contains("Exec format error")
|
||||
{
|
||||
return Err(format!("Failed to launch browser: Executable format error. This browser version is not compatible with your system architecture ({}). Please try a different browser or version that supports your platform.", std::env::consts::ARCH));
|
||||
}
|
||||
}
|
||||
return Err(crate::wrap_backend_error(
|
||||
e,
|
||||
"Failed to launch browser or open URL",
|
||||
));
|
||||
}
|
||||
crate::wrap_backend_error(e, "Failed to launch browser or open URL")
|
||||
})?;
|
||||
};
|
||||
|
||||
log::info!(
|
||||
"Browser launch completed for profile: {} (ID: {})",
|
||||
@@ -1513,10 +1812,15 @@ pub async fn open_url_with_profile(
|
||||
app_handle: tauri::AppHandle,
|
||||
profile_id: String,
|
||||
url: String,
|
||||
consent_token: Option<String>,
|
||||
) -> Result<(), String> {
|
||||
let browser_runner = BrowserRunner::instance();
|
||||
let gate = match consent_token {
|
||||
Some(token) => crate::launch_gate::FingerprintGate::Consented(token),
|
||||
None => crate::launch_gate::FingerprintGate::Enforce,
|
||||
};
|
||||
browser_runner
|
||||
.open_url_with_profile(app_handle, profile_id, url)
|
||||
.open_url_with_profile(app_handle, profile_id, url, gate)
|
||||
.await
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+51
-11
@@ -28,9 +28,9 @@ 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
|
||||
/// flags instead of a single "is paid?" boolean, so a plan like the future
|
||||
/// "starter" tier (cross-OS fingerprints + cloud backup, no automation) is just
|
||||
/// data here.
|
||||
/// 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.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Entitlements {
|
||||
#[serde(default)]
|
||||
@@ -49,6 +49,12 @@ pub struct Entitlements {
|
||||
/// together.
|
||||
#[serde(rename = "cookieBot", default)]
|
||||
pub cookie_bot: bool,
|
||||
/// Whether the plan may open a HANDS-ON remote session. Distinct from
|
||||
/// `cookie_bot`: solo funds a nightly bot out of its remote hours but may not
|
||||
/// drive a remote browser itself, so anything that offers interactive remote
|
||||
/// control must read THIS rather than `remote_browser_hours > 0`.
|
||||
#[serde(rename = "remoteInteractive", default)]
|
||||
pub remote_interactive: bool,
|
||||
#[serde(rename = "profileLimit", default)]
|
||||
pub profile_limit: i64,
|
||||
#[serde(rename = "requestsPerHour", default)]
|
||||
@@ -77,16 +83,29 @@ fn derive_entitlements(
|
||||
cloud_backup: false,
|
||||
team_collaboration: false,
|
||||
cookie_bot: false,
|
||||
remote_interactive: false,
|
||||
profile_limit: 0,
|
||||
requests_per_hour: 0,
|
||||
remote_browser_hours: 0,
|
||||
};
|
||||
}
|
||||
// pro and any unrecognized paid plan -> pro-level (never team).
|
||||
let (browser_automation, cross_os_fingerprints, cloud_backup, team_collaboration) = match plan {
|
||||
"starter" => (false, true, true, false),
|
||||
"team" | "enterprise" => (true, true, true, true),
|
||||
_ => (true, true, true, false),
|
||||
// Tuple order: (browser_automation, cross_os_fingerprints, cloud_backup,
|
||||
// team_collaboration, cookie_bot, remote_interactive).
|
||||
//
|
||||
// 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.
|
||||
let (
|
||||
browser_automation,
|
||||
cross_os_fingerprints,
|
||||
cloud_backup,
|
||||
team_collaboration,
|
||||
cookie_bot,
|
||||
remote_interactive,
|
||||
) = match plan {
|
||||
"solo" => (false, false, true, false, true, false),
|
||||
"team" | "enterprise" => (true, true, true, true, true, true),
|
||||
_ => (true, true, true, false, true, true),
|
||||
};
|
||||
Entitlements {
|
||||
active,
|
||||
@@ -94,9 +113,8 @@ fn derive_entitlements(
|
||||
cross_os_fingerprints,
|
||||
cloud_backup,
|
||||
team_collaboration,
|
||||
// A bot run IS remote automation on leased hardware, so the two capabilities
|
||||
// never diverge: a plan that cannot drive a browser cannot warm one either.
|
||||
cookie_bot: browser_automation,
|
||||
cookie_bot,
|
||||
remote_interactive,
|
||||
profile_limit,
|
||||
requests_per_hour: if browser_automation {
|
||||
DEFAULT_REQUESTS_PER_HOUR
|
||||
@@ -155,6 +173,13 @@ impl CloudUser {
|
||||
/// locally from the plan fields (keeps older cached state / backends working).
|
||||
pub fn entitlements(&self) -> Entitlements {
|
||||
if let Some(e) = &self.entitlements {
|
||||
// Returned verbatim, INCLUDING the `#[serde(default)]` false that a
|
||||
// backend older than this release leaves on `cookie_bot` /
|
||||
// `remote_interactive`. Repairing it here is impossible anyway — serde's
|
||||
// default erases the difference between "sent false" and "not sent" — and
|
||||
// it is not this layer's job: nothing in Rust gates on either flag, and
|
||||
// `getEntitlements()` in `src/lib/entitlements.ts` fills both gaps at the
|
||||
// single point every UI consumer already goes through.
|
||||
return e.clone();
|
||||
}
|
||||
derive_entitlements(
|
||||
@@ -794,6 +819,21 @@ impl CloudAuthManager {
|
||||
}
|
||||
|
||||
/// Launch/drive profiles programmatically (local API + MCP automation).
|
||||
/// Whether this account may run the nightly Cookie Bot.
|
||||
///
|
||||
/// NOT `can_use_browser_automation`. Solo is exactly the plan where the two
|
||||
/// disagree — it pays for a nightly bot and has `browser_automation: false` —
|
||||
/// so gating the bot on automation refused a Solo customer the one feature
|
||||
/// their plan is sold on, and answered 402 while their scheduled runs kept
|
||||
/// working server-side.
|
||||
pub async fn can_use_cookie_bot(&self) -> bool {
|
||||
self
|
||||
.entitlements()
|
||||
.await
|
||||
.map(|e| e.cookie_bot)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
pub async fn can_use_browser_automation(&self) -> bool {
|
||||
#[cfg(feature = "e2e")]
|
||||
if crate::e2e_automation_enabled()
|
||||
|
||||
+642
-11
@@ -51,6 +51,20 @@ const REPORT_CODES: FailureCodes = FailureCodes {
|
||||
conflict: cloud_errors::UNAVAILABLE,
|
||||
};
|
||||
|
||||
/// Failure codes for the user-template routes.
|
||||
///
|
||||
/// Distinct from `SCHEDULE_CODES` on every axis that matters: a 404 here is a
|
||||
/// template that was deleted (possibly from another device), not an unenrolled
|
||||
/// profile, and a 409 is a name the user already used, not a teammate's
|
||||
/// enrolment. Sharing the schedule set would have told someone renaming a site
|
||||
/// list that a colleague already warms this profile.
|
||||
const TEMPLATE_CODES: FailureCodes = FailureCodes {
|
||||
bad_request: "COOKIE_BOT_INVALID_TEMPLATE_NAME",
|
||||
forbidden: "COOKIE_BOT_NOT_ENTITLED",
|
||||
not_found: "COOKIE_BOT_TEMPLATE_NOT_FOUND",
|
||||
conflict: "COOKIE_BOT_TEMPLATE_NAME_TAKEN",
|
||||
};
|
||||
|
||||
/// Every cookie-bot call fails as a code the frontend can translate.
|
||||
///
|
||||
/// There is no `Other(String)` carrying backend English: a raw message reaches
|
||||
@@ -91,6 +105,18 @@ impl From<BackendFailure> for CookieBotError {
|
||||
// One place for every request and response shape, so a backend contract change
|
||||
// is a single edit here rather than a hunt through call sites.
|
||||
|
||||
/// One time-of-day an enrolment fires, on a set of local weekdays.
|
||||
///
|
||||
/// Copy, and deliberately tiny: a calendar is a list of these, and the desktop
|
||||
/// rebuilds that list on every keystroke in the enrolment form.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
pub struct CookieBotSlot {
|
||||
/// Bitmask of local weekdays, bit 0 = Monday. At least one bit set.
|
||||
pub days_mask: u8,
|
||||
/// Minutes past local midnight, in the schedule's timezone.
|
||||
pub run_at_minute: u16,
|
||||
}
|
||||
|
||||
/// A profile enrolled in the nightly bot.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
pub struct CookieBotSchedule {
|
||||
@@ -98,13 +124,31 @@ pub struct CookieBotSchedule {
|
||||
pub profile_name: String,
|
||||
pub platform: String,
|
||||
pub enabled: bool,
|
||||
/// Minutes past local midnight the run is anchored to.
|
||||
/// Minutes past local midnight the FIRST slot is anchored to. The server
|
||||
/// mirrors `slots[0]` onto this pair on every write.
|
||||
pub run_at_minute: u16,
|
||||
/// Bitmask of local weekdays, bit 0 = Monday.
|
||||
/// The first slot's weekdays, bit 0 = Monday. See `run_at_minute`.
|
||||
pub days_mask: u8,
|
||||
/// Every time-of-day this enrolment fires.
|
||||
///
|
||||
/// `default` rather than required because a server older than multi-slot
|
||||
/// scheduling sends only the mirrored pair above, and a decode failure there
|
||||
/// would blank the whole Cookie Bot surface rather than show one time instead
|
||||
/// of several. Callers must therefore fall back to the pair when this is
|
||||
/// empty — never treat an empty list as "fires at no time".
|
||||
#[serde(default)]
|
||||
pub slots: Vec<CookieBotSlot>,
|
||||
pub timezone: String,
|
||||
/// Server-issued preset id. Opaque here — what it expands to is infra's.
|
||||
pub preset: String,
|
||||
/// The template the sites came from, or `None` for the user's own list.
|
||||
///
|
||||
/// A built-in id (`low-intent-purchaser`) means `sites` is EMPTY on purpose:
|
||||
/// its URLs are server-owned and never sent to a client. A `user:<uuid>` id
|
||||
/// is provenance only — those sites were copied onto the enrolment and are
|
||||
/// present below.
|
||||
#[serde(default)]
|
||||
pub template_id: Option<String>,
|
||||
pub max_minutes: u32,
|
||||
#[serde(default)]
|
||||
pub sites: Vec<String>,
|
||||
@@ -120,6 +164,11 @@ 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.
|
||||
#[serde(default)]
|
||||
pub proxy_remote_reachable: bool,
|
||||
#[serde(default)]
|
||||
pub touch_fingerprint: bool,
|
||||
#[serde(default)]
|
||||
@@ -162,8 +211,26 @@ pub struct CookieBotScheduleInput {
|
||||
pub enabled: bool,
|
||||
pub run_at_minute: u16,
|
||||
pub days_mask: u8,
|
||||
/// The whole calendar, when the caller has one.
|
||||
///
|
||||
/// `skip_serializing_if` is load-bearing rather than tidiness: the server
|
||||
/// reads an ABSENT `slots` as "one slot, from the pair above" and refuses a
|
||||
/// present-but-empty one, and `null` takes the refusing branch. Serialising
|
||||
/// `None` as null would 400 every write from a single-slot form.
|
||||
///
|
||||
/// The pair above is still sent, mirrored from `slots[0]`, so a server that
|
||||
/// predates multi-slot stores the first time rather than nothing.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub slots: Option<Vec<CookieBotSlot>>,
|
||||
pub timezone: String,
|
||||
pub preset: String,
|
||||
/// A browsing template instead of a typed site list.
|
||||
///
|
||||
/// Mutually exclusive with a non-empty `sites`: the server refuses a write
|
||||
/// carrying both, because merging a curated persona with the user's own list
|
||||
/// produces neither. A caller naming a template sends `sites: []`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub template_id: Option<String>,
|
||||
pub max_minutes: u32,
|
||||
pub sites: Vec<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
@@ -191,6 +258,8 @@ pub struct CookieBotScheduleInput {
|
||||
#[serde(default)]
|
||||
pub has_proxy: bool,
|
||||
#[serde(default)]
|
||||
pub proxy_remote_reachable: bool,
|
||||
#[serde(default)]
|
||||
pub encrypted_sync: bool,
|
||||
#[serde(default)]
|
||||
pub touch_fingerprint: bool,
|
||||
@@ -331,6 +400,53 @@ pub struct CookieBotPreset {
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
/// 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.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
pub struct CookieBotTemplate {
|
||||
pub id: String,
|
||||
/// How many sites this template browses. Not which.
|
||||
#[serde(default)]
|
||||
pub site_count: u32,
|
||||
/// Server-supplied English label and blurb, present only so a template added
|
||||
/// after this build still renders. The UI prefers its own `t()` key for an id
|
||||
/// it recognises.
|
||||
#[serde(default)]
|
||||
pub name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
/// The bounds the schedule routes actually enforce, as this build reads them.
|
||||
///
|
||||
/// Every field is optional because a server that predates `limits` sends none
|
||||
/// of them, and a client that read a missing bound as `0` would refuse every
|
||||
/// value the form can produce. Only the bounds the desktop acts on are decoded
|
||||
/// — serde drops the rest, and this struct is what the GUI ultimately receives,
|
||||
/// so adding a field here is what makes one reachable from TypeScript.
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
pub struct CookieBotLimits {
|
||||
#[serde(default)]
|
||||
pub min_minutes: Option<u32>,
|
||||
#[serde(default)]
|
||||
pub max_minutes: Option<u32>,
|
||||
#[serde(default)]
|
||||
pub min_sites: Option<u32>,
|
||||
#[serde(default)]
|
||||
pub max_sites: Option<u32>,
|
||||
/// Most entries a calendar may carry.
|
||||
#[serde(default)]
|
||||
pub max_slots: Option<u32>,
|
||||
/// Longest name a saved site list may be given.
|
||||
#[serde(default)]
|
||||
pub max_template_name_length: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
pub struct CookieBotPresetList {
|
||||
#[serde(default)]
|
||||
@@ -339,6 +455,33 @@ pub struct CookieBotPresetList {
|
||||
/// preference.
|
||||
#[serde(default)]
|
||||
pub default_preset: Option<String>,
|
||||
/// The curated templates on offer. Served beside the presets so a template
|
||||
/// added server-side appears without a desktop release.
|
||||
#[serde(default)]
|
||||
pub templates: Vec<CookieBotTemplate>,
|
||||
/// The server's own bounds, when it publishes them. The desktop mirrors a
|
||||
/// copy for offline form validation; these win where they disagree.
|
||||
#[serde(default)]
|
||||
pub limits: Option<CookieBotLimits>,
|
||||
}
|
||||
|
||||
/// One of the caller's OWN saved site lists.
|
||||
///
|
||||
/// Carries its URLs, unlike {@link CookieBotTemplate} — they are the user's own
|
||||
/// and there is nothing to withhold. Applying one copies the sites onto the
|
||||
/// enrolment, so a list edited later does not silently change what an existing
|
||||
/// enrolment browses until it is saved again.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
pub struct CookieBotUserTemplate {
|
||||
/// Already carries the `user:` prefix: this id's job is to be pasted into a
|
||||
/// schedule's `template_id`, and assembling that convention on the client is
|
||||
/// how the two kinds of template get confused.
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub sites: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub updated_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
@@ -465,7 +608,10 @@ pub struct CookieBotUsage {
|
||||
/// 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
|
||||
/// `{"code":…}` string a Tauri command surfaces directly.
|
||||
pub fn bot_precondition(profile: &BrowserProfile) -> Result<(), String> {
|
||||
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.
|
||||
@@ -491,6 +637,21 @@ pub fn bot_precondition(profile: &BrowserProfile) -> Result<(), String> {
|
||||
// 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.
|
||||
//
|
||||
// Taken as an ARGUMENT rather than resolved here, for the same reason
|
||||
// `ProfileState` is required rather than defaulted: resolving it needs the
|
||||
// proxy and VPN stores, and a function that reaches into those globals is one
|
||||
// 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.
|
||||
if !exit.is_remote() {
|
||||
return Err(error("COOKIE_BOT_REQUIRES_REMOTE_EXIT_NODE", &[]));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -511,6 +672,12 @@ pub fn profile_state(profile: &BrowserProfile) -> ProfileState {
|
||||
// A VPN is an exit node just as much as a proxy is; the server only asks
|
||||
// whether the traffic leaves through something the user brought.
|
||||
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`.
|
||||
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
|
||||
@@ -533,16 +700,69 @@ 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.
|
||||
pub proxy_remote_reachable: bool,
|
||||
pub touch_fingerprint: bool,
|
||||
pub sticky_exit: bool,
|
||||
}
|
||||
|
||||
/// Whether this profile's exit could be used from a host that is not this one.
|
||||
///
|
||||
/// Resolves the profile's proxy or VPN out of local storage — the server cannot
|
||||
/// do this, because it never sees a proxy record until sync has uploaded one and
|
||||
/// 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.
|
||||
pub fn exit_reachability(profile: &BrowserProfile) -> crate::remote_exit::ExitReachability {
|
||||
use crate::remote_exit::{classify_proxy, classify_wireguard_endpoint, ExitReachability};
|
||||
|
||||
if let Some(proxy_id) = profile.proxy_id.as_deref() {
|
||||
let stored = crate::proxy_manager::PROXY_MANAGER
|
||||
.get_stored_proxies()
|
||||
.into_iter()
|
||||
.find(|candidate| candidate.id == proxy_id);
|
||||
return match stored {
|
||||
Some(proxy) => classify_proxy(&proxy.proxy_settings),
|
||||
// Referenced but missing. Fail closed: a dangling id is not evidence of a
|
||||
// reachable exit, and the launch would fail anyway.
|
||||
None => ExitReachability::Unknown {
|
||||
reason: "the profile references a proxy that no longer exists".to_string(),
|
||||
source: "proxy",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if let Some(vpn_id) = profile.vpn_id.as_deref() {
|
||||
let config = crate::vpn::VPN_STORAGE
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|storage| storage.load_config(vpn_id).ok());
|
||||
return match config {
|
||||
Some(config) => match crate::vpn::parse_wireguard_config(&config.config_data) {
|
||||
Ok(parsed) => classify_wireguard_endpoint(&parsed.peer_endpoint),
|
||||
Err(error) => ExitReachability::Unknown {
|
||||
reason: format!("VPN config could not be parsed ({error})"),
|
||||
source: "VPN",
|
||||
},
|
||||
},
|
||||
None => ExitReachability::Unknown {
|
||||
reason: "the profile references a VPN config that no longer exists".to_string(),
|
||||
source: "VPN",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
ExitReachability::None
|
||||
}
|
||||
|
||||
impl CookieBotScheduleInput {
|
||||
/// Stamp the profile facts onto an input built from user-chosen values.
|
||||
pub fn with_profile_state(mut self, state: ProfileState) -> Self {
|
||||
self.sync_enabled = state.sync_enabled;
|
||||
self.encrypted_sync = state.encrypted_sync;
|
||||
self.has_proxy = state.has_proxy;
|
||||
self.proxy_remote_reachable = state.proxy_remote_reachable;
|
||||
self.touch_fingerprint = state.touch_fingerprint;
|
||||
self.sticky_exit = state.sticky_exit;
|
||||
self
|
||||
@@ -663,6 +883,10 @@ pub async fn update_profile_state(
|
||||
body.insert("sync_enabled".to_string(), state.sync_enabled.into());
|
||||
body.insert("encrypted_sync".to_string(), state.encrypted_sync.into());
|
||||
body.insert("has_proxy".to_string(), state.has_proxy.into());
|
||||
body.insert(
|
||||
"proxy_remote_reachable".to_string(),
|
||||
state.proxy_remote_reachable.into(),
|
||||
);
|
||||
body.insert(
|
||||
"touch_fingerprint".to_string(),
|
||||
state.touch_fingerprint.into(),
|
||||
@@ -855,6 +1079,171 @@ pub async fn list_presets() -> Result<CookieBotPresetList, CookieBotError> {
|
||||
.await
|
||||
}
|
||||
|
||||
// --- User-defined templates -------------------------------------------------
|
||||
//
|
||||
// The caller's own saved site lists. Unlike every other route in this file
|
||||
// these are addressed by an id the SERVER minted and the client echoes back,
|
||||
// so each one percent-encodes it: the id is spelled `user:<uuid>`, and a bare
|
||||
// colon in a path segment is a spelling the router is free to read differently.
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct UserTemplateListEnvelope {
|
||||
#[serde(default)]
|
||||
templates: Vec<CookieBotUserTemplate>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct UserTemplateEnvelope {
|
||||
template: CookieBotUserTemplate,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct UserTemplateDeleted {
|
||||
#[serde(default)]
|
||||
deleted: bool,
|
||||
}
|
||||
|
||||
/// Every site list this user has saved, most recently edited first.
|
||||
pub async fn list_user_templates() -> Result<Vec<CookieBotUserTemplate>, CookieBotError> {
|
||||
let envelope: UserTemplateListEnvelope = request(
|
||||
reqwest::Method::GET,
|
||||
format!("{}/user-templates", base()),
|
||||
Vec::new(),
|
||||
None,
|
||||
TEMPLATE_CODES,
|
||||
)
|
||||
.await?;
|
||||
Ok(envelope.templates)
|
||||
}
|
||||
|
||||
/// Save a new one.
|
||||
pub async fn create_user_template(
|
||||
name: &str,
|
||||
sites: &[String],
|
||||
) -> Result<CookieBotUserTemplate, CookieBotError> {
|
||||
let body = serde_json::json!({ "name": name, "sites": sites });
|
||||
let envelope: UserTemplateEnvelope = request(
|
||||
reqwest::Method::POST,
|
||||
format!("{}/user-templates", base()),
|
||||
Vec::new(),
|
||||
Some(body),
|
||||
TEMPLATE_CODES,
|
||||
)
|
||||
.await?;
|
||||
Ok(envelope.template)
|
||||
}
|
||||
|
||||
/// Rename one, replace its sites, or both.
|
||||
///
|
||||
/// A PATCH with only the fields that changed, because the two are independent:
|
||||
/// a rename that had to carry the whole site list is a rename that silently
|
||||
/// reverts an edit made to it from another device in the meantime. Sending an
|
||||
/// omitted field as `null` would defeat that, so each is skipped when absent.
|
||||
pub async fn update_user_template(
|
||||
id: &str,
|
||||
name: Option<&str>,
|
||||
sites: Option<&[String]>,
|
||||
) -> Result<CookieBotUserTemplate, CookieBotError> {
|
||||
let mut body = serde_json::Map::new();
|
||||
if let Some(name) = name {
|
||||
body.insert(
|
||||
"name".to_string(),
|
||||
serde_json::Value::String(name.to_string()),
|
||||
);
|
||||
}
|
||||
if let Some(sites) = sites {
|
||||
body.insert("sites".to_string(), serde_json::json!(sites));
|
||||
}
|
||||
|
||||
let envelope: UserTemplateEnvelope = request(
|
||||
reqwest::Method::PATCH,
|
||||
format!("{}/user-templates/{}", base(), urlencoding::encode(id)),
|
||||
Vec::new(),
|
||||
Some(serde_json::Value::Object(body)),
|
||||
TEMPLATE_CODES,
|
||||
)
|
||||
.await?;
|
||||
Ok(envelope.template)
|
||||
}
|
||||
|
||||
/// Delete one. Enrolments that used it keep the sites they copied, so this is
|
||||
/// never a way to stop a profile being warmed tonight.
|
||||
///
|
||||
/// Safe to repeat: deleting a list that is already gone answers `false` rather
|
||||
/// than 404, which is what makes a retry after a dropped response harmless.
|
||||
pub async fn delete_user_template(id: &str) -> Result<bool, CookieBotError> {
|
||||
let deleted: UserTemplateDeleted = request(
|
||||
reqwest::Method::DELETE,
|
||||
format!("{}/user-templates/{}", base(), urlencoding::encode(id)),
|
||||
Vec::new(),
|
||||
None,
|
||||
TEMPLATE_CODES,
|
||||
)
|
||||
.await?;
|
||||
Ok(deleted.deleted)
|
||||
}
|
||||
|
||||
// --- Tauri commands ---------------------------------------------------------
|
||||
//
|
||||
// The user-template commands live here rather than in `lib.rs` beside the
|
||||
// schedule ones because they carry no local precondition: nothing about a saved
|
||||
// site list depends on a profile this machine holds, so there is no profile to
|
||||
// look up and no `bot_precondition` to apply. They must still be registered in
|
||||
// `lib.rs`'s `invoke_handler` to be reachable.
|
||||
|
||||
/// Log a refusal and hand the frontend the envelope it translates.
|
||||
///
|
||||
/// The raw HTTP text never reaches the user: an untranslated backend sentence
|
||||
/// in a Japanese UI is the failure the `{"code":…}` convention exists to stop.
|
||||
fn command_error(context: &str, err: CookieBotError) -> String {
|
||||
log::warn!(
|
||||
"Cookie bot {context} failed: {} (HTTP {})",
|
||||
err.code(),
|
||||
err.status()
|
||||
);
|
||||
err.to_error_json()
|
||||
}
|
||||
|
||||
/// Every site list this user has saved.
|
||||
#[tauri::command]
|
||||
pub async fn get_cookie_bot_user_templates() -> Result<Vec<CookieBotUserTemplate>, String> {
|
||||
list_user_templates()
|
||||
.await
|
||||
.map_err(|e| command_error("template list", e))
|
||||
}
|
||||
|
||||
/// Save the current site list under a name.
|
||||
#[tauri::command]
|
||||
pub async fn create_cookie_bot_user_template(
|
||||
name: String,
|
||||
sites: Vec<String>,
|
||||
) -> Result<CookieBotUserTemplate, String> {
|
||||
create_user_template(&name, &sites)
|
||||
.await
|
||||
.map_err(|e| command_error("template create", e))
|
||||
}
|
||||
|
||||
/// Rename a saved list, replace its sites, or both. Omitted fields are left
|
||||
/// exactly as they are.
|
||||
#[tauri::command]
|
||||
pub async fn update_cookie_bot_user_template(
|
||||
id: String,
|
||||
name: Option<String>,
|
||||
sites: Option<Vec<String>>,
|
||||
) -> Result<CookieBotUserTemplate, String> {
|
||||
update_user_template(&id, name.as_deref(), sites.as_deref())
|
||||
.await
|
||||
.map_err(|e| command_error("template update", e))
|
||||
}
|
||||
|
||||
/// Delete a saved list. `false` means there was nothing left to delete.
|
||||
#[tauri::command]
|
||||
pub async fn delete_cookie_bot_user_template(id: String) -> Result<bool, String> {
|
||||
delete_user_template(&id)
|
||||
.await
|
||||
.map_err(|e| command_error("template delete", e))
|
||||
}
|
||||
|
||||
/// Per-member and per-profile spend for a calendar month (`YYYY-MM`).
|
||||
pub async fn team_usage(period: Option<&str>) -> Result<CookieBotUsage, CookieBotError> {
|
||||
let query = period
|
||||
@@ -976,6 +1365,7 @@ async fn request<T: DeserializeOwned>(
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::profile::types::SyncMode;
|
||||
use crate::remote_exit::ExitReachability;
|
||||
|
||||
fn eligible_profile() -> BrowserProfile {
|
||||
BrowserProfile {
|
||||
@@ -1045,7 +1435,8 @@ mod tests {
|
||||
// that emptiness over the user's real profile.
|
||||
let mut profile = eligible_profile();
|
||||
profile.sync_mode = SyncMode::Disabled;
|
||||
let err = bot_precondition(&profile).expect_err("a local-only profile must be refused");
|
||||
let err = bot_precondition(&profile, &ExitReachability::Remote)
|
||||
.expect_err("a local-only profile must be refused");
|
||||
assert_eq!(code_of(&err), "COOKIE_BOT_REQUIRES_CLOUD_SYNC");
|
||||
}
|
||||
|
||||
@@ -1055,7 +1446,8 @@ mod tests {
|
||||
// one code cannot carry two different instructions.
|
||||
let mut profile = eligible_profile();
|
||||
profile.sync_mode = SyncMode::Encrypted;
|
||||
let err = bot_precondition(&profile).expect_err("encrypted sync must be refused");
|
||||
let err = bot_precondition(&profile, &ExitReachability::Remote)
|
||||
.expect_err("encrypted sync must be refused");
|
||||
assert_eq!(code_of(&err), "COOKIE_BOT_ENCRYPTED_SYNC_UNSUPPORTED");
|
||||
}
|
||||
|
||||
@@ -1063,7 +1455,8 @@ mod tests {
|
||||
fn linux_is_refused_at_enrolment_rather_than_at_two_in_the_morning() {
|
||||
let mut profile = eligible_profile();
|
||||
profile.host_os = Some("linux".to_string());
|
||||
let err = bot_precondition(&profile).expect_err("linux has no host to lease");
|
||||
let err = bot_precondition(&profile, &ExitReachability::Remote)
|
||||
.expect_err("linux 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!(
|
||||
@@ -1076,7 +1469,8 @@ mod tests {
|
||||
fn a_profile_with_no_recorded_os_cannot_be_scheduled_onto_a_host() {
|
||||
let mut profile = eligible_profile();
|
||||
profile.host_os = None;
|
||||
let err = bot_precondition(&profile).expect_err("no OS means no matching host");
|
||||
let err = bot_precondition(&profile, &ExitReachability::Remote)
|
||||
.expect_err("no OS means no matching host");
|
||||
assert_eq!(code_of(&err), "COOKIE_BOT_UNKNOWN_PLATFORM");
|
||||
}
|
||||
|
||||
@@ -1087,7 +1481,8 @@ mod tests {
|
||||
let mut profile = eligible_profile();
|
||||
profile.proxy_id = None;
|
||||
profile.vpn_id = None;
|
||||
let err = bot_precondition(&profile).expect_err("datacenter egress must be refused");
|
||||
let err = bot_precondition(&profile, &ExitReachability::None)
|
||||
.expect_err("datacenter egress must be refused");
|
||||
assert_eq!(code_of(&err), "COOKIE_BOT_REQUIRES_EXIT_NODE");
|
||||
}
|
||||
|
||||
@@ -1096,21 +1491,60 @@ mod tests {
|
||||
let mut profile = eligible_profile();
|
||||
profile.proxy_id = None;
|
||||
profile.vpn_id = Some("vpn-1".to_string());
|
||||
assert!(bot_precondition(&profile).is_ok());
|
||||
assert!(bot_precondition(&profile, &ExitReachability::Remote).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_windows_profile_with_sync_and_a_proxy_qualifies() {
|
||||
let mut profile = eligible_profile();
|
||||
profile.host_os = Some("windows".to_string());
|
||||
assert!(bot_precondition(&profile).is_ok());
|
||||
assert!(bot_precondition(&profile, &ExitReachability::Remote).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_exit_only_this_machine_can_reach_is_refused() {
|
||||
// The gap `has_proxy` alone could never see, and — before the verdict became
|
||||
// an argument — a case no unit test could construct, because resolving it
|
||||
// reached into the global proxy store. The profile is otherwise perfect.
|
||||
let profile = eligible_profile();
|
||||
|
||||
let err = bot_precondition(
|
||||
&profile,
|
||||
&ExitReachability::LocalOnly {
|
||||
host: "127.0.0.1".to_string(),
|
||||
source: "proxy",
|
||||
},
|
||||
)
|
||||
.expect_err("a loopback exit cannot be dialled from a leased host");
|
||||
|
||||
// Its own code: "attach a proxy" is unactionable advice for someone whose
|
||||
// proxy is plainly attached.
|
||||
assert_eq!(code_of(&err), "COOKIE_BOT_REQUIRES_REMOTE_EXIT_NODE");
|
||||
}
|
||||
|
||||
#[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.
|
||||
let err = bot_precondition(
|
||||
&eligible_profile(),
|
||||
&ExitReachability::Unknown {
|
||||
reason: "VPN config could not be parsed".to_string(),
|
||||
source: "VPN",
|
||||
},
|
||||
)
|
||||
.expect_err("an unreadable exit must not be assumed reachable");
|
||||
|
||||
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.
|
||||
const SERVER_SCHEDULE_VIEW: &str = r#"{
|
||||
"profile_id":"p1","profile_name":"Yu","platform":"macos","enabled":true,
|
||||
"run_at_minute":120,"days_mask":127,"timezone":"Europe/Berlin",
|
||||
"run_at_minute":120,"days_mask":127,
|
||||
"slots":[{"days_mask":127,"run_at_minute":120},{"days_mask":31,"run_at_minute":690}],
|
||||
"timezone":"Europe/Berlin","template_id":null,
|
||||
"preset":"balanced","max_minutes":45,"sites":["https://example.com"],
|
||||
"jitter_seconds":900,"sync_enabled":true,"encrypted_sync":false,
|
||||
"has_proxy":true,"touch_fingerprint":false,"sticky_exit":false,
|
||||
@@ -1142,6 +1576,132 @@ mod tests {
|
||||
assert!(schedule.blocked_by.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_schedule_carries_its_whole_calendar_not_just_the_first_time() {
|
||||
// The mirrored pair is `slots[0]`, so a client that read only the pair
|
||||
// would show "every night at 02:00" for an enrolment that also runs at
|
||||
// 11:30 on weeknights — fewer runs than the user booked, silently.
|
||||
let schedule: CookieBotSchedule =
|
||||
serde_json::from_str(SERVER_SCHEDULE_VIEW).expect("a multi-slot schedule must deserialize");
|
||||
|
||||
assert_eq!(schedule.slots.len(), 2);
|
||||
assert_eq!(schedule.slots[0].run_at_minute, schedule.run_at_minute);
|
||||
assert_eq!(schedule.slots[0].days_mask, schedule.days_mask);
|
||||
assert_eq!(schedule.slots[1].run_at_minute, 690);
|
||||
assert_eq!(schedule.slots[1].days_mask, 31);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_server_that_predates_multi_slot_still_decodes_with_no_slots() {
|
||||
// `slots` absent is a deployment that has not rolled forward, not a broken
|
||||
// enrolment. Requiring it would blank the whole Cookie Bot surface against
|
||||
// an older backend rather than show the one time it does know about.
|
||||
let schedule: CookieBotSchedule = serde_json::from_str(
|
||||
r#"{"profile_id":"p1","profile_name":"Yu","platform":"windows","enabled":true,
|
||||
"run_at_minute":120,"days_mask":31,"timezone":"UTC","preset":"light",
|
||||
"max_minutes":10}"#,
|
||||
)
|
||||
.expect("a pre-multi-slot schedule must deserialize");
|
||||
|
||||
assert!(schedule.slots.is_empty());
|
||||
assert!(schedule.template_id.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_templated_enrolment_reports_its_template_and_no_sites() {
|
||||
// A built-in template's URLs are server-owned. An empty `sites` here is the
|
||||
// contract working, not a schedule with nothing to browse — anything that
|
||||
// reads it as "no sites" would show a healthy enrolment as broken.
|
||||
let schedule: CookieBotSchedule = serde_json::from_str(
|
||||
&SERVER_SCHEDULE_VIEW
|
||||
.replace(
|
||||
"\"template_id\":null",
|
||||
"\"template_id\":\"low-intent-purchaser\"",
|
||||
)
|
||||
.replace("\"sites\":[\"https://example.com\"]", "\"sites\":[]"),
|
||||
)
|
||||
.expect("a templated schedule must deserialize");
|
||||
|
||||
assert_eq!(
|
||||
schedule.template_id.as_deref(),
|
||||
Some("low-intent-purchaser")
|
||||
);
|
||||
assert!(schedule.sites.is_empty());
|
||||
assert!(schedule.blocked_by.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_calendar_is_sent_as_slots_and_omitted_entirely_when_there_is_none() {
|
||||
// The server reads an ABSENT `slots` as "one slot, from the legacy pair"
|
||||
// and REFUSES a null or empty one. Serialising `None` as null would 400
|
||||
// every write from a form with a single time on it.
|
||||
let one_slot = CookieBotScheduleInput {
|
||||
profile_name: "Yu".to_string(),
|
||||
platform: "macos".to_string(),
|
||||
enabled: true,
|
||||
run_at_minute: 120,
|
||||
days_mask: 127,
|
||||
timezone: "Europe/Berlin".to_string(),
|
||||
preset: "balanced".to_string(),
|
||||
max_minutes: 45,
|
||||
sites: vec!["https://example.com".to_string()],
|
||||
..Default::default()
|
||||
};
|
||||
let encoded = serde_json::to_value(&one_slot).expect("input must serialize");
|
||||
assert!(
|
||||
encoded.get("slots").is_none(),
|
||||
"an absent calendar must be absent on the wire, not null"
|
||||
);
|
||||
assert!(encoded.get("template_id").is_none());
|
||||
|
||||
let many = CookieBotScheduleInput {
|
||||
slots: Some(vec![
|
||||
CookieBotSlot {
|
||||
days_mask: 127,
|
||||
run_at_minute: 120,
|
||||
},
|
||||
CookieBotSlot {
|
||||
days_mask: 31,
|
||||
run_at_minute: 690,
|
||||
},
|
||||
]),
|
||||
..one_slot
|
||||
};
|
||||
let encoded = serde_json::to_value(&many).expect("input must serialize");
|
||||
let slots = encoded["slots"].as_array().expect("slots must be a list");
|
||||
assert_eq!(slots.len(), 2);
|
||||
// Mirrored, because a server that predates multi-slot ignores `slots` and
|
||||
// stores this pair. Dropping it would leave that server with no time at all.
|
||||
assert_eq!(encoded["run_at_minute"], 120);
|
||||
assert_eq!(encoded["days_mask"], 127);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_templated_write_names_the_template_and_sends_no_sites() {
|
||||
// The server refuses a body carrying both: a curated persona merged with
|
||||
// the user's own list is neither.
|
||||
let input = CookieBotScheduleInput {
|
||||
profile_name: "Yu".to_string(),
|
||||
platform: "macos".to_string(),
|
||||
enabled: true,
|
||||
run_at_minute: 120,
|
||||
days_mask: 127,
|
||||
timezone: "UTC".to_string(),
|
||||
preset: "balanced".to_string(),
|
||||
max_minutes: 45,
|
||||
sites: Vec::new(),
|
||||
template_id: Some("low-intent-purchaser".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
let encoded = serde_json::to_value(&input).expect("input must serialize");
|
||||
assert_eq!(encoded["template_id"], "low-intent-purchaser");
|
||||
assert_eq!(
|
||||
encoded["sites"].as_array().map(Vec::len),
|
||||
Some(0),
|
||||
"sites must still be sent, and must be empty, beside a template"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_broken_enrolment_carries_the_reason_it_cannot_run() {
|
||||
// The whole point of `blocked_by`: a profile whose proxy was detached in
|
||||
@@ -1419,5 +1979,76 @@ mod tests {
|
||||
assert_eq!(presets.presets[0].id, "balanced");
|
||||
assert_eq!(presets.presets[0].typical_minutes, Some(35));
|
||||
assert_eq!(presets.default_preset.as_deref(), Some("balanced"));
|
||||
// An older deployment sends neither of these, and the dialog has to render
|
||||
// against it: no templates simply means the picker offers the user's own
|
||||
// list, and no limits means the mirrored bounds apply.
|
||||
assert!(presets.templates.is_empty());
|
||||
assert!(presets.limits.is_none());
|
||||
}
|
||||
|
||||
#[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.
|
||||
let presets: CookieBotPresetList = serde_json::from_str(
|
||||
r#"{"presets":[],"default_preset":"balanced",
|
||||
"templates":[{"id":"low-intent-purchaser","site_count":32,
|
||||
"name":"Low-Intent Purchaser","description":"Price-sensitive browsing."}],
|
||||
"limits":{"min_minutes":5,"max_minutes":120,"min_sites":1,"max_sites":40,
|
||||
"max_site_length":2048,"max_jitter_seconds":3600,"max_slots":14,
|
||||
"max_template_name_length":80}}"#,
|
||||
)
|
||||
.expect("the preset list must carry templates and limits");
|
||||
|
||||
assert_eq!(presets.templates[0].id, "low-intent-purchaser");
|
||||
assert_eq!(presets.templates[0].site_count, 32);
|
||||
let limits = presets.limits.expect("limits must decode");
|
||||
assert_eq!(limits.max_slots, Some(14));
|
||||
assert_eq!(limits.max_template_name_length, Some(80));
|
||||
assert_eq!(limits.max_sites, Some(40));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_saved_list_arrives_with_the_prefix_a_schedule_write_needs() {
|
||||
// The id is what `template_id` takes verbatim. Handing the client a bare
|
||||
// uuid and expecting it to prepend `user:` is how a saved list gets looked
|
||||
// up against the built-in catalogue instead — which answers "no sites" and
|
||||
// silently unschedules the profile.
|
||||
let envelope: UserTemplateListEnvelope = serde_json::from_str(
|
||||
r#"{"templates":[{"id":"user:1c9a…","name":"My shops",
|
||||
"sites":["https://example.com"],"updated_at":"2026-08-05T10:00:00.000Z"}]}"#,
|
||||
)
|
||||
.expect("the user template list must deserialize");
|
||||
|
||||
let template = &envelope.templates[0];
|
||||
assert!(template.id.starts_with("user:"));
|
||||
assert_eq!(template.name, "My shops");
|
||||
assert_eq!(template.sites.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_a_saved_list_that_is_already_gone_is_not_a_failure() {
|
||||
// The route never 404s, so a delete retried after a dropped response has to
|
||||
// read as "nothing left to do" rather than as an error the user must act on.
|
||||
let deleted: UserTemplateDeleted =
|
||||
serde_json::from_str(r#"{"deleted":false,"id":"user:gone"}"#)
|
||||
.expect("a no-op delete must deserialize");
|
||||
assert!(!deleted.deleted);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_template_404_is_a_missing_list_and_not_an_unenrolled_profile() {
|
||||
// Sharing SCHEDULE_CODES here would tell someone renaming a site list that
|
||||
// their profile is not enrolled, and a name collision that a teammate
|
||||
// already warms the profile.
|
||||
assert_eq!(
|
||||
cloud_errors::classify_message("(404) Not Found", TEMPLATE_CODES).code,
|
||||
"COOKIE_BOT_TEMPLATE_NOT_FOUND"
|
||||
);
|
||||
assert_eq!(
|
||||
cloud_errors::classify_message("(409) Conflict", TEMPLATE_CODES).code,
|
||||
"COOKIE_BOT_TEMPLATE_NAME_TAKEN"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+153
-34
@@ -59,25 +59,48 @@ impl BlocklistLevel {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn url(&self) -> Option<&'static str> {
|
||||
/// Where this tier's `domains/*.txt` list is fetched from.
|
||||
///
|
||||
/// `raw.githubusercontent.com` only, deliberately. This used to be a jsDelivr
|
||||
/// URL against `hagezi/dns-blocklists`, and it broke every blocklisted launch:
|
||||
/// that repo grew past jsDelivr's 150 MB package-resolution limit, so
|
||||
/// `@latest` began answering `403 Package size exceeded the configured limit
|
||||
/// of 150 MB` for every tier. Nothing was wrong locally and nothing a user
|
||||
/// could do would fix it — a third party's repo got too big and a CDN's
|
||||
/// package resolver gave up.
|
||||
///
|
||||
/// raw.githubusercontent.com serves the file straight from the ref and
|
||||
/// resolves no package at all, so it cannot fail that way. The
|
||||
/// `domains/*.txt` format now lives in `hagezi/dns-blocklists-legacy`.
|
||||
///
|
||||
/// Returned as a slice so the fetch path can try several sources if one is
|
||||
/// ever added; today there is exactly one on purpose.
|
||||
pub fn urls(&self) -> &'static [&'static str] {
|
||||
match self {
|
||||
Self::None | Self::Custom => None,
|
||||
Self::None | Self::Custom => &[],
|
||||
Self::Light => {
|
||||
Some("https://cdn.jsdelivr.net/gh/hagezi/dns-blocklists@latest/domains/light.txt")
|
||||
&["https://raw.githubusercontent.com/hagezi/dns-blocklists-legacy/main/domains/light.txt"]
|
||||
}
|
||||
Self::Normal => {
|
||||
Some("https://cdn.jsdelivr.net/gh/hagezi/dns-blocklists@latest/domains/multi.txt")
|
||||
&["https://raw.githubusercontent.com/hagezi/dns-blocklists-legacy/main/domains/multi.txt"]
|
||||
}
|
||||
Self::Pro => Some("https://cdn.jsdelivr.net/gh/hagezi/dns-blocklists@latest/domains/pro.txt"),
|
||||
Self::ProPlus => {
|
||||
Some("https://cdn.jsdelivr.net/gh/hagezi/dns-blocklists@latest/domains/pro.plus.txt")
|
||||
}
|
||||
Self::Ultimate => {
|
||||
Some("https://cdn.jsdelivr.net/gh/hagezi/dns-blocklists@latest/domains/ultimate.txt")
|
||||
Self::Pro => {
|
||||
&["https://raw.githubusercontent.com/hagezi/dns-blocklists-legacy/main/domains/pro.txt"]
|
||||
}
|
||||
Self::ProPlus => &[
|
||||
"https://raw.githubusercontent.com/hagezi/dns-blocklists-legacy/main/domains/pro.plus.txt",
|
||||
],
|
||||
Self::Ultimate => &[
|
||||
"https://raw.githubusercontent.com/hagezi/dns-blocklists-legacy/main/domains/ultimate.txt",
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
/// The preferred source, for callers that only need to name one.
|
||||
pub fn url(&self) -> Option<&'static str> {
|
||||
self.urls().first().copied()
|
||||
}
|
||||
|
||||
pub fn filename(&self) -> Option<&'static str> {
|
||||
match self {
|
||||
Self::None => None,
|
||||
@@ -295,49 +318,85 @@ impl BlocklistManager {
|
||||
}
|
||||
|
||||
pub async fn fetch_blocklist(level: BlocklistLevel) -> Result<PathBuf, String> {
|
||||
let production_url = level
|
||||
.url()
|
||||
.ok_or_else(|| format!("No URL for level {:?}", level))?;
|
||||
let production_urls: Vec<String> = level.urls().iter().map(|u| (*u).to_string()).collect();
|
||||
if production_urls.is_empty() {
|
||||
return Err(format!("No URL for level {:?}", level));
|
||||
}
|
||||
#[cfg(feature = "e2e")]
|
||||
let url = std::env::var("DONUT_E2E_DNS_BLOCKLIST_BASE_URL")
|
||||
let urls = std::env::var("DONUT_E2E_DNS_BLOCKLIST_BASE_URL")
|
||||
.ok()
|
||||
.filter(|base| !base.is_empty())
|
||||
.map(|base| {
|
||||
format!(
|
||||
vec![format!(
|
||||
"{}/{}",
|
||||
base.trim_end_matches('/'),
|
||||
level.filename().unwrap_or("blocklist.txt")
|
||||
)
|
||||
)]
|
||||
})
|
||||
.unwrap_or_else(|| production_url.to_string());
|
||||
.unwrap_or(production_urls);
|
||||
#[cfg(not(feature = "e2e"))]
|
||||
let url = production_url.to_string();
|
||||
let urls = production_urls;
|
||||
let path =
|
||||
Self::cached_file_path(level).ok_or_else(|| format!("No filename for level {:?}", level))?;
|
||||
|
||||
let cache_dir = Self::cache_dir();
|
||||
std::fs::create_dir_all(&cache_dir).map_err(|e| format!("Failed to create cache dir: {e}"))?;
|
||||
|
||||
log::info!(
|
||||
"[dns-blocklist] Fetching {} from {}",
|
||||
level.display_name(),
|
||||
url
|
||||
);
|
||||
// Try each source in turn. A tier is only a failure once EVERY source has
|
||||
// refused it: the outage this replaced was one CDN answering 403 for a
|
||||
// reason that had nothing to do with the user, and falling back would have
|
||||
// made it invisible.
|
||||
let mut body: Option<String> = None;
|
||||
let mut failures: Vec<String> = Vec::new();
|
||||
|
||||
let response = HTTP_CLIENT
|
||||
.get(&url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to fetch blocklist: {e}"))?;
|
||||
for url in &urls {
|
||||
log::info!(
|
||||
"[dns-blocklist] Fetching {} from {}",
|
||||
level.display_name(),
|
||||
url
|
||||
);
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(format!("HTTP {} when fetching {}", response.status(), url));
|
||||
let response = match HTTP_CLIENT.get(url).send().await {
|
||||
Ok(response) => response,
|
||||
Err(e) => {
|
||||
failures.push(format!("{url}: {e}"));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
if !response.status().is_success() {
|
||||
failures.push(format!("{url}: HTTP {}", response.status()));
|
||||
continue;
|
||||
}
|
||||
|
||||
match response.text().await {
|
||||
Ok(text) => {
|
||||
if failures.is_empty() {
|
||||
log::info!("[dns-blocklist] {} fetched", level.display_name());
|
||||
} else {
|
||||
// Worth saying out loud: the primary source is down and somebody
|
||||
// should know before the backup goes too.
|
||||
log::warn!(
|
||||
"[dns-blocklist] {} came from a fallback source after {} failure(s): {}",
|
||||
level.display_name(),
|
||||
failures.len(),
|
||||
failures.join("; ")
|
||||
);
|
||||
}
|
||||
body = Some(text);
|
||||
break;
|
||||
}
|
||||
Err(e) => failures.push(format!("{url}: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
let body = response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to read response body: {e}"))?;
|
||||
let Some(body) = body else {
|
||||
return Err(format!(
|
||||
"Failed to fetch blocklist {} from any source ({})",
|
||||
level.display_name(),
|
||||
failures.join("; ")
|
||||
));
|
||||
};
|
||||
|
||||
// Write atomically: write to temp file, then rename
|
||||
let tmp_path = path.with_extension("tmp");
|
||||
@@ -796,6 +855,66 @@ mod tests {
|
||||
assert!(BlocklistLevel::None.filename().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_tier_is_served_only_from_raw_githubusercontent() {
|
||||
// jsDelivr is deliberately not a source. It resolves a whole package to
|
||||
// serve one file, so when `hagezi/dns-blocklists` grew past its 150 MB
|
||||
// limit every tier began answering 403 — an outage nothing local could fix.
|
||||
// raw.githubusercontent.com serves the file straight from the ref and
|
||||
// resolves no package, so it cannot fail that way.
|
||||
for &level in BlocklistLevel::all_downloadable() {
|
||||
let urls = level.urls();
|
||||
assert_eq!(
|
||||
urls.len(),
|
||||
1,
|
||||
"{} should have exactly one source: {urls:?}",
|
||||
level.as_str()
|
||||
);
|
||||
for url in urls {
|
||||
assert!(
|
||||
url.starts_with("https://raw.githubusercontent.com/"),
|
||||
"{} must be served from raw.githubusercontent.com: {url}",
|
||||
level.as_str()
|
||||
);
|
||||
assert!(
|
||||
!url.contains("jsdelivr"),
|
||||
"{} must not reintroduce jsDelivr: {url}",
|
||||
level.as_str()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_tier_points_at_the_oversized_upstream_repo() {
|
||||
// The `domains/*.txt` format moved to `-legacy`, which is small enough for
|
||||
// jsDelivr to resolve. Pointing any tier back at the original repo
|
||||
// reintroduces the 403.
|
||||
for &level in BlocklistLevel::all_downloadable() {
|
||||
for url in level.urls() {
|
||||
assert!(
|
||||
!url.contains("/hagezi/dns-blocklists@") && !url.contains("/hagezi/dns-blocklists/"),
|
||||
"{} still points at the oversized repo: {url}",
|
||||
level.as_str()
|
||||
);
|
||||
assert!(
|
||||
url.contains("dns-blocklists-legacy"),
|
||||
"{} should read the legacy list repo: {url}",
|
||||
level.as_str()
|
||||
);
|
||||
assert!(
|
||||
url.ends_with(
|
||||
level
|
||||
.filename()
|
||||
.expect("downloadable tiers have a filename")
|
||||
),
|
||||
"{} source must serve its own tier file: {url}",
|
||||
level.as_str()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cache_status_returns_all_levels() {
|
||||
let statuses = BlocklistManager::get_cache_status();
|
||||
|
||||
@@ -26,6 +26,29 @@ pub struct DownloadedBrowsersRegistry {
|
||||
geoip_downloader: &'static GeoIPDownloader,
|
||||
}
|
||||
|
||||
/// Filename suffixes that identify a *downloaded artifact* — the container we
|
||||
/// fetched from the network — rather than a file belonging to the extracted
|
||||
/// install. Cleanup preserves these so a manually placed archive survives.
|
||||
///
|
||||
/// `.exe` and `.AppImage` are deliberately absent even though both can be
|
||||
/// downloaded. On Windows the extracted Wayfern payload is flat at the version
|
||||
/// root (`extraction::ensure_correct_directory_structure` returns early rather
|
||||
/// than nesting it), so preserving `.exe` kept `chrome.exe` while deleting every
|
||||
/// sibling `.dll`, the `.manifest`, `.pak` and `locales/` — a gutted install
|
||||
/// that then failed to launch with os error 14001. On Linux the `.AppImage`
|
||||
/// *is* the extracted payload. Cleanup must never leave behind something that
|
||||
/// still reads as an installed browser; the archive is deleted right after a
|
||||
/// successful download anyway, so nothing of value is lost.
|
||||
const DOWNLOAD_ARTIFACT_SUFFIXES: [&str; 7] =
|
||||
["zip", "dmg", "tar.xz", "tar.gz", "tar.bz2", "pkg", "msi"];
|
||||
|
||||
fn is_download_artifact(file_name: &str) -> bool {
|
||||
let lowered = file_name.to_lowercase();
|
||||
DOWNLOAD_ARTIFACT_SUFFIXES
|
||||
.iter()
|
||||
.any(|suffix| lowered.ends_with(suffix))
|
||||
}
|
||||
|
||||
impl DownloadedBrowsersRegistry {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
@@ -174,15 +197,19 @@ impl DownloadedBrowsersRegistry {
|
||||
browser: &str,
|
||||
version: &str,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
// Never delete files out from under a live download or extraction. Both the
|
||||
// detached task that runs the moment a download completes and the periodic
|
||||
// maintenance task land here, and a freshly downloaded version is referenced
|
||||
// by no persisted profile while profile creation is still in flight.
|
||||
if crate::downloader::is_downloading(browser, version) {
|
||||
log::info!("Skipping cleanup of {browser} {version}: a download is in progress");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if let Some(info) = self.remove_browser(browser, version) {
|
||||
// Clean up extracted binaries but preserve downloaded archives
|
||||
if info.file_path.exists() {
|
||||
if info.file_path.is_dir() {
|
||||
// Allowed archive extensions to preserve
|
||||
let archive_exts = [
|
||||
"zip", "dmg", "tar.xz", "tar.gz", "tar.bz2", "AppImage", "exe", "pkg", "msi",
|
||||
];
|
||||
|
||||
for entry in fs::read_dir(&info.file_path)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
@@ -192,16 +219,11 @@ impl DownloadedBrowsersRegistry {
|
||||
continue;
|
||||
}
|
||||
|
||||
// For files, preserve if they look like downloaded archives/installers
|
||||
// For files, preserve only genuine downloaded archives/installers
|
||||
let keep = path
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.map(|name| {
|
||||
// Match suffixes (handles multi-part extensions like .tar.xz)
|
||||
archive_exts
|
||||
.iter()
|
||||
.any(|ext| name.to_lowercase().ends_with(&ext.to_lowercase()))
|
||||
})
|
||||
.map(is_download_artifact)
|
||||
.unwrap_or(false);
|
||||
|
||||
if !keep {
|
||||
@@ -215,13 +237,7 @@ impl DownloadedBrowsersRegistry {
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("");
|
||||
let archive_exts = [
|
||||
"zip", "dmg", "tar.xz", "tar.gz", "tar.bz2", "AppImage", "exe", "pkg", "msi",
|
||||
];
|
||||
let is_archive = archive_exts
|
||||
.iter()
|
||||
.any(|ext| file_name.to_lowercase().ends_with(&ext.to_lowercase()));
|
||||
if !is_archive {
|
||||
if !is_download_artifact(file_name) {
|
||||
fs::remove_file(&info.file_path)?;
|
||||
}
|
||||
}
|
||||
@@ -1230,6 +1246,130 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// The Windows payload is extracted flat at the version root, so preserving
|
||||
/// every `*.exe` used to leave `chrome.exe` behind while deleting the `.dll`
|
||||
/// files and the `.manifest` next to it. That gutted directory still passed
|
||||
/// the "is it downloaded?" check, was re-registered as healthy, and launching
|
||||
/// it failed in the Windows loader with os error 14001.
|
||||
#[test]
|
||||
fn test_cleanup_removes_the_browser_executable_not_just_its_libraries() {
|
||||
use tempfile::TempDir;
|
||||
let temp = TempDir::new().unwrap();
|
||||
let version_dir = temp.path().join("wayfern").join("140.0");
|
||||
std::fs::create_dir_all(&version_dir).unwrap();
|
||||
|
||||
for name in [
|
||||
"chrome.exe",
|
||||
"wayfern.exe",
|
||||
"notification_helper.exe",
|
||||
"chrome.dll",
|
||||
"chrome_elf.dll",
|
||||
"chrome.exe.manifest",
|
||||
"resources.pak",
|
||||
] {
|
||||
std::fs::File::create(version_dir.join(name)).unwrap();
|
||||
}
|
||||
std::fs::create_dir_all(version_dir.join("locales")).unwrap();
|
||||
|
||||
let registry = DownloadedBrowsersRegistry::new();
|
||||
registry.add_browser(DownloadedBrowserInfo {
|
||||
browser: "wayfern".to_string(),
|
||||
version: "140.0".to_string(),
|
||||
file_path: version_dir.clone(),
|
||||
});
|
||||
|
||||
registry
|
||||
.cleanup_failed_download("wayfern", "140.0")
|
||||
.expect("cleanup should succeed");
|
||||
|
||||
let leftovers: Vec<String> = std::fs::read_dir(&version_dir)
|
||||
.unwrap()
|
||||
.flatten()
|
||||
.map(|e| e.file_name().to_string_lossy().into_owned())
|
||||
.collect();
|
||||
assert!(
|
||||
leftovers.is_empty(),
|
||||
"cleanup must not leave a half-deleted install behind, found: {leftovers:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The preserve rule still exists for its actual purpose: a downloaded
|
||||
/// archive (including one placed there by hand) survives the cleanup.
|
||||
#[test]
|
||||
fn test_cleanup_preserves_a_downloaded_archive() {
|
||||
use tempfile::TempDir;
|
||||
let temp = TempDir::new().unwrap();
|
||||
let version_dir = temp.path().join("wayfern").join("141.0");
|
||||
std::fs::create_dir_all(&version_dir).unwrap();
|
||||
|
||||
std::fs::File::create(version_dir.join("wayfern-win64.zip")).unwrap();
|
||||
std::fs::File::create(version_dir.join("wayfern-mac.tar.xz")).unwrap();
|
||||
std::fs::File::create(version_dir.join("chrome.exe")).unwrap();
|
||||
std::fs::File::create(version_dir.join("chrome.dll")).unwrap();
|
||||
|
||||
let registry = DownloadedBrowsersRegistry::new();
|
||||
registry.add_browser(DownloadedBrowserInfo {
|
||||
browser: "wayfern".to_string(),
|
||||
version: "141.0".to_string(),
|
||||
file_path: version_dir.clone(),
|
||||
});
|
||||
|
||||
registry
|
||||
.cleanup_failed_download("wayfern", "141.0")
|
||||
.expect("cleanup should succeed");
|
||||
|
||||
assert!(
|
||||
version_dir.join("wayfern-win64.zip").exists(),
|
||||
"a downloaded archive must be preserved"
|
||||
);
|
||||
assert!(
|
||||
version_dir.join("wayfern-mac.tar.xz").exists(),
|
||||
"multi-part archive extensions must still be recognised"
|
||||
);
|
||||
assert!(
|
||||
!version_dir.join("chrome.exe").exists(),
|
||||
"the extracted executable must be removed"
|
||||
);
|
||||
assert!(
|
||||
!version_dir.join("chrome.dll").exists(),
|
||||
"the extracted libraries must be removed"
|
||||
);
|
||||
}
|
||||
|
||||
/// Cleanup runs on a detached task the moment a download completes and again
|
||||
/// on a periodic timer, either of which can land while an install is still
|
||||
/// being written. It must stand down instead of deleting live files.
|
||||
#[test]
|
||||
fn test_cleanup_stands_down_while_a_download_is_in_progress() {
|
||||
use tempfile::TempDir;
|
||||
let temp = TempDir::new().unwrap();
|
||||
let version_dir = temp.path().join("wayfern").join("142.0");
|
||||
std::fs::create_dir_all(&version_dir).unwrap();
|
||||
std::fs::File::create(version_dir.join("chrome.exe")).unwrap();
|
||||
std::fs::File::create(version_dir.join("chrome.dll")).unwrap();
|
||||
|
||||
let registry = DownloadedBrowsersRegistry::new();
|
||||
registry.add_browser(DownloadedBrowserInfo {
|
||||
browser: "wayfern".to_string(),
|
||||
version: "142.0".to_string(),
|
||||
file_path: version_dir.clone(),
|
||||
});
|
||||
|
||||
crate::downloader::mark_downloading_for_test("wayfern", "142.0");
|
||||
let result = registry.cleanup_failed_download("wayfern", "142.0");
|
||||
crate::downloader::clear_download_state_for_browser("wayfern");
|
||||
result.expect("cleanup should succeed");
|
||||
|
||||
assert!(
|
||||
version_dir.join("chrome.exe").exists() && version_dir.join("chrome.dll").exists(),
|
||||
"an in-flight download must not be deleted out from under itself"
|
||||
);
|
||||
assert!(
|
||||
registry.is_browser_registered("wayfern", "142.0"),
|
||||
"the registry entry must survive too, the version is still being installed"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_browser_registered_vs_downloaded() {
|
||||
let registry = DownloadedBrowsersRegistry::new();
|
||||
|
||||
@@ -879,6 +879,17 @@ pub fn is_downloading(browser: &str, version: &str) -> bool {
|
||||
downloading.contains(&download_key)
|
||||
}
|
||||
|
||||
/// Test-only: mark a browser-version pair as in flight so guards that consult
|
||||
/// `is_downloading` can be exercised without running a real download. Clear it
|
||||
/// again with `clear_download_state_for_browser`.
|
||||
#[cfg(test)]
|
||||
pub fn mark_downloading_for_test(browser: &str, version: &str) {
|
||||
DOWNLOADING_BROWSERS
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert(format!("{browser}-{version}"));
|
||||
}
|
||||
|
||||
/// Clear all in-progress download bookkeeping for a browser.
|
||||
///
|
||||
/// Used as a last-resort cleanup when a download future is abandoned (e.g. dropped
|
||||
|
||||
@@ -86,6 +86,62 @@ fn find_zip_start(data: &[u8]) -> usize {
|
||||
0
|
||||
}
|
||||
|
||||
/// Read and parse an extension archive's `manifest.json`. Handles the CRX3
|
||||
/// header by seeking to the embedded ZIP. Shared with
|
||||
/// `vpn_extension_detect`, which classifies from the raw manifest rather than
|
||||
/// from the metadata subset persisted on `Extension`.
|
||||
pub(crate) fn read_manifest_from_archive(
|
||||
file_data: &[u8],
|
||||
file_type: &str,
|
||||
) -> Option<serde_json::Value> {
|
||||
let zip_start = if file_type == "crx" {
|
||||
find_zip_start(file_data)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
let cursor = std::io::Cursor::new(file_data.get(zip_start..)?);
|
||||
let mut archive = zip::ZipArchive::new(cursor).ok()?;
|
||||
|
||||
let mut contents = String::new();
|
||||
{
|
||||
let mut file = archive.by_name("manifest.json").ok()?;
|
||||
std::io::Read::read_to_string(&mut file, &mut contents).ok()?;
|
||||
}
|
||||
serde_json::from_str(&contents).ok()
|
||||
}
|
||||
|
||||
/// Resolve a `__MSG_key__` placeholder against the archive's default locale
|
||||
/// messages. Chromium extensions routinely localize `name`/`description`, and
|
||||
/// showing the raw placeholder in a warning dialog reads as a bug.
|
||||
pub(crate) fn resolve_archive_i18n(
|
||||
file_data: &[u8],
|
||||
file_type: &str,
|
||||
manifest: &serde_json::Value,
|
||||
value: &str,
|
||||
) -> Option<String> {
|
||||
let key = crate::vpn_extension_detect::message_placeholder_key(value)?;
|
||||
let default_locale = manifest.get("default_locale")?.as_str()?;
|
||||
|
||||
let zip_start = if file_type == "crx" {
|
||||
find_zip_start(file_data)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let cursor = std::io::Cursor::new(file_data.get(zip_start..)?);
|
||||
let mut archive = zip::ZipArchive::new(cursor).ok()?;
|
||||
|
||||
let mut contents = String::new();
|
||||
{
|
||||
let mut file = archive
|
||||
.by_name(&format!("_locales/{default_locale}/messages.json"))
|
||||
.ok()?;
|
||||
std::io::Read::read_to_string(&mut file, &mut contents).ok()?;
|
||||
}
|
||||
let messages: serde_json::Value = serde_json::from_str(&contents).ok()?;
|
||||
crate::vpn_extension_detect::lookup_message(&messages, &key)
|
||||
}
|
||||
|
||||
#[allow(clippy::type_complexity)]
|
||||
fn extract_manifest_metadata(
|
||||
file_data: &[u8],
|
||||
@@ -97,39 +153,11 @@ fn extract_manifest_metadata(
|
||||
Option<String>,
|
||||
Option<String>,
|
||||
) {
|
||||
let zip_start = if file_type == "crx" {
|
||||
find_zip_start(file_data)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
let cursor = std::io::Cursor::new(&file_data[zip_start..]);
|
||||
let mut archive = match zip::ZipArchive::new(cursor) {
|
||||
Ok(a) => a,
|
||||
Err(_) => return (None, None, None, None, None),
|
||||
};
|
||||
|
||||
let manifest_content = if let Ok(mut file) = archive.by_name("manifest.json") {
|
||||
let mut contents = String::new();
|
||||
if std::io::Read::read_to_string(&mut file, &mut contents).is_ok() {
|
||||
Some(contents)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let manifest_content = match manifest_content {
|
||||
Some(c) => c,
|
||||
let manifest = match read_manifest_from_archive(file_data, file_type) {
|
||||
Some(v) => v,
|
||||
None => return (None, None, None, None, None),
|
||||
};
|
||||
|
||||
let manifest: serde_json::Value = match serde_json::from_str(&manifest_content) {
|
||||
Ok(v) => v,
|
||||
Err(_) => return (None, None, None, None, None),
|
||||
};
|
||||
|
||||
let name = manifest
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
//! Launch-time consistency check: resolve the proxy's exit IP, geolocate it
|
||||
//! with the bundled MaxMind database (the same source the fingerprint generator
|
||||
//! uses), then compare its timezone and country against the profile
|
||||
//! fingerprint's timezone and 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 — so we warn the user after launch and offer to match the
|
||||
//! fingerprint to the exit. Launches never rewrite the fingerprint silently, so
|
||||
//! a real mismatch always surfaces here.
|
||||
//! Measures a proxy's exit node and compares it to a profile's fingerprint.
|
||||
//!
|
||||
//! Resolve the exit IP through the upstream, geolocate it with the bundled
|
||||
//! MaxMind database (the same source the fingerprint generator uses), then
|
||||
//! compare its timezone and country against the fingerprint's timezone and
|
||||
//! 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.
|
||||
//!
|
||||
//! 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
|
||||
//! rewrite the fingerprint silently, so a real mismatch always surfaces.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
@@ -19,20 +23,68 @@ use crate::proxy_manager::PROXY_MANAGER;
|
||||
/// on every launch is wasteful.
|
||||
const EXIT_CACHE_TTL_SECS: u64 = 30 * 60;
|
||||
|
||||
/// Ceiling on a single exit probe. `fetch_public_ip` races six endpoints with
|
||||
/// a 10s timeout each, which is fine for a background check but far longer
|
||||
/// than a user will wait staring at a launch that has not started yet.
|
||||
const PROBE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(8);
|
||||
|
||||
#[derive(Clone)]
|
||||
struct CachedExit {
|
||||
fetched_at: u64,
|
||||
/// The proxy URL this exit was measured through. Editing a stored proxy keeps
|
||||
/// The endpoint this exit was measured through. Editing a stored proxy keeps
|
||||
/// its id, so without this an entry outlives the endpoint it describes: the
|
||||
/// check would compare a re-generated fingerprint against the *old* exit and
|
||||
/// either warn about a correct profile or — worse — call a genuinely
|
||||
/// mismatched one consistent, which is exactly the tell it exists to catch.
|
||||
proxy_url: String,
|
||||
///
|
||||
/// Never a loopback URL: the Xray/VPN workers a launch spins up get a fresh
|
||||
/// random port and credentials each time, so keying on those would miss on
|
||||
/// every relaunch and re-probe forever.
|
||||
identity: String,
|
||||
timezone: Option<String>,
|
||||
country_code: Option<String>,
|
||||
ip: Option<String>,
|
||||
}
|
||||
|
||||
/// Identity of the exit a profile routes through, stable across worker
|
||||
/// restarts.
|
||||
///
|
||||
/// `scope` keys the cache; `identity` detects that the endpoint behind that
|
||||
/// key changed. Cloud-derived proxies inject a per-profile sticky-session id,
|
||||
/// so two profiles sharing one stored proxy correctly get different identities
|
||||
/// and never inherit each other's verdict.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ExitCacheKey {
|
||||
pub scope: String,
|
||||
pub identity: String,
|
||||
}
|
||||
|
||||
/// Resolve the cache identity from the profile's *stored* configuration.
|
||||
///
|
||||
/// Deliberately not derived from the normalized upstream the launcher passes
|
||||
/// to the probe: for VLESS and VPN that upstream is a loopback worker whose
|
||||
/// port and credentials are regenerated per launch.
|
||||
pub fn exit_cache_key(profile: &BrowserProfile) -> Option<ExitCacheKey> {
|
||||
if let Some(proxy_id) = &profile.proxy_id {
|
||||
let settings = PROXY_MANAGER
|
||||
.resolve_proxy_for_profile(proxy_id, &profile.id.to_string())
|
||||
.or_else(|| PROXY_MANAGER.get_proxy_settings_by_id(proxy_id))?;
|
||||
// build_proxy_url returns the VLESS URI verbatim for vless proxies, so one
|
||||
// call covers every transport.
|
||||
return Some(ExitCacheKey {
|
||||
scope: format!("proxy:{proxy_id}"),
|
||||
identity: crate::proxy_manager::ProxyManager::build_proxy_url(&settings),
|
||||
});
|
||||
}
|
||||
if let Some(vpn_id) = &profile.vpn_id {
|
||||
return Some(ExitCacheKey {
|
||||
scope: format!("vpn:{vpn_id}"),
|
||||
identity: vpn_id.clone(),
|
||||
});
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
lazy_static::lazy_static! {
|
||||
static ref EXIT_CACHE: Mutex<HashMap<String, CachedExit>> = Mutex::new(HashMap::new());
|
||||
}
|
||||
@@ -54,7 +106,7 @@ pub struct ConsistencyResult {
|
||||
}
|
||||
|
||||
impl ConsistencyResult {
|
||||
fn skip() -> Self {
|
||||
pub fn skip() -> Self {
|
||||
Self {
|
||||
consistent: true,
|
||||
checked: false,
|
||||
@@ -68,18 +120,15 @@ impl ConsistencyResult {
|
||||
}
|
||||
}
|
||||
|
||||
/// URL for handing this proxy to reqwest. VLESS is reached through the
|
||||
/// authenticated loopback Xray-core worker already serving the profile.
|
||||
fn proxy_url(settings: &crate::browser::ProxySettings, profile_id: Option<&str>) -> Option<String> {
|
||||
/// Whether this upstream can carry a probe request at all.
|
||||
///
|
||||
/// Shadowsocks and anything else reqwest cannot dial directly is skipped
|
||||
/// 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(
|
||||
crate::proxy_manager::ProxyManager::build_proxy_url(settings),
|
||||
crate::proxy_manager::ProxyManager::build_probe_proxy_url(settings),
|
||||
),
|
||||
"vless" => profile_id
|
||||
.and_then(crate::xray_worker_storage::find_xray_worker_by_profile_id)
|
||||
.map(|worker| {
|
||||
crate::proxy_manager::ProxyManager::build_proxy_url(&worker.local_proxy_settings())
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -119,120 +168,174 @@ fn fingerprint_locale(profile: &BrowserProfile) -> (Option<String>, Option<Strin
|
||||
(timezone, language)
|
||||
}
|
||||
|
||||
/// Run the check for a profile. No-ops (consistent, unchecked) when the
|
||||
/// profile has no proxy or the exit node can't be reached.
|
||||
pub async fn check_profile_consistency(
|
||||
/// A mutex whose poison is not fatal.
|
||||
///
|
||||
/// A panic anywhere under this lock used to brick the check process-wide.
|
||||
/// That was tolerable when a failed check only skipped a warning; now a launch
|
||||
/// consults it, so a poisoned lock must degrade rather than propagate.
|
||||
fn exit_cache() -> std::sync::MutexGuard<'static, HashMap<String, CachedExit>> {
|
||||
EXIT_CACHE.lock().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
/// Compare a measured exit against a profile's fingerprint. Pure — no I/O.
|
||||
pub fn compare_exit_to_fingerprint(
|
||||
profile: &BrowserProfile,
|
||||
) -> Result<ConsistencyResult, String> {
|
||||
let Some(proxy_id) = &profile.proxy_id else {
|
||||
return Ok(ConsistencyResult::skip());
|
||||
};
|
||||
let Some(settings) = PROXY_MANAGER.get_proxy_settings_by_id(proxy_id) else {
|
||||
return Ok(ConsistencyResult::skip());
|
||||
};
|
||||
let profile_id = profile.id.to_string();
|
||||
let Some(url) = proxy_url(&settings, Some(&profile_id)) else {
|
||||
return Ok(ConsistencyResult::skip());
|
||||
};
|
||||
let cache_identity = if settings.proxy_type.eq_ignore_ascii_case("vless") {
|
||||
settings.vless_uri.clone().unwrap_or_else(|| url.clone())
|
||||
} else {
|
||||
url.clone()
|
||||
};
|
||||
|
||||
let now = crate::proxy_manager::now_secs();
|
||||
|
||||
// Serve a fresh cached exit lookup for this proxy if we have one, but only if
|
||||
// it was measured through the proxy's current endpoint and credentials.
|
||||
let cached = {
|
||||
let cache = EXIT_CACHE.lock().unwrap();
|
||||
cache
|
||||
.get(proxy_id)
|
||||
.filter(|c| {
|
||||
c.proxy_url == cache_identity && now.saturating_sub(c.fetched_at) < EXIT_CACHE_TTL_SECS
|
||||
})
|
||||
.cloned()
|
||||
};
|
||||
|
||||
let (exit_tz, exit_cc, exit_ip) = if let Some(c) = cached {
|
||||
(c.timezone, c.country_code, c.ip)
|
||||
} else {
|
||||
// Resolve the exit IP through the proxy, then geolocate it with the SAME
|
||||
// bundled MaxMind database the fingerprint generator (and the on-demand
|
||||
// match) use. Using one geo source everywhere means the check can never
|
||||
// disagree with what generation produced — a second source (e.g. ip-api)
|
||||
// routinely reports a different IANA zone for the same IP in multi-zone
|
||||
// countries, which would flag correctly-generated fingerprints and would
|
||||
// leave the "match to proxy" fix unable to satisfy the check.
|
||||
let exit_ip = crate::ip_utils::fetch_public_ip(Some(&url))
|
||||
.await
|
||||
.map_err(|e| format!("exit-node lookup failed: {e}"))?;
|
||||
match crate::geolocation::get_geolocation(&exit_ip) {
|
||||
Ok(geo) => {
|
||||
let tz = Some(geo.timezone);
|
||||
let cc = geo.locale.region.clone();
|
||||
let ip = Some(exit_ip);
|
||||
EXIT_CACHE.lock().unwrap().insert(
|
||||
proxy_id.clone(),
|
||||
CachedExit {
|
||||
fetched_at: now,
|
||||
proxy_url: cache_identity,
|
||||
timezone: tz.clone(),
|
||||
country_code: cc.clone(),
|
||||
ip: ip.clone(),
|
||||
},
|
||||
);
|
||||
(tz, cc, ip)
|
||||
}
|
||||
// Reached the exit but couldn't place it (database missing, or a private
|
||||
// exit IP). Skip rather than warn on an unknown location — the same
|
||||
// database gates fingerprint geo, so there's nothing to disagree with.
|
||||
Err(e) => {
|
||||
log::debug!("Consistency check: could not geolocate exit IP: {e}");
|
||||
return Ok(ConsistencyResult::skip());
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
exit_timezone: Option<String>,
|
||||
exit_country_code: Option<String>,
|
||||
exit_ip: Option<String>,
|
||||
) -> ConsistencyResult {
|
||||
let (fp_tz, fp_lang) = fingerprint_locale(profile);
|
||||
let mut mismatches = Vec::new();
|
||||
|
||||
if let (Some(exit), Some(fp)) = (&exit_tz, &fp_tz) {
|
||||
if let (Some(exit), Some(fp)) = (&exit_timezone, &fp_tz) {
|
||||
if !exit.eq_ignore_ascii_case(fp) {
|
||||
mismatches.push("timezone".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
if let (Some(cc), Some(lang)) = (&exit_cc, &fp_lang) {
|
||||
if let (Some(cc), Some(lang)) = (&exit_country_code, &fp_lang) {
|
||||
if language_matches_country(cc, lang) == Some(false) {
|
||||
mismatches.push("language".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(ConsistencyResult {
|
||||
ConsistencyResult {
|
||||
consistent: mismatches.is_empty(),
|
||||
checked: true,
|
||||
exit_ip,
|
||||
exit_country_code: exit_cc,
|
||||
exit_timezone: exit_tz,
|
||||
exit_country_code,
|
||||
exit_timezone,
|
||||
fingerprint_timezone: fp_tz,
|
||||
fingerprint_language: fp_lang,
|
||||
mismatches,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn check_profile_fingerprint_consistency(
|
||||
profile_id: String,
|
||||
/// Look up a still-valid cached exit for this profile.
|
||||
fn cached_exit(key: &ExitCacheKey) -> Option<CachedExit> {
|
||||
let now = crate::proxy_manager::now_secs();
|
||||
exit_cache()
|
||||
.get(&key.scope)
|
||||
.filter(|c| {
|
||||
c.identity == key.identity && now.saturating_sub(c.fetched_at) < EXIT_CACHE_TTL_SECS
|
||||
})
|
||||
.cloned()
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
let Some(key) = exit_cache_key(profile) else {
|
||||
return ConsistencyResult::skip();
|
||||
};
|
||||
let Some(cached) = cached_exit(&key) else {
|
||||
return ConsistencyResult::skip();
|
||||
};
|
||||
compare_exit_to_fingerprint(profile, cached.timezone, cached.country_code, cached.ip)
|
||||
}
|
||||
|
||||
/// Drop any cached exit for this profile, so the next check re-measures.
|
||||
pub fn invalidate_exit_cache(profile: &BrowserProfile) {
|
||||
if let Some(key) = exit_cache_key(profile) {
|
||||
exit_cache().remove(&key.scope);
|
||||
}
|
||||
}
|
||||
|
||||
/// Measure the exit through an already-normalized upstream and compare it to
|
||||
/// the fingerprint.
|
||||
///
|
||||
/// `upstream` is what the launcher will actually hand the browser — a loopback
|
||||
/// worker for VLESS and VPN, the resolved endpoint for a stored proxy — so one
|
||||
/// code path covers every transport. `None` means a genuine direct connection,
|
||||
/// which has nothing to disagree with.
|
||||
pub async fn probe_and_check_consistency(
|
||||
profile: &BrowserProfile,
|
||||
upstream: Option<&crate::browser::ProxySettings>,
|
||||
key: &ExitCacheKey,
|
||||
) -> Result<ConsistencyResult, String> {
|
||||
let profiles = crate::profile::ProfileManager::instance()
|
||||
.list_profiles()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let profile = profiles
|
||||
.into_iter()
|
||||
.find(|p| p.id.to_string() == profile_id)
|
||||
.ok_or_else(|| serde_json::json!({ "code": "PROFILE_NOT_FOUND" }).to_string())?;
|
||||
check_profile_consistency(&profile).await
|
||||
if let Some(cached) = cached_exit(key) {
|
||||
return Ok(compare_exit_to_fingerprint(
|
||||
profile,
|
||||
cached.timezone,
|
||||
cached.country_code,
|
||||
cached.ip,
|
||||
));
|
||||
}
|
||||
|
||||
let Some(settings) = upstream else {
|
||||
return Ok(ConsistencyResult::skip());
|
||||
};
|
||||
let Some(url) = probe_url(settings) else {
|
||||
return Ok(ConsistencyResult::skip());
|
||||
};
|
||||
|
||||
// Resolve the exit IP through the proxy, then geolocate it with the SAME
|
||||
// bundled MaxMind database the fingerprint generator (and the on-demand
|
||||
// match) use. Using one geo source everywhere means the check can never
|
||||
// disagree with what generation produced — a second source (e.g. ip-api)
|
||||
// routinely reports a different IANA zone for the same IP in multi-zone
|
||||
// countries, which would flag correctly-generated fingerprints and would
|
||||
// leave the "match to proxy" fix unable to satisfy the check.
|
||||
//
|
||||
// Bounded independently of fetch_public_ip's own per-request timeout: that
|
||||
// one races six endpoints and can add up to far longer than a user will wait
|
||||
// in front of a launch.
|
||||
let fetched = tokio::time::timeout(PROBE_TIMEOUT, crate::ip_utils::fetch_public_ip(Some(&url)))
|
||||
.await
|
||||
.map_err(|_| crate::backend_error("EXIT_PROBE_FAILED"))?;
|
||||
let exit_ip = fetched.map_err(|e| crate::backend_error_with_detail("EXIT_PROBE_FAILED", e))?;
|
||||
|
||||
match crate::geolocation::get_geolocation(&exit_ip) {
|
||||
Ok(geo) => {
|
||||
let tz = Some(geo.timezone);
|
||||
let cc = geo.locale.region.clone();
|
||||
exit_cache().insert(
|
||||
key.scope.clone(),
|
||||
CachedExit {
|
||||
fetched_at: crate::proxy_manager::now_secs(),
|
||||
identity: key.identity.clone(),
|
||||
timezone: tz.clone(),
|
||||
country_code: cc.clone(),
|
||||
ip: Some(exit_ip.clone()),
|
||||
},
|
||||
);
|
||||
Ok(compare_exit_to_fingerprint(profile, tz, cc, Some(exit_ip)))
|
||||
}
|
||||
// Reached the exit but couldn't place it (database missing, or a private
|
||||
// exit IP). Skip rather than warn on an unknown location — the same
|
||||
// database gates fingerprint geo, so there's nothing to disagree with.
|
||||
Err(e) => {
|
||||
log::debug!("Consistency check: could not geolocate exit IP: {e}");
|
||||
Ok(ConsistencyResult::skip())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Measure the exit this machine reaches without any proxy, and compare it to
|
||||
/// the fingerprint.
|
||||
///
|
||||
/// Used when a profile declares a route that did not materialize: the browser
|
||||
/// is about to connect directly, so the direct exit is the one that matters.
|
||||
/// Deliberately NOT cached — a direct exit belongs to this machine's network,
|
||||
/// not to any stored proxy, and it changes without a config edit.
|
||||
pub async fn probe_direct_and_check(profile: &BrowserProfile) -> Result<ConsistencyResult, String> {
|
||||
let fetched = tokio::time::timeout(PROBE_TIMEOUT, crate::ip_utils::fetch_public_ip(None))
|
||||
.await
|
||||
.map_err(|_| crate::backend_error("EXIT_PROBE_FAILED"))?;
|
||||
let exit_ip = fetched.map_err(|e| crate::backend_error_with_detail("EXIT_PROBE_FAILED", e))?;
|
||||
|
||||
match crate::geolocation::get_geolocation(&exit_ip) {
|
||||
Ok(geo) => Ok(compare_exit_to_fingerprint(
|
||||
profile,
|
||||
Some(geo.timezone),
|
||||
geo.locale.region.clone(),
|
||||
Some(exit_ip),
|
||||
)),
|
||||
Err(e) => {
|
||||
log::debug!("Consistency check: could not geolocate direct exit IP: {e}");
|
||||
Ok(ConsistencyResult::skip())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Rewrite a profile's stored fingerprint so its geolocation (timezone,
|
||||
@@ -280,6 +383,10 @@ pub async fn match_profile_fingerprint_to_exit(
|
||||
.to_string()
|
||||
})?;
|
||||
|
||||
// The stored verdict was computed against the fingerprint we just rewrote.
|
||||
// Leaving it would re-block the very launch this fix exists to unblock.
|
||||
invalidate_exit_cache(&profile);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -344,55 +451,218 @@ mod tests {
|
||||
assert!(language_matches_country("CH", "de-CH").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proxy_url_percent_encodes_credentials_and_skips_shadowsocks() {
|
||||
let http = crate::browser::ProxySettings {
|
||||
proxy_type: "http".into(),
|
||||
host: "h".into(),
|
||||
fn settings(
|
||||
proxy_type: &str,
|
||||
user: Option<&str>,
|
||||
pass: Option<&str>,
|
||||
) -> crate::browser::ProxySettings {
|
||||
crate::browser::ProxySettings {
|
||||
proxy_type: proxy_type.into(),
|
||||
host: "gw.provider.io".into(),
|
||||
port: 8080,
|
||||
username: Some("u".into()),
|
||||
password: Some("p".into()),
|
||||
username: user.map(str::to_string),
|
||||
password: pass.map(str::to_string),
|
||||
vless_uri: None,
|
||||
};
|
||||
assert_eq!(proxy_url(&http, None).as_deref(), Some("http://u:p@h:8080"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn probe_url_percent_encodes_credentials_and_skips_shadowsocks() {
|
||||
assert_eq!(
|
||||
probe_url(&settings("http", Some("u"), Some("p"))).as_deref(),
|
||||
Some("http://u:p@gw.provider.io:8080")
|
||||
);
|
||||
|
||||
// A password with URL-reserved characters must not break the authority —
|
||||
// unencoded, the `/` truncates the host and reqwest targets `u` instead.
|
||||
let reserved = crate::browser::ProxySettings {
|
||||
proxy_type: "http".into(),
|
||||
host: "gw.provider.io".into(),
|
||||
port: 8080,
|
||||
username: Some("user".into()),
|
||||
password: Some("ab/cd@ef".into()),
|
||||
vless_uri: None,
|
||||
};
|
||||
assert_eq!(
|
||||
proxy_url(&reserved, None).as_deref(),
|
||||
probe_url(&settings("http", Some("user"), Some("ab/cd@ef"))).as_deref(),
|
||||
Some("http://user:ab%2Fcd%40ef@gw.provider.io:8080")
|
||||
);
|
||||
|
||||
// Username-only proxies keep their auth.
|
||||
let user_only = crate::browser::ProxySettings {
|
||||
proxy_type: "socks5".into(),
|
||||
host: "h".into(),
|
||||
port: 1080,
|
||||
username: Some("justuser".into()),
|
||||
password: None,
|
||||
vless_uri: None,
|
||||
};
|
||||
assert_eq!(
|
||||
proxy_url(&user_only, None).as_deref(),
|
||||
Some("socks5://justuser@h:1080")
|
||||
probe_url(&settings("socks4", Some("justuser"), None)).as_deref(),
|
||||
Some("socks4://justuser@gw.provider.io:8080")
|
||||
);
|
||||
|
||||
let ss = crate::browser::ProxySettings {
|
||||
proxy_type: "ss".into(),
|
||||
host: "h".into(),
|
||||
port: 8080,
|
||||
username: None,
|
||||
password: None,
|
||||
vless_uri: None,
|
||||
// Shadowsocks cannot carry a reqwest probe, so it is skipped rather than
|
||||
// guessed at.
|
||||
assert_eq!(probe_url(&settings("ss", None, None)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn probe_url_uses_socks5h_so_dns_resolves_at_the_exit() {
|
||||
let url = probe_url(&settings("socks5", Some("u"), Some("p"))).unwrap();
|
||||
assert!(
|
||||
url.starts_with("socks5h://"),
|
||||
"probe must not resolve the echo host locally, got {url}"
|
||||
);
|
||||
// The browser-facing builder is deliberately left alone.
|
||||
assert!(
|
||||
crate::proxy_manager::ProxyManager::build_proxy_url(&settings(
|
||||
"socks5",
|
||||
Some("u"),
|
||||
Some("p")
|
||||
))
|
||||
.starts_with("socks5://")
|
||||
);
|
||||
}
|
||||
|
||||
fn profile_with_fingerprint(timezone: &str, language: &str) -> BrowserProfile {
|
||||
let mut profile = BrowserProfile {
|
||||
id: uuid::Uuid::new_v4(),
|
||||
name: "p".into(),
|
||||
browser: "wayfern".into(),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(proxy_url(&ss, None), None);
|
||||
profile.wayfern_config = Some(crate::wayfern_manager::WayfernConfig {
|
||||
fingerprint: Some(
|
||||
serde_json::json!({ "timezone": timezone, "language": language }).to_string(),
|
||||
),
|
||||
..Default::default()
|
||||
});
|
||||
profile
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compare_flags_a_timezone_mismatch() {
|
||||
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.checked);
|
||||
assert!(!result.consistent);
|
||||
assert!(result.mismatches.contains(&"timezone".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compare_accepts_a_matching_exit() {
|
||||
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.consistent, "{:?}", result.mismatches);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compare_is_case_insensitive_on_timezone() {
|
||||
let profile = profile_with_fingerprint("Europe/Berlin", "de-DE");
|
||||
let result = compare_exit_to_fingerprint(
|
||||
&profile,
|
||||
Some("europe/berlin".into()),
|
||||
Some("DE".into()),
|
||||
None,
|
||||
);
|
||||
assert!(result.consistent);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compare_skips_dimensions_the_fingerprint_does_not_declare() {
|
||||
// A profile with no fingerprint has nothing to contradict; it must not be
|
||||
// reported as a mismatch and so must never block a launch.
|
||||
let profile = BrowserProfile {
|
||||
id: uuid::Uuid::new_v4(),
|
||||
browser: "wayfern".into(),
|
||||
..Default::default()
|
||||
};
|
||||
let result = compare_exit_to_fingerprint(
|
||||
&profile,
|
||||
Some("Europe/Berlin".into()),
|
||||
Some("DE".into()),
|
||||
None,
|
||||
);
|
||||
assert!(result.consistent);
|
||||
assert!(result.mismatches.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cached_check_reports_unchecked_without_a_proxy_or_vpn() {
|
||||
let profile = profile_with_fingerprint("Europe/Berlin", "de-DE");
|
||||
let result = check_profile_consistency_cached(&profile);
|
||||
assert!(!result.checked);
|
||||
assert!(result.consistent, "an unchecked profile must never block");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exit_cache_key_is_absent_without_a_proxy_or_vpn() {
|
||||
let profile = profile_with_fingerprint("Europe/Berlin", "de-DE");
|
||||
assert_eq!(exit_cache_key(&profile), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exit_cache_key_scopes_a_vpn_profile_by_vpn_id() {
|
||||
let mut profile = profile_with_fingerprint("Europe/Berlin", "de-DE");
|
||||
profile.vpn_id = Some("vpn-abc".into());
|
||||
let key = exit_cache_key(&profile).expect("vpn profiles must be cacheable");
|
||||
assert_eq!(key.scope, "vpn:vpn-abc");
|
||||
assert_eq!(key.identity, "vpn-abc");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cached_entry_is_ignored_once_the_endpoint_identity_changes() {
|
||||
let key = ExitCacheKey {
|
||||
scope: "proxy:test-identity-change".into(),
|
||||
identity: "http://old@host:1".into(),
|
||||
};
|
||||
exit_cache().insert(
|
||||
key.scope.clone(),
|
||||
CachedExit {
|
||||
fetched_at: crate::proxy_manager::now_secs(),
|
||||
identity: key.identity.clone(),
|
||||
timezone: Some("Europe/Berlin".into()),
|
||||
country_code: Some("DE".into()),
|
||||
ip: Some("1.2.3.4".into()),
|
||||
},
|
||||
);
|
||||
assert!(cached_exit(&key).is_some());
|
||||
|
||||
// Editing a stored proxy keeps its id but changes the endpoint; the old
|
||||
// measurement must not be reused for the new one.
|
||||
let rotated = ExitCacheKey {
|
||||
identity: "http://new@host:2".into(),
|
||||
..key.clone()
|
||||
};
|
||||
assert!(cached_exit(&rotated).is_none());
|
||||
exit_cache().remove(&key.scope);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cached_entry_expires_after_the_ttl() {
|
||||
let key: ExitCacheKey = ExitCacheKey {
|
||||
scope: "proxy:test-ttl".into(),
|
||||
identity: "http://host:1".into(),
|
||||
};
|
||||
exit_cache().insert(
|
||||
key.scope.clone(),
|
||||
CachedExit {
|
||||
fetched_at: crate::proxy_manager::now_secs() - EXIT_CACHE_TTL_SECS - 1,
|
||||
identity: key.identity.clone(),
|
||||
timezone: Some("Europe/Berlin".into()),
|
||||
country_code: Some("DE".into()),
|
||||
ip: None,
|
||||
},
|
||||
);
|
||||
assert!(cached_exit(&key).is_none());
|
||||
exit_cache().remove(&key.scope);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exit_cache_survives_a_poisoned_lock() {
|
||||
// A panic under this lock must degrade the check, not brick every
|
||||
// subsequent launch that consults it.
|
||||
let _ = std::thread::spawn(|| {
|
||||
let _guard = EXIT_CACHE.lock().unwrap();
|
||||
panic!("poison the cache");
|
||||
})
|
||||
.join();
|
||||
assert!(EXIT_CACHE.is_poisoned());
|
||||
exit_cache().remove("nonexistent-scope");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,540 @@
|
||||
//! The pre-spawn launch gate.
|
||||
//!
|
||||
//! Two findings can stop a launch being what the user expects:
|
||||
//!
|
||||
//! * a **VPN/proxy extension** in the profile, which can override the proxy
|
||||
//! Donut configured and silently move the browser's exit away from the one
|
||||
//! the fingerprint was generated for — a warning, since Donut cannot tell
|
||||
//! from outside whether it is actually routing anything;
|
||||
//! * a measured **exit/fingerprint mismatch**, which is a hard block: the
|
||||
//! browser does not start until the user explicitly proceeds.
|
||||
//!
|
||||
//! The enforcing half runs inside `browser_runner::launch_browser_internal`,
|
||||
//! after the upstream has been normalized (so VLESS and VPN profiles are
|
||||
//! reachable at all) and before the local proxy starts or the browser spawns.
|
||||
//! `get_profile_pre_launch_checks` is the cheap, local-only half the UI calls
|
||||
//! first, so a profile whose exit is already known blocks without starting a
|
||||
//! single worker.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use crate::fingerprint_consistency::{self, ConsistencyResult};
|
||||
use crate::profile::types::BrowserProfile;
|
||||
use crate::vpn_extension_detect::{self, DetectedVpnExtension};
|
||||
|
||||
/// How long a "launch anyway" decision stays redeemable. Long enough to read
|
||||
/// the dialog, short enough that a token cannot sit around across a session.
|
||||
const CONSENT_TTL_SECS: u64 = 10 * 60;
|
||||
|
||||
/// What the gate is allowed to do on this launch.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub enum FingerprintGate {
|
||||
/// Block on a measured mismatch. The default, and what the GUI uses.
|
||||
#[default]
|
||||
Enforce,
|
||||
/// Measure only from cache and report; never block, never probe the network.
|
||||
/// Automation runs here: a headless client has no dialog to answer and
|
||||
/// cannot regenerate its fingerprint mid-run, so a hard failure would turn a
|
||||
/// warning into an outage for a whole fleet.
|
||||
Advisory,
|
||||
/// The user already said "launch anyway" and handed back a token.
|
||||
Consented(String),
|
||||
}
|
||||
|
||||
struct PendingConsent {
|
||||
profile_id: String,
|
||||
fingerprint_hash: String,
|
||||
exit_identity: String,
|
||||
issued_at: u64,
|
||||
}
|
||||
|
||||
lazy_static::lazy_static! {
|
||||
static ref CONSENTS: Mutex<HashMap<String, PendingConsent>> = Mutex::new(HashMap::new());
|
||||
}
|
||||
|
||||
fn consents() -> std::sync::MutexGuard<'static, HashMap<String, PendingConsent>> {
|
||||
CONSENTS.lock().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
fn random_token() -> String {
|
||||
use rand::Rng;
|
||||
let mut rng = rand::rng();
|
||||
let mut bytes = [0u8; 16];
|
||||
rng.fill_bytes(&mut bytes);
|
||||
bytes.iter().map(|b| format!("{b:02x}")).collect()
|
||||
}
|
||||
|
||||
/// Issue a single-use token authorizing one launch of this exact
|
||||
/// (profile, fingerprint, exit) combination.
|
||||
///
|
||||
/// A plain `bypass: bool` cannot express this: a "proceed" the user granted
|
||||
/// while looking at proxy A would silently authorize a launch through proxy B
|
||||
/// if they changed it before the retry landed.
|
||||
pub fn mint_consent(profile: &BrowserProfile, exit_identity: &str) -> String {
|
||||
let token = random_token();
|
||||
let now = crate::proxy_manager::now_secs();
|
||||
let mut store = consents();
|
||||
store.retain(|_, c| now.saturating_sub(c.issued_at) < CONSENT_TTL_SECS);
|
||||
store.insert(
|
||||
token.clone(),
|
||||
PendingConsent {
|
||||
profile_id: profile.id.to_string(),
|
||||
fingerprint_hash: crate::launch_gate_prefs::fingerprint_hash(profile),
|
||||
exit_identity: exit_identity.to_string(),
|
||||
issued_at: now,
|
||||
},
|
||||
);
|
||||
token
|
||||
}
|
||||
|
||||
/// Redeem a consent token. Single use — a redeemed token is removed whether or
|
||||
/// not it validated, so a leaked token cannot be replayed.
|
||||
pub fn redeem_consent(
|
||||
token: &str,
|
||||
profile: &BrowserProfile,
|
||||
exit_identity: &str,
|
||||
) -> Result<(), String> {
|
||||
let now = crate::proxy_manager::now_secs();
|
||||
let pending = {
|
||||
let mut store = consents();
|
||||
store.retain(|_, c| now.saturating_sub(c.issued_at) < CONSENT_TTL_SECS);
|
||||
store.remove(token)
|
||||
};
|
||||
|
||||
let Some(pending) = pending else {
|
||||
return Err(crate::backend_error("LAUNCH_CONSENT_EXPIRED"));
|
||||
};
|
||||
if pending.profile_id != profile.id.to_string()
|
||||
|| pending.fingerprint_hash != crate::launch_gate_prefs::fingerprint_hash(profile)
|
||||
|| pending.exit_identity != exit_identity
|
||||
{
|
||||
return Err(crate::backend_error("LAUNCH_CONSENT_EXPIRED"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn mismatch_error(result: &ConsistencyResult, token: &str) -> String {
|
||||
serde_json::json!({
|
||||
"code": "FINGERPRINT_EXIT_MISMATCH",
|
||||
"params": {
|
||||
"token": token,
|
||||
"exitIp": result.exit_ip.clone().unwrap_or_default(),
|
||||
"exitCountry": result.exit_country_code.clone().unwrap_or_default(),
|
||||
"exitTimezone": result.exit_timezone.clone().unwrap_or_default(),
|
||||
"fingerprintTimezone": result.fingerprint_timezone.clone().unwrap_or_default(),
|
||||
"fingerprintLanguage": result.fingerprint_language.clone().unwrap_or_default(),
|
||||
"mismatches": result.mismatches.join(","),
|
||||
}
|
||||
})
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn gate_disabled() -> bool {
|
||||
crate::settings_manager::SettingsManager::instance()
|
||||
.load_settings()
|
||||
.map(|s| s.fingerprint_gate_disabled)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn extension_warning_disabled() -> bool {
|
||||
crate::settings_manager::SettingsManager::instance()
|
||||
.load_settings()
|
||||
.map(|s| s.vpn_extension_warning_disabled)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Identity used for consent and acknowledgement when the browser will connect
|
||||
/// directly. Distinct from any proxy identity, so accepting a direct-exit
|
||||
/// mismatch never disarms the gate for a proxied one.
|
||||
const DIRECT_EXIT_IDENTITY: &str = "direct";
|
||||
|
||||
/// Gate a launch that will connect directly despite the profile declaring a
|
||||
/// route. Measures the exit the browser will really use.
|
||||
async fn enforce_direct_exit(
|
||||
profile: &BrowserProfile,
|
||||
gate: &FingerprintGate,
|
||||
) -> Result<(), String> {
|
||||
if gate_disabled() {
|
||||
return Ok(());
|
||||
}
|
||||
if crate::launch_gate_prefs::fingerprint_ack_matches(profile, DIRECT_EXIT_IDENTITY) {
|
||||
return Ok(());
|
||||
}
|
||||
if let FingerprintGate::Consented(token) = gate {
|
||||
return redeem_consent(token, profile, DIRECT_EXIT_IDENTITY);
|
||||
}
|
||||
// Automation never probes; without a cache to consult there is nothing to say.
|
||||
if matches!(gate, FingerprintGate::Advisory) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let result = match fingerprint_consistency::probe_direct_and_check(profile).await {
|
||||
Ok(result) => result,
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"Fingerprint gate: direct exit probe failed for profile {}, allowing launch: {e}",
|
||||
profile.name
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
if !result.checked || result.consistent {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let token = mint_consent(profile, DIRECT_EXIT_IDENTITY);
|
||||
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.
|
||||
///
|
||||
/// Fails **open** on every degradation — probe failure, timeout, missing geo
|
||||
/// database, private exit IP. The gate blocks only on a positively measured
|
||||
/// mismatch; a flaky IP-echo endpoint must never make profiles unlaunchable.
|
||||
pub async fn enforce_fingerprint_gate(
|
||||
profile: &BrowserProfile,
|
||||
upstream: Option<&crate::browser::ProxySettings>,
|
||||
gate: &FingerprintGate,
|
||||
) -> Result<(), String> {
|
||||
// A profile that declares no route is genuinely direct: the browser's exit is
|
||||
// this machine, which is what an un-proxied fingerprint should describe.
|
||||
//
|
||||
// But a profile that DOES declare one and still arrives here with no upstream
|
||||
// is about to go direct anyway — a deleted or unresolvable proxy resolves to
|
||||
// `None` and the launch continues. That is the exact leak this gate exists to
|
||||
// stop, so it must be measured, not waved through.
|
||||
let declares_route = profile.proxy_id.is_some() || profile.vpn_id.is_some();
|
||||
if upstream.is_none() && !declares_route {
|
||||
return Ok(());
|
||||
}
|
||||
if gate_disabled() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Decide *once*, before any consent handling, whether this launch is going
|
||||
// out directly. Both a route that no longer resolves (deleted proxy) and one
|
||||
// that produced no usable upstream (a VPN worker with no local port) end up
|
||||
// connecting directly, and both must mint and redeem consent under the same
|
||||
// identity — splitting that decision across the function meant the first
|
||||
// attempt minted under "direct" while the retry redeemed against the proxy
|
||||
// identity, so "Launch anyway" could never succeed.
|
||||
let key = fingerprint_consistency::exit_cache_key(profile);
|
||||
if key.is_none() || upstream.is_none() {
|
||||
log::warn!(
|
||||
"Fingerprint gate: {} declares a proxy/VPN that yielded no usable upstream; \
|
||||
measuring the direct exit it will actually use",
|
||||
profile.name
|
||||
);
|
||||
return enforce_direct_exit(profile, gate).await;
|
||||
}
|
||||
let key = key.expect("checked above");
|
||||
|
||||
// Ack first: a persisted acknowledgement already permits this launch, so a
|
||||
// stale token must not turn it into a hard failure.
|
||||
if crate::launch_gate_prefs::fingerprint_ack_matches(profile, &key.identity) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if let FingerprintGate::Consented(token) = gate {
|
||||
redeem_consent(token, profile, &key.identity)?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let result = if matches!(gate, FingerprintGate::Advisory) {
|
||||
// Automation: answer from a warm cache or say nothing. Probing here would
|
||||
// add seconds to every profile in a batch run.
|
||||
fingerprint_consistency::check_profile_consistency_cached(profile)
|
||||
} else {
|
||||
match fingerprint_consistency::probe_and_check_consistency(profile, upstream, &key).await {
|
||||
Ok(result) => result,
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"Fingerprint gate: exit probe failed for profile {}, allowing launch: {e}",
|
||||
profile.name
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if !result.checked || result.consistent {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Only now is the extension scan worth its disk walk. A confirmed
|
||||
// proxy-permission extension can redirect the browser's traffic away from the
|
||||
// upstream we just measured, so the measurement describes an exit the browser
|
||||
// may not take. Report it, but do not hard-block on a number known to be
|
||||
// unreliable.
|
||||
let measurement_unreliable =
|
||||
vpn_extension_detect::has_confirmed(&vpn_extension_detect::scan_profile(profile));
|
||||
|
||||
if matches!(gate, FingerprintGate::Advisory) || measurement_unreliable {
|
||||
log::warn!(
|
||||
"Fingerprint gate: {} launching with a {} exit mismatch ({})",
|
||||
profile.name,
|
||||
if measurement_unreliable {
|
||||
"unverifiable"
|
||||
} else {
|
||||
"known"
|
||||
},
|
||||
result.mismatches.join(", ")
|
||||
);
|
||||
if let Err(e) = crate::events::emit("fingerprint-consistency-warning", &result) {
|
||||
log::warn!("Failed to emit fingerprint consistency warning: {e}");
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let token = mint_consent(profile, &key.identity);
|
||||
Err(mismatch_error(&result, &token))
|
||||
}
|
||||
|
||||
/// Everything the UI needs to decide whether to stop a launch, answered
|
||||
/// without touching the network or starting any worker.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PreLaunchChecks {
|
||||
pub vpn_extensions: Vec<DetectedVpnExtension>,
|
||||
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.
|
||||
pub exit_probe_pending: bool,
|
||||
/// A confirmed proxy-permission extension is present, so any exit
|
||||
/// measurement describes a route the browser may not take.
|
||||
pub exit_measurement_unreliable: bool,
|
||||
/// Present only when a cached mismatch is already blocking, so "launch
|
||||
/// anyway" can proceed without a second round trip.
|
||||
pub consent_token: Option<String>,
|
||||
}
|
||||
|
||||
fn load_profile(profile_id: &str) -> Result<BrowserProfile, String> {
|
||||
crate::profile::ProfileManager::instance()
|
||||
.list_profiles()
|
||||
.map_err(|e| e.to_string())?
|
||||
.into_iter()
|
||||
.find(|p| p.id.to_string() == profile_id)
|
||||
.ok_or_else(|| crate::backend_error("PROFILE_NOT_FOUND"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_profile_pre_launch_checks(profile_id: String) -> Result<PreLaunchChecks, String> {
|
||||
let profile = load_profile(&profile_id)?;
|
||||
|
||||
let scan = if extension_warning_disabled() {
|
||||
vpn_extension_detect::ExtensionScan {
|
||||
extensions: Vec::new(),
|
||||
scan_state: "scanned".to_string(),
|
||||
}
|
||||
} else {
|
||||
vpn_extension_detect::scan_profile(&profile)
|
||||
};
|
||||
|
||||
// Drop anything the user has already acknowledged for this profile, so the
|
||||
// dialog only ever opens for something new.
|
||||
let vpn_extensions: Vec<DetectedVpnExtension> = scan
|
||||
.extensions
|
||||
.iter()
|
||||
.filter(|e| {
|
||||
!crate::launch_gate_prefs::extensions_acked(&profile_id, std::slice::from_ref(&e.key))
|
||||
})
|
||||
.cloned()
|
||||
.collect();
|
||||
let exit_measurement_unreliable = vpn_extension_detect::has_confirmed(&scan);
|
||||
|
||||
let disabled = gate_disabled();
|
||||
let key = fingerprint_consistency::exit_cache_key(&profile);
|
||||
|
||||
let consistency = if disabled {
|
||||
ConsistencyResult::skip()
|
||||
} else {
|
||||
fingerprint_consistency::check_profile_consistency_cached(&profile)
|
||||
};
|
||||
|
||||
let already_acked = key
|
||||
.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 consent_token = match (&key, blocking) {
|
||||
(Some(k), true) => Some(mint_consent(&profile, &k.identity)),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
Ok(PreLaunchChecks {
|
||||
vpn_extensions,
|
||||
scan_state: scan.scan_state,
|
||||
consistency: if blocking {
|
||||
consistency
|
||||
} else {
|
||||
ConsistencyResult::skip()
|
||||
},
|
||||
exit_probe_pending: !disabled && !already_acked && key.is_some() && !blocking,
|
||||
exit_measurement_unreliable,
|
||||
consent_token,
|
||||
})
|
||||
}
|
||||
|
||||
/// Persist "don't ask me again" choices from the gate dialog.
|
||||
#[tauri::command]
|
||||
pub async fn ack_launch_gate(
|
||||
profile_id: String,
|
||||
ack_fingerprint: bool,
|
||||
ack_extension_keys: Vec<String>,
|
||||
) -> Result<(), String> {
|
||||
let profile = load_profile(&profile_id)?;
|
||||
|
||||
if ack_fingerprint {
|
||||
// Must match the identity the block was issued against. A profile whose
|
||||
// route did not resolve is gated on the direct exit and has no cache key,
|
||||
// so falling back here is what makes "don't block again" stick for it.
|
||||
let identity = fingerprint_consistency::exit_cache_key(&profile)
|
||||
.map(|key| key.identity)
|
||||
.unwrap_or_else(|| DIRECT_EXIT_IDENTITY.to_string());
|
||||
crate::launch_gate_prefs::ack_fingerprint(&profile, &identity);
|
||||
}
|
||||
crate::launch_gate_prefs::ack_extensions(&profile_id, &ack_extension_keys);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn profile_with(fingerprint: &str) -> BrowserProfile {
|
||||
let mut profile = BrowserProfile {
|
||||
id: uuid::Uuid::new_v4(),
|
||||
browser: "wayfern".into(),
|
||||
..Default::default()
|
||||
};
|
||||
profile.wayfern_config = Some(crate::wayfern_manager::WayfernConfig {
|
||||
fingerprint: Some(fingerprint.to_string()),
|
||||
..Default::default()
|
||||
});
|
||||
profile
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn consent_token_authorizes_exactly_one_launch() {
|
||||
let profile = profile_with(r#"{"timezone":"Europe/Berlin"}"#);
|
||||
let token = mint_consent(&profile, "http://gw:1");
|
||||
assert!(redeem_consent(&token, &profile, "http://gw:1").is_ok());
|
||||
// Replaying it must fail, so a leaked token cannot re-authorize.
|
||||
assert!(redeem_consent(&token, &profile, "http://gw:1").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn consent_token_is_rejected_for_a_different_profile() {
|
||||
let profile = profile_with(r#"{"timezone":"Europe/Berlin"}"#);
|
||||
let other = profile_with(r#"{"timezone":"Europe/Berlin"}"#);
|
||||
let token = mint_consent(&profile, "http://gw:1");
|
||||
assert!(redeem_consent(&token, &other, "http://gw:1").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn consent_token_is_rejected_after_the_fingerprint_changes() {
|
||||
let profile = profile_with(r#"{"timezone":"Europe/Berlin"}"#);
|
||||
let token = mint_consent(&profile, "http://gw:1");
|
||||
|
||||
let mut regenerated = profile_with(r#"{"timezone":"America/New_York"}"#);
|
||||
regenerated.id = profile.id;
|
||||
assert!(redeem_consent(&token, ®enerated, "http://gw:1").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn consent_token_is_rejected_after_the_exit_changes() {
|
||||
// The reason a bare `bypass: bool` is not enough: consent granted for one
|
||||
// proxy must not authorize a launch through another.
|
||||
let profile = profile_with(r#"{"timezone":"Europe/Berlin"}"#);
|
||||
let token = mint_consent(&profile, "http://gw:1");
|
||||
assert!(redeem_consent(&token, &profile, "http://other:2").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unknown_token_is_rejected() {
|
||||
let profile = profile_with(r#"{"timezone":"Europe/Berlin"}"#);
|
||||
let err = redeem_consent("deadbeef", &profile, "http://gw:1").unwrap_err();
|
||||
assert!(err.contains("LAUNCH_CONSENT_EXPIRED"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expired_tokens_are_swept_and_rejected() {
|
||||
let profile = profile_with(r#"{"timezone":"Europe/Berlin"}"#);
|
||||
let token = mint_consent(&profile, "http://gw:1");
|
||||
// Back-date it past the TTL.
|
||||
{
|
||||
let mut store = consents();
|
||||
if let Some(pending) = store.get_mut(&token) {
|
||||
pending.issued_at = crate::proxy_manager::now_secs() - CONSENT_TTL_SECS - 1;
|
||||
}
|
||||
}
|
||||
assert!(redeem_consent(&token, &profile, "http://gw:1").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mismatch_error_carries_the_details_the_dialog_renders() {
|
||||
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: Some("en-US".into()),
|
||||
mismatches: vec!["timezone".into(), "language".into()],
|
||||
};
|
||||
let encoded = mismatch_error(&result, "tok");
|
||||
let parsed: serde_json::Value = serde_json::from_str(&encoded).unwrap();
|
||||
assert_eq!(parsed["code"], "FINGERPRINT_EXIT_MISMATCH");
|
||||
assert_eq!(parsed["params"]["token"], "tok");
|
||||
assert_eq!(parsed["params"]["exitTimezone"], "Europe/Berlin");
|
||||
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");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gate_allows_a_direct_connection_without_measuring() {
|
||||
let profile = profile_with(r#"{"timezone":"Europe/Berlin"}"#);
|
||||
assert!(
|
||||
enforce_fingerprint_gate(&profile, None, &FingerprintGate::Enforce)
|
||||
.await
|
||||
.is_ok()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gate_allows_a_profile_with_no_proxy_or_vpn() {
|
||||
// A profile that declares no route has no upstream either — that pairing is
|
||||
// the only one the launcher can actually produce. It must return without
|
||||
// measuring anything, so this stays a pure unit test with no network.
|
||||
let profile = profile_with(r#"{"timezone":"Europe/Berlin"}"#);
|
||||
assert!(
|
||||
enforce_fingerprint_gate(&profile, None, &FingerprintGate::Enforce)
|
||||
.await
|
||||
.is_ok()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn consent_for_a_direct_launch_is_redeemable_by_the_gate() {
|
||||
// Regression: a route that yields no usable upstream is gated on the direct
|
||||
// exit, so consent is minted under DIRECT_EXIT_IDENTITY. If the gate then
|
||||
// redeemed against the proxy/VPN identity instead, "Launch anyway" would
|
||||
// fail forever and the profile could never be started.
|
||||
let mut profile = profile_with(r#"{"timezone":"Europe/Berlin"}"#);
|
||||
profile.vpn_id = Some("vpn-with-no-port".into());
|
||||
|
||||
let token = mint_consent(&profile, DIRECT_EXIT_IDENTITY);
|
||||
// No upstream: the launcher could not bring the route up.
|
||||
let result = enforce_fingerprint_gate(&profile, None, &FingerprintGate::Consented(token)).await;
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"consent minted for the direct exit must be redeemable, got {result:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
//! Persisted "I know, launch it anyway" acknowledgements for the launch gate.
|
||||
//!
|
||||
//! Deliberately NOT synced. An acknowledgement is a statement about this
|
||||
//! machine's operator ("I understand this profile's exit disagrees with its
|
||||
//! fingerprint"), not a property of the profile. Syncing it would let one
|
||||
//! teammate disarm another's gate, and writing it into profile metadata would
|
||||
//! bump `updated_at` and make a local dismissal look like a remote edit.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use crate::profile::types::BrowserProfile;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct FingerprintAck {
|
||||
/// Hash of the fingerprint that was acknowledged.
|
||||
pub fingerprint_hash: String,
|
||||
/// Exit endpoint identity it was acknowledged against.
|
||||
pub exit_identity: String,
|
||||
pub acked_at: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct LaunchGatePrefs {
|
||||
#[serde(default)]
|
||||
pub fingerprint_acks: HashMap<String, FingerprintAck>,
|
||||
/// Profile id -> acknowledged extension keys.
|
||||
#[serde(default)]
|
||||
pub vpn_extension_acks: HashMap<String, Vec<String>>,
|
||||
}
|
||||
|
||||
lazy_static::lazy_static! {
|
||||
/// Serializes read-modify-write so two concurrent acknowledgements in a bulk
|
||||
/// run cannot clobber each other.
|
||||
static ref PREFS_LOCK: Mutex<()> = Mutex::new(());
|
||||
}
|
||||
|
||||
fn prefs_file() -> PathBuf {
|
||||
crate::app_dirs::data_subdir().join("launch_gate_prefs.json")
|
||||
}
|
||||
|
||||
pub fn load() -> LaunchGatePrefs {
|
||||
let Ok(content) = std::fs::read_to_string(prefs_file()) else {
|
||||
return LaunchGatePrefs::default();
|
||||
};
|
||||
serde_json::from_str(&content).unwrap_or_else(|e| {
|
||||
log::warn!("Failed to parse launch gate prefs, ignoring them: {e}");
|
||||
LaunchGatePrefs::default()
|
||||
})
|
||||
}
|
||||
|
||||
fn save(prefs: &LaunchGatePrefs) {
|
||||
let path = prefs_file();
|
||||
if let Some(parent) = path.parent() {
|
||||
if let Err(e) = std::fs::create_dir_all(parent) {
|
||||
log::warn!("Failed to create launch gate prefs dir: {e}");
|
||||
return;
|
||||
}
|
||||
}
|
||||
match serde_json::to_string_pretty(prefs) {
|
||||
Ok(json) => {
|
||||
if let Err(e) = std::fs::write(&path, json) {
|
||||
log::warn!("Failed to write launch gate prefs: {e}");
|
||||
}
|
||||
}
|
||||
Err(e) => log::warn!("Failed to serialize launch gate prefs: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn update(mutate: impl FnOnce(&mut LaunchGatePrefs)) {
|
||||
let _guard = PREFS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let mut prefs = load();
|
||||
mutate(&mut prefs);
|
||||
save(&prefs);
|
||||
}
|
||||
|
||||
/// Stable digest of a profile's stored fingerprint, so an acknowledgement stops
|
||||
/// applying the moment the fingerprint is regenerated or matched to a new exit.
|
||||
pub fn fingerprint_hash(profile: &BrowserProfile) -> String {
|
||||
use sha2::{Digest, Sha256};
|
||||
let fingerprint = profile
|
||||
.wayfern_config
|
||||
.as_ref()
|
||||
.and_then(|c| c.fingerprint.as_deref())
|
||||
.unwrap_or("");
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(fingerprint.as_bytes());
|
||||
hasher
|
||||
.finalize()
|
||||
.iter()
|
||||
.map(|b| format!("{b:02x}"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Record that the user accepted this exact (fingerprint, exit) mismatch.
|
||||
pub fn ack_fingerprint(profile: &BrowserProfile, exit_identity: &str) {
|
||||
let ack = FingerprintAck {
|
||||
fingerprint_hash: fingerprint_hash(profile),
|
||||
exit_identity: exit_identity.to_string(),
|
||||
acked_at: crate::proxy_manager::now_secs(),
|
||||
};
|
||||
let profile_id = profile.id.to_string();
|
||||
update(|prefs| {
|
||||
prefs.fingerprint_acks.insert(profile_id, ack);
|
||||
});
|
||||
}
|
||||
|
||||
/// Whether the user already accepted the mismatch this profile currently has.
|
||||
///
|
||||
/// Bound to both the fingerprint and the exit endpoint on purpose: the old
|
||||
/// per-profile "don't warn again" flag never expired, so one dismissal left a
|
||||
/// profile unprotected forever, including after its proxy was swapped for one
|
||||
/// in a different country.
|
||||
pub fn fingerprint_ack_matches(profile: &BrowserProfile, exit_identity: &str) -> bool {
|
||||
let prefs = load();
|
||||
prefs
|
||||
.fingerprint_acks
|
||||
.get(&profile.id.to_string())
|
||||
.is_some_and(|ack| {
|
||||
ack.fingerprint_hash == fingerprint_hash(profile) && ack.exit_identity == exit_identity
|
||||
})
|
||||
}
|
||||
|
||||
pub fn ack_extensions(profile_id: &str, keys: &[String]) {
|
||||
if keys.is_empty() {
|
||||
return;
|
||||
}
|
||||
let profile_id = profile_id.to_string();
|
||||
let keys = keys.to_vec();
|
||||
update(|prefs| {
|
||||
let entry = prefs.vpn_extension_acks.entry(profile_id).or_default();
|
||||
for key in keys {
|
||||
if !entry.contains(&key) {
|
||||
entry.push(key);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// True when every one of these extensions has already been acknowledged for
|
||||
/// this profile. Installing a *different* VPN extension later re-warns, because
|
||||
/// its key is not in the acknowledged set.
|
||||
pub fn extensions_acked(profile_id: &str, keys: &[String]) -> bool {
|
||||
if keys.is_empty() {
|
||||
return true;
|
||||
}
|
||||
let prefs = load();
|
||||
let Some(acked) = prefs.vpn_extension_acks.get(profile_id) else {
|
||||
return false;
|
||||
};
|
||||
keys.iter().all(|k| acked.contains(k))
|
||||
}
|
||||
|
||||
/// Drop everything remembered for a profile, for use when it is deleted.
|
||||
pub fn forget_profile(profile_id: &str) {
|
||||
let profile_id = profile_id.to_string();
|
||||
update(|prefs| {
|
||||
prefs.fingerprint_acks.remove(&profile_id);
|
||||
prefs.vpn_extension_acks.remove(&profile_id);
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn profile_with(fingerprint: &str) -> BrowserProfile {
|
||||
let mut profile = BrowserProfile {
|
||||
id: uuid::Uuid::new_v4(),
|
||||
browser: "wayfern".into(),
|
||||
..Default::default()
|
||||
};
|
||||
profile.wayfern_config = Some(crate::wayfern_manager::WayfernConfig {
|
||||
fingerprint: Some(fingerprint.to_string()),
|
||||
..Default::default()
|
||||
});
|
||||
profile
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fingerprint_hash_changes_with_the_fingerprint() {
|
||||
let a = profile_with(r#"{"timezone":"Europe/Berlin"}"#);
|
||||
let b = profile_with(r#"{"timezone":"America/New_York"}"#);
|
||||
assert_ne!(fingerprint_hash(&a), fingerprint_hash(&b));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fingerprint_hash_is_stable_for_the_same_fingerprint() {
|
||||
let a = profile_with(r#"{"timezone":"Europe/Berlin"}"#);
|
||||
let b = profile_with(r#"{"timezone":"Europe/Berlin"}"#);
|
||||
assert_eq!(fingerprint_hash(&a), fingerprint_hash(&b));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_profile_without_a_fingerprint_still_hashes() {
|
||||
let profile = BrowserProfile {
|
||||
id: uuid::Uuid::new_v4(),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(!fingerprint_hash(&profile).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn acks_round_trip_and_rearm_on_change() {
|
||||
let _guard = crate::app_dirs::set_test_data_dir(tempfile::tempdir().expect("tempdir").keep());
|
||||
|
||||
let profile = profile_with(r#"{"timezone":"Europe/Berlin"}"#);
|
||||
assert!(!fingerprint_ack_matches(&profile, "http://gw:1"));
|
||||
|
||||
ack_fingerprint(&profile, "http://gw:1");
|
||||
assert!(fingerprint_ack_matches(&profile, "http://gw:1"));
|
||||
|
||||
// Swapping the proxy re-arms the gate: the mismatch the user accepted is
|
||||
// not the mismatch they now have.
|
||||
assert!(!fingerprint_ack_matches(&profile, "http://other:2"));
|
||||
|
||||
// Regenerating the fingerprint re-arms it too.
|
||||
let regenerated = profile_with(r#"{"timezone":"America/New_York"}"#);
|
||||
let mut same_id = regenerated.clone();
|
||||
same_id.id = profile.id;
|
||||
assert!(!fingerprint_ack_matches(&same_id, "http://gw:1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extension_acks_are_per_key() {
|
||||
let _guard = crate::app_dirs::set_test_data_dir(tempfile::tempdir().expect("tempdir").keep());
|
||||
|
||||
let profile_id = uuid::Uuid::new_v4().to_string();
|
||||
let nord = vec!["crx:aaaa".to_string()];
|
||||
let other = vec!["crx:bbbb".to_string()];
|
||||
|
||||
assert!(!extensions_acked(&profile_id, &nord));
|
||||
ack_extensions(&profile_id, &nord);
|
||||
assert!(extensions_acked(&profile_id, &nord));
|
||||
|
||||
// A different extension installed later must warn again.
|
||||
assert!(!extensions_acked(&profile_id, &other));
|
||||
assert!(!extensions_acked(
|
||||
&profile_id,
|
||||
&[nord[0].clone(), other[0].clone()]
|
||||
));
|
||||
|
||||
// Nothing to acknowledge is trivially acknowledged, so an empty scan never
|
||||
// opens the dialog.
|
||||
assert!(extensions_acked(&profile_id, &[]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forgetting_a_profile_clears_both_kinds_of_ack() {
|
||||
let _guard = crate::app_dirs::set_test_data_dir(tempfile::tempdir().expect("tempdir").keep());
|
||||
|
||||
let profile = profile_with(r#"{"timezone":"Europe/Berlin"}"#);
|
||||
let profile_id = profile.id.to_string();
|
||||
ack_fingerprint(&profile, "http://gw:1");
|
||||
ack_extensions(&profile_id, &["crx:aaaa".to_string()]);
|
||||
|
||||
forget_profile(&profile_id);
|
||||
|
||||
assert!(!fingerprint_ack_matches(&profile, "http://gw:1"));
|
||||
assert!(!extensions_acked(&profile_id, &["crx:aaaa".to_string()]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corrupt_prefs_file_is_ignored_rather_than_fatal() {
|
||||
let dir = tempfile::tempdir().expect("tempdir").keep();
|
||||
let _guard = crate::app_dirs::set_test_data_dir(dir.clone());
|
||||
std::fs::create_dir_all(dir.join("data")).unwrap();
|
||||
std::fs::write(
|
||||
dir.join("data").join("launch_gate_prefs.json"),
|
||||
"{ not json",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Must not panic, and must fail closed (nothing acknowledged).
|
||||
let prefs = load();
|
||||
assert!(prefs.fingerprint_acks.is_empty());
|
||||
}
|
||||
}
|
||||
+127
-6
@@ -23,6 +23,16 @@ pub(crate) fn backend_error_with_detail(code: &str, detail: impl std::fmt::Displ
|
||||
serde_json::json!({ "code": code, "params": { "detail": detail.to_string() } }).to_string()
|
||||
}
|
||||
|
||||
/// A VLESS URI Donut cannot use, carrying which part is unsupported so the UI
|
||||
/// can say so instead of implying a typo.
|
||||
pub(crate) fn vless_config_error(error: &crate::xray::XrayError) -> String {
|
||||
serde_json::json!({
|
||||
"code": "VLESS_CONFIG_INVALID",
|
||||
"params": { "reason": error.reason_code(), "detail": error.to_string() }
|
||||
})
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn e2e_automation_enabled() -> bool {
|
||||
#[cfg(feature = "e2e")]
|
||||
{
|
||||
@@ -51,6 +61,7 @@ mod automation_rate_limiter;
|
||||
mod browser;
|
||||
mod browser_runner;
|
||||
mod browser_version_manager;
|
||||
mod cdp_target;
|
||||
mod default_browser;
|
||||
pub mod dns_blocklist;
|
||||
mod downloaded_browsers_registry;
|
||||
@@ -64,6 +75,8 @@ mod geolocation;
|
||||
mod group_manager;
|
||||
mod human_typing;
|
||||
mod ip_utils;
|
||||
mod launch_gate;
|
||||
mod launch_gate_prefs;
|
||||
mod log_redaction;
|
||||
mod platform_browser;
|
||||
mod profile;
|
||||
@@ -72,6 +85,8 @@ mod proxy_manager;
|
||||
pub mod proxy_runner;
|
||||
pub mod proxy_server;
|
||||
pub mod proxy_storage;
|
||||
mod remote_exit;
|
||||
mod remote_handoff;
|
||||
mod remote_session;
|
||||
mod settings_manager;
|
||||
pub mod socks5_local;
|
||||
@@ -80,6 +95,7 @@ mod synchronizer;
|
||||
pub mod traffic_stats;
|
||||
mod wayfern_manager;
|
||||
mod wayfern_terms;
|
||||
mod window_decorations;
|
||||
// mod theme_detector; // removed: theme detection handled in webview via CSS prefers-color-scheme
|
||||
pub mod cloud_auth;
|
||||
mod cloud_errors;
|
||||
@@ -93,6 +109,7 @@ mod tag_manager;
|
||||
mod team_lock;
|
||||
mod version_updater;
|
||||
pub mod vpn;
|
||||
mod vpn_extension_detect;
|
||||
pub mod vpn_worker_runner;
|
||||
pub mod vpn_worker_storage;
|
||||
pub mod xray;
|
||||
@@ -312,6 +329,16 @@ async fn create_stored_proxy(
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate a VLESS URI without touching the network, so the proxy form can
|
||||
/// tell the user their setup is unsupported while they are still editing it
|
||||
/// rather than only after they try to save or launch.
|
||||
#[tauri::command]
|
||||
fn validate_vless_uri(uri: String) -> Result<(), String> {
|
||||
crate::xray::parse_vless_uri(uri.trim())
|
||||
.map(|_| ())
|
||||
.map_err(|error| vless_config_error(&error))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn get_stored_proxies() -> Result<Vec<crate::proxy_manager::StoredProxy>, String> {
|
||||
Ok(crate::proxy_manager::PROXY_MANAGER.get_stored_proxies())
|
||||
@@ -1333,11 +1360,27 @@ async fn get_remote_session(
|
||||
/// so a handful of short launches bills an allowance meant for a hundred.
|
||||
#[tauri::command]
|
||||
async fn stop_remote_session(
|
||||
app_handle: tauri::AppHandle,
|
||||
session_id: String,
|
||||
) -> Result<remote_session::EndRemoteSessionOutcome, String> {
|
||||
remote_session::end_remote_session(&session_id)
|
||||
let outcome = remote_session::end_remote_session(&session_id)
|
||||
.await
|
||||
.map_err(|e| remote_session_error("stop", e))
|
||||
.map_err(|e| remote_session_error("stop", e))?;
|
||||
// The stream normally reports the close, but a stop must not depend on a
|
||||
// socket being up: without this the session's work would sit in cloud storage
|
||||
// with nothing to pull it, and the profile would look ready to open locally
|
||||
// while its local copy still predated the session.
|
||||
remote_session::note_session_stopped(&app_handle, &session_id);
|
||||
Ok(outcome)
|
||||
}
|
||||
|
||||
/// Which profiles cannot be launched locally right now, and why.
|
||||
///
|
||||
/// Backed by the same store the launch gate reads, so the button the UI disables
|
||||
/// and the refusal the backend would produce can never disagree.
|
||||
#[tauri::command]
|
||||
fn get_remote_handoff_states() -> std::collections::HashMap<String, remote_handoff::HandoffState> {
|
||||
remote_handoff::states()
|
||||
}
|
||||
|
||||
/// Subscribe to session transitions. Idempotent.
|
||||
@@ -1426,7 +1469,7 @@ async fn save_cookie_bot_schedule(
|
||||
// Refused here rather than at 02:00: a profile that can never be warmed
|
||||
// should never reach a schedule row, an hour of quota or a leased host.
|
||||
let profile = cookie_bot_profile(&profile_id)?;
|
||||
cookie_bot::bot_precondition(&profile)?;
|
||||
cookie_bot::bot_precondition(&profile, &cookie_bot::exit_reachability(&profile))?;
|
||||
// The frontend sends the user's choices; the profile facts the server refuses
|
||||
// a run on are stamped here, from the profile itself, so a caller cannot
|
||||
// assert them.
|
||||
@@ -1486,7 +1529,8 @@ async fn run_cookie_bot_now(
|
||||
profile_id: String,
|
||||
max_minutes: Option<u32>,
|
||||
) -> Result<cookie_bot::CookieBotRunStarted, String> {
|
||||
cookie_bot::bot_precondition(&cookie_bot_profile(&profile_id)?)?;
|
||||
let profile = cookie_bot_profile(&profile_id)?;
|
||||
cookie_bot::bot_precondition(&profile, &cookie_bot::exit_reachability(&profile))?;
|
||||
cookie_bot::run_now(&profile_id, max_minutes)
|
||||
.await
|
||||
.map_err(|e| cookie_bot_error("run start", e))
|
||||
@@ -1748,7 +1792,12 @@ pub fn run_with_builder(
|
||||
.with_state_flags(
|
||||
tauri_plugin_window_state::StateFlags::all()
|
||||
& !tauri_plugin_window_state::StateFlags::VISIBLE
|
||||
& !tauri_plugin_window_state::StateFlags::FULLSCREEN,
|
||||
& !tauri_plugin_window_state::StateFlags::FULLSCREEN
|
||||
// Whether the window is decorated is decided per-session by
|
||||
// `window_decorations::use_client_side_decorations()`, not by what
|
||||
// a previous run saved. Restoring it would put a real titlebar back
|
||||
// on top of the one the app draws — or strip both.
|
||||
& !tauri_plugin_window_state::StateFlags::DECORATIONS,
|
||||
)
|
||||
.build(),
|
||||
);
|
||||
@@ -1788,9 +1837,21 @@ pub fn run_with_builder(
|
||||
None => win_builder,
|
||||
};
|
||||
|
||||
// The app draws its own titlebar. macOS keeps the native one and makes
|
||||
// it transparent (below); Windows and Linux drop decorations entirely and
|
||||
// render their own controls.
|
||||
#[cfg(target_os = "windows")]
|
||||
let win_builder = win_builder.decorations(false);
|
||||
|
||||
// Linux opts out on the one configuration where dropping decorations can
|
||||
// make things worse rather than better — see `use_client_side_decorations`.
|
||||
#[cfg(target_os = "linux")]
|
||||
let win_builder = if window_decorations::use_client_side_decorations() {
|
||||
win_builder.decorations(false)
|
||||
} else {
|
||||
win_builder
|
||||
};
|
||||
|
||||
#[allow(unused_variables)]
|
||||
let window = win_builder.build().unwrap();
|
||||
|
||||
@@ -1823,6 +1884,44 @@ pub fn run_with_builder(
|
||||
});
|
||||
}
|
||||
|
||||
// Publish the desktop's titlebar button layout to the frontend. Runs
|
||||
// here because `setup` is the GTK main thread, which `gtk::Settings`
|
||||
// requires.
|
||||
//
|
||||
// The decorated state is logged alongside it: "my window has no titlebar"
|
||||
// and "my window has two titlebars" are both reports that hinge on this
|
||||
// one boolean, and it is otherwise invisible after the fact.
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
log::info!(
|
||||
"Linux window decorations: server-side = {:?}",
|
||||
window.is_decorated()
|
||||
);
|
||||
|
||||
// tao makes the window visible before it clears the decorations, so it
|
||||
// is realized while still framed and the frame extents come out of the
|
||||
// size we asked for (a requested 880x500 arrives noticeably smaller).
|
||||
//
|
||||
// Only correct that on a first run. Once window-state has geometry
|
||||
// saved, that geometry is the user's and has already been restored —
|
||||
// re-applying the default here would move and resize their window on
|
||||
// every launch, and the plugin would then persist the reset.
|
||||
let has_saved_geometry = app
|
||||
.path()
|
||||
.app_config_dir()
|
||||
.map(|dir| dir.join(".window-state.json").exists())
|
||||
.unwrap_or(false);
|
||||
if window_decorations::use_client_side_decorations() && !has_saved_geometry {
|
||||
if let Err(e) = window.set_size(tauri::LogicalSize::new(880.0, 500.0)) {
|
||||
log::warn!("Failed to re-apply the window size after dropping decorations: {e}");
|
||||
}
|
||||
if let Err(e) = window.center() {
|
||||
log::warn!("Failed to re-center the window after dropping decorations: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
window_decorations::init(app.handle());
|
||||
|
||||
// Set transparent titlebar for macOS
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
@@ -2535,6 +2634,12 @@ pub fn run_with_builder(
|
||||
// and would only be refused on a loop; the frontend starts it again
|
||||
// through `start_remote_session_events` once the user signs in.
|
||||
remote_session::start_session_events(app_handle_cloud.clone());
|
||||
|
||||
// A session that finished while this machine was shut, or whose pull
|
||||
// ran out of retries offline, leaves a profile blocked from launching
|
||||
// with its work still in cloud storage. Signing in is the first moment
|
||||
// that pull can succeed, so it is where it is retried.
|
||||
remote_handoff::resume_pending_pulls(&app_handle_cloud);
|
||||
}
|
||||
cloud_auth::CloudAuthManager::start_sync_token_refresh_loop(app_handle_cloud).await;
|
||||
});
|
||||
@@ -2649,8 +2754,11 @@ pub fn run_with_builder(
|
||||
clear_all_traffic_stats,
|
||||
clear_profile_traffic_stats,
|
||||
get_traffic_stats_for_period,
|
||||
fingerprint_consistency::check_profile_fingerprint_consistency,
|
||||
fingerprint_consistency::match_profile_fingerprint_to_exit,
|
||||
launch_gate::get_profile_pre_launch_checks,
|
||||
launch_gate::ack_launch_gate,
|
||||
window_decorations::get_window_decoration_layout,
|
||||
validate_vless_uri,
|
||||
get_sync_settings,
|
||||
save_sync_settings,
|
||||
set_profile_sync_mode,
|
||||
@@ -2731,6 +2839,7 @@ pub fn run_with_builder(
|
||||
list_remote_sessions,
|
||||
get_remote_session,
|
||||
stop_remote_session,
|
||||
get_remote_handoff_states,
|
||||
start_remote_session_events,
|
||||
stop_remote_session_events,
|
||||
get_remote_session_events_status,
|
||||
@@ -2746,6 +2855,14 @@ pub fn run_with_builder(
|
||||
get_cookie_bot_presets,
|
||||
get_remote_hours_quota,
|
||||
get_cookie_bot_usage,
|
||||
// Defined in `cookie_bot.rs` rather than here because they carry no local
|
||||
// precondition — there is no profile to look up and no `bot_precondition`
|
||||
// to apply. Unregistered they are unreachable, and the saved-list tab
|
||||
// fails at runtime with "command not found" rather than at build time.
|
||||
cookie_bot::get_cookie_bot_user_templates,
|
||||
cookie_bot::create_cookie_bot_user_template,
|
||||
cookie_bot::update_cookie_bot_user_template,
|
||||
cookie_bot::delete_cookie_bot_user_template,
|
||||
// Profile password commands
|
||||
set_profile_password,
|
||||
change_profile_password,
|
||||
@@ -2809,6 +2926,10 @@ mod tests {
|
||||
crate::remote_session::EVENT_SESSION_STATE,
|
||||
crate::remote_session::EVENT_SESSION_SNAPSHOT,
|
||||
crate::remote_session::EVENT_STREAM_STATUS,
|
||||
// The launch gate is emitted from the same place for the same reason: a
|
||||
// Run button that does not hear about it stays enabled over a profile the
|
||||
// backend will refuse, or over unsynced work it must not open.
|
||||
crate::remote_handoff::EVENT_REMOTE_HANDOFF,
|
||||
] {
|
||||
assert!(
|
||||
client.contains(&format!("\"{event}\"")),
|
||||
|
||||
+308
-428
File diff suppressed because it is too large
Load Diff
@@ -41,7 +41,7 @@ fn is_kept(name: &str) -> bool {
|
||||
/// step since it regenerates — leaves a populated `Default/` without it. Such a
|
||||
/// directory would then be treated as stale and removed wholesale, destroying the
|
||||
/// Extensions and Bookmarks this feature exists to preserve.
|
||||
fn is_profile_dir_name(name: &str) -> bool {
|
||||
pub(crate) fn is_profile_dir_name(name: &str) -> bool {
|
||||
matches!(name, "Default" | "Guest Profile" | "System Profile")
|
||||
|| name
|
||||
.strip_prefix("Profile ")
|
||||
|
||||
@@ -479,6 +479,10 @@ impl ProfileManager {
|
||||
);
|
||||
}
|
||||
|
||||
// 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);
|
||||
|
||||
// Remember sync mode before deleting local files
|
||||
let was_sync_enabled = profile.is_sync_enabled();
|
||||
|
||||
|
||||
@@ -464,10 +464,10 @@ impl ProxyManager {
|
||||
.as_deref()
|
||||
.filter(|uri| !uri.is_empty())
|
||||
.ok_or_else(|| crate::backend_error("VLESS_CONFIG_INVALID"))?;
|
||||
let parsed = crate::xray::parse_vless_uri(uri)
|
||||
.map_err(|error| crate::backend_error_with_detail("VLESS_CONFIG_INVALID", error))?;
|
||||
let parsed =
|
||||
crate::xray::parse_vless_uri(uri).map_err(|error| crate::vless_config_error(&error))?;
|
||||
let canonical_uri = crate::xray::export_vless_uri(&parsed.config, parsed.name.as_deref())
|
||||
.map_err(|error| crate::backend_error_with_detail("VLESS_CONFIG_INVALID", error))?;
|
||||
.map_err(|error| crate::vless_config_error(&error))?;
|
||||
|
||||
proxy_settings.proxy_type = "vless".to_string();
|
||||
proxy_settings.host = parsed.config.address;
|
||||
@@ -1152,6 +1152,21 @@ impl ProxyManager {
|
||||
url
|
||||
}
|
||||
|
||||
/// Proxy URL for a diagnostic probe made by the app itself (reqwest), as
|
||||
/// opposed to `build_proxy_url`, which feeds the browser.
|
||||
///
|
||||
/// SOCKS5 becomes `socks5h://` so the probe endpoint's hostname resolves at
|
||||
/// the exit rather than on this machine. Resolving locally would leak the
|
||||
/// real DNS and, behind a split-horizon resolver, can reach a different host
|
||||
/// than the browser would.
|
||||
pub fn build_probe_proxy_url(proxy_settings: &ProxySettings) -> String {
|
||||
let url = Self::build_proxy_url(proxy_settings);
|
||||
if proxy_settings.proxy_type.eq_ignore_ascii_case("socks5") {
|
||||
return url.replacen("socks5://", "socks5h://", 1);
|
||||
}
|
||||
url
|
||||
}
|
||||
|
||||
// Check if a proxy is valid by routing through a temporary donut-proxy process.
|
||||
// This tests the exact same code path the browser uses.
|
||||
// Falls back to direct reqwest check if the proxy worker fails to start.
|
||||
|
||||
@@ -0,0 +1,511 @@
|
||||
//! Whether a profile's exit node can be reached from somewhere that is not this
|
||||
//! machine.
|
||||
//!
|
||||
//! Remote execution — an interactive remote session or a Cookie Bot night — runs
|
||||
//! the browser on a leased fleet host, but the PROFILE (and its proxy, and its
|
||||
//! VPN config) is pulled from the user's sync namespace. Nothing in that
|
||||
//! handover rewrites addresses, so a proxy recorded as `127.0.0.1:8080` arrives
|
||||
//! on the fleet host meaning *the fleet host's own loopback*.
|
||||
//!
|
||||
//! That is the whole bug this module exists to prevent. The server already
|
||||
//! refuses a profile with NO exit (`proxy_required`), because a night browsed
|
||||
//! from the fleet's datacenter address damages an identity rather than building
|
||||
//! it — but it was asking whether an exit was *configured*, never whether it was
|
||||
//! *reachable*. A local proxy satisfied the first question and failed the
|
||||
//! second, so the run was accepted, dispatched, and burned a leased host either
|
||||
//! erroring out or (worse) egressing direct from the datacenter: exactly the
|
||||
//! outcome `proxy_required` exists to stop, reached by the one route it did not
|
||||
//! check.
|
||||
//!
|
||||
//! Local proxies are not an exotic case. A local MITM proxy, an SSH tunnel, a
|
||||
//! locally-run SOCKS client and Donut's own VLESS support all present to the
|
||||
//! browser as `127.0.0.1:<port>`.
|
||||
//!
|
||||
//! This module is the single answer, shared by every caller, and it FAILS
|
||||
//! CLOSED: anything it cannot parse is reported as unreachable. Refusing a
|
||||
//! working setup costs the user one support question; accepting a broken one
|
||||
//! costs a burned hour and a damaged profile identity.
|
||||
|
||||
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
|
||||
|
||||
/// Whether a leased fleet host could dial this profile's exit.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ExitReachability {
|
||||
/// No proxy and no VPN. The caller's existing "no exit" refusal applies.
|
||||
None,
|
||||
/// An address a host elsewhere on the internet can reach.
|
||||
Remote,
|
||||
/// An address that only means anything on this machine or this LAN.
|
||||
LocalOnly {
|
||||
/// The offending host, for a message the user can act on.
|
||||
host: String,
|
||||
/// Which part of the config it came from: "proxy" or "VPN".
|
||||
source: &'static str,
|
||||
},
|
||||
/// Configured, but this code could not determine the host.
|
||||
///
|
||||
/// Treated as unreachable by [`ExitReachability::is_remote`] — see the
|
||||
/// fail-closed note in the module docs.
|
||||
Unknown {
|
||||
reason: String,
|
||||
source: &'static str,
|
||||
},
|
||||
}
|
||||
|
||||
impl ExitReachability {
|
||||
/// Whether remote execution may proceed.
|
||||
pub fn is_remote(&self) -> bool {
|
||||
matches!(self, ExitReachability::Remote)
|
||||
}
|
||||
|
||||
/// A one-line reason for a refusal, or None when there is nothing to refuse.
|
||||
pub fn refusal_detail(&self) -> Option<String> {
|
||||
match self {
|
||||
ExitReachability::Remote | ExitReachability::None => None,
|
||||
ExitReachability::LocalOnly { host, source } => Some(format!(
|
||||
"The {source} for this profile points at {host}, which only exists on this computer. \
|
||||
Remote runs happen on our hosts and cannot reach it."
|
||||
)),
|
||||
ExitReachability::Unknown { reason, source } => Some(format!(
|
||||
"The {source} for this profile could not be read ({reason}), so we cannot confirm a \
|
||||
remote host could use it."
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a hostname or IP literal is reachable from another machine.
|
||||
///
|
||||
/// Rejects, in order: empty/whitespace, unparsable-as-either, and every IP
|
||||
/// range that is scoped to a machine or a private network. Hostnames that are
|
||||
/// not IP literals are accepted unless they use a name suffix that is
|
||||
/// definitionally local — a public DNS name cannot be validated here without a
|
||||
/// lookup, and doing a lookup would make this impure and slow on a hot path.
|
||||
pub fn host_is_remote_reachable(host: &str) -> bool {
|
||||
let host = normalize_host(host);
|
||||
if host.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
if let Ok(ip) = host.parse::<IpAddr>() {
|
||||
return ip_is_remote_reachable(ip);
|
||||
}
|
||||
|
||||
let lower = host.to_ascii_lowercase();
|
||||
|
||||
// `localhost` and anything under it resolve to loopback everywhere.
|
||||
if lower == "localhost" || lower.ends_with(".localhost") {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Suffixes reserved for local/private name resolution (RFC 6762 mDNS, RFC
|
||||
// 8375, and the names router vendors hand out on a LAN). A fleet host
|
||||
// resolving one of these gets its own network's answer, not the user's.
|
||||
const LOCAL_SUFFIXES: [&str; 7] = [
|
||||
".local",
|
||||
".localdomain",
|
||||
".internal",
|
||||
".home",
|
||||
".home.arpa",
|
||||
".lan",
|
||||
".intranet",
|
||||
];
|
||||
if LOCAL_SUFFIXES.iter().any(|suffix| lower.ends_with(suffix)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// A bare single-label name ("my-proxy", "router") is only resolvable through
|
||||
// a local search domain, so it is no more use to a fleet host than `.local`.
|
||||
if !lower.contains('.') {
|
||||
return false;
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
/// Whether an IP literal is routable from another machine.
|
||||
fn ip_is_remote_reachable(ip: IpAddr) -> bool {
|
||||
match ip {
|
||||
IpAddr::V4(v4) => ipv4_is_remote_reachable(v4),
|
||||
IpAddr::V6(v6) => ipv6_is_remote_reachable(v6),
|
||||
}
|
||||
}
|
||||
|
||||
fn ipv4_is_remote_reachable(ip: Ipv4Addr) -> bool {
|
||||
// `is_private`/`is_loopback`/`is_link_local` are stable; the rest are not, so
|
||||
// the remaining ranges are spelled out rather than gated behind a nightly
|
||||
// feature.
|
||||
if ip.is_loopback() || ip.is_private() || ip.is_link_local() || ip.is_unspecified() {
|
||||
return false;
|
||||
}
|
||||
if ip.is_broadcast() || ip.is_multicast() || ip.is_documentation() {
|
||||
return false;
|
||||
}
|
||||
let [a, b, ..] = ip.octets();
|
||||
// 100.64.0.0/10 — carrier-grade NAT (RFC 6598). Reachable inside one
|
||||
// carrier's network and nowhere else.
|
||||
if a == 100 && (64..128).contains(&b) {
|
||||
return false;
|
||||
}
|
||||
// 0.0.0.0/8 "this network", and 240.0.0.0/4 reserved.
|
||||
if a == 0 || a >= 240 {
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn ipv6_is_remote_reachable(ip: Ipv6Addr) -> bool {
|
||||
if ip.is_loopback() || ip.is_unspecified() || ip.is_multicast() {
|
||||
return false;
|
||||
}
|
||||
// An IPv4 address wearing an IPv6 hat is still that IPv4 address — classify
|
||||
// it as one, or `::ffff:127.0.0.1` walks straight through.
|
||||
if let Some(v4) = ip.to_ipv4_mapped() {
|
||||
return ipv4_is_remote_reachable(v4);
|
||||
}
|
||||
if let Some(v4) = ip.to_ipv4() {
|
||||
return ipv4_is_remote_reachable(v4);
|
||||
}
|
||||
let segments = ip.segments();
|
||||
// fc00::/7 unique-local, fe80::/10 link-local.
|
||||
if (segments[0] & 0xfe00) == 0xfc00 {
|
||||
return false;
|
||||
}
|
||||
if (segments[0] & 0xffc0) == 0xfe80 {
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Strip the decoration a host can arrive wrapped in: whitespace, `[...]`
|
||||
/// around an IPv6 literal, a trailing dot on an FQDN, and any `user@` or
|
||||
/// `:port` that came along from a URI.
|
||||
fn normalize_host(raw: &str) -> String {
|
||||
let mut host = raw.trim();
|
||||
if host.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
// `user:pass@host` — take what follows the LAST '@', since a password may
|
||||
// itself contain one.
|
||||
if let Some(at) = host.rfind('@') {
|
||||
host = &host[at + 1..];
|
||||
}
|
||||
|
||||
// Bracketed IPv6, optionally with a port: `[::1]:1080`.
|
||||
if let Some(stripped) = host.strip_prefix('[') {
|
||||
if let Some(end) = stripped.find(']') {
|
||||
return stripped[..end].trim().to_string();
|
||||
}
|
||||
return stripped.trim().to_string();
|
||||
}
|
||||
|
||||
// `host:port`, but only when there is exactly one colon — more than one means
|
||||
// a bare IPv6 literal, whose colons are part of the address.
|
||||
if host.matches(':').count() == 1 {
|
||||
if let Some((left, _port)) = host.split_once(':') {
|
||||
host = left;
|
||||
}
|
||||
}
|
||||
|
||||
host.trim().trim_end_matches('.').to_string()
|
||||
}
|
||||
|
||||
/// The host a VLESS URI actually dials.
|
||||
///
|
||||
/// Load-bearing because of an asymmetry that is easy to get backwards: a VLESS
|
||||
/// proxy presents to the browser as `127.0.0.1:<port>` — Donut runs a local xray
|
||||
/// worker and points the browser at it — but the address that decides whether
|
||||
/// anyone else could use this config is the SERVER inside the URI. The local
|
||||
/// port is an implementation detail of this machine; the URI is the exit.
|
||||
pub fn vless_uri_host(uri: &str) -> Option<String> {
|
||||
let rest = uri.trim().strip_prefix("vless://")?;
|
||||
// Cut the fragment (`#label`) and query (`?type=...`) before looking for the
|
||||
// authority — either may contain '@' or ':'.
|
||||
let rest = rest.split('#').next()?;
|
||||
let rest = rest.split('?').next()?;
|
||||
// `uuid@host:port/...`
|
||||
let authority = rest.split('/').next()?;
|
||||
let host_port = authority
|
||||
.rsplit_once('@')
|
||||
.map(|(_, h)| h)
|
||||
.unwrap_or(authority);
|
||||
let host = normalize_host(host_port);
|
||||
if host.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(host)
|
||||
}
|
||||
}
|
||||
|
||||
/// The exit host a stored proxy represents, as a remote host would have to dial
|
||||
/// it.
|
||||
pub fn proxy_exit_host(settings: &crate::browser::ProxySettings) -> Result<String, String> {
|
||||
if settings.proxy_type.eq_ignore_ascii_case("vless") {
|
||||
let uri = settings
|
||||
.vless_uri
|
||||
.as_deref()
|
||||
.filter(|uri| !uri.trim().is_empty())
|
||||
.ok_or_else(|| "VLESS proxy has no server URI".to_string())?;
|
||||
return vless_uri_host(uri).ok_or_else(|| "VLESS server URI is malformed".to_string());
|
||||
}
|
||||
|
||||
let host = normalize_host(&settings.host);
|
||||
if host.is_empty() {
|
||||
return Err("proxy has no host".to_string());
|
||||
}
|
||||
Ok(host)
|
||||
}
|
||||
|
||||
/// Classify a stored proxy.
|
||||
pub fn classify_proxy(settings: &crate::browser::ProxySettings) -> ExitReachability {
|
||||
match proxy_exit_host(settings) {
|
||||
Err(reason) => ExitReachability::Unknown {
|
||||
reason,
|
||||
source: "proxy",
|
||||
},
|
||||
Ok(host) => {
|
||||
if host_is_remote_reachable(&host) {
|
||||
ExitReachability::Remote
|
||||
} else {
|
||||
ExitReachability::LocalOnly {
|
||||
host,
|
||||
source: "proxy",
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Classify a WireGuard peer endpoint (`host:port`).
|
||||
pub fn classify_wireguard_endpoint(peer_endpoint: &str) -> ExitReachability {
|
||||
let host = normalize_host(peer_endpoint);
|
||||
if host.is_empty() {
|
||||
return ExitReachability::Unknown {
|
||||
reason: "VPN config has no peer endpoint".to_string(),
|
||||
source: "VPN",
|
||||
};
|
||||
}
|
||||
if host_is_remote_reachable(&host) {
|
||||
ExitReachability::Remote
|
||||
} else {
|
||||
ExitReachability::LocalOnly {
|
||||
host,
|
||||
source: "VPN",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::browser::ProxySettings;
|
||||
|
||||
fn proxy(proxy_type: &str, host: &str) -> ProxySettings {
|
||||
ProxySettings {
|
||||
proxy_type: proxy_type.to_string(),
|
||||
host: host.to_string(),
|
||||
port: 8080,
|
||||
username: None,
|
||||
password: None,
|
||||
vless_uri: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loopback_in_every_spelling_is_local() {
|
||||
// The literal case the bug was reported for, plus the spellings that reach
|
||||
// the same place. `::ffff:127.0.0.1` is the one a naive IPv6 check misses.
|
||||
for host in [
|
||||
"127.0.0.1",
|
||||
"127.1.2.3",
|
||||
"localhost",
|
||||
"LOCALHOST",
|
||||
"foo.localhost",
|
||||
"::1",
|
||||
"[::1]",
|
||||
"::ffff:127.0.0.1",
|
||||
"0.0.0.0",
|
||||
"::",
|
||||
] {
|
||||
assert!(
|
||||
!host_is_remote_reachable(host),
|
||||
"{host} should not be remote-reachable"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn private_and_carrier_ranges_are_local() {
|
||||
for host in [
|
||||
"10.0.0.1",
|
||||
"192.168.1.1",
|
||||
"172.16.0.1",
|
||||
"172.31.255.254",
|
||||
"169.254.1.1", // link-local / APIPA
|
||||
"100.64.0.1", // CGNAT
|
||||
"100.127.255.1",
|
||||
"fd00::1", // unique-local
|
||||
"fe80::1", // link-local
|
||||
"240.0.0.1",
|
||||
"0.1.2.3",
|
||||
] {
|
||||
assert!(
|
||||
!host_is_remote_reachable(host),
|
||||
"{host} should not be remote-reachable"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn public_addresses_and_names_are_reachable() {
|
||||
for host in [
|
||||
"1.1.1.1",
|
||||
"8.8.8.8",
|
||||
"172.15.0.1", // just outside 172.16/12
|
||||
"172.32.0.1",
|
||||
"100.63.255.255", // just outside 100.64/10
|
||||
"100.128.0.1",
|
||||
"2606:4700:4700::1111",
|
||||
"proxy.example.com",
|
||||
"gate.smartproxy.net.",
|
||||
"residential.example.co.uk",
|
||||
] {
|
||||
assert!(
|
||||
host_is_remote_reachable(host),
|
||||
"{host} should be remote-reachable"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lan_only_names_are_local() {
|
||||
// A fleet host resolving these gets ITS network's answer, not the user's —
|
||||
// which is worse than failing, because it may well succeed against
|
||||
// something unrelated.
|
||||
for host in [
|
||||
"my-proxy", // single label: needs a search domain
|
||||
"router.local",
|
||||
"nas.home.arpa",
|
||||
"proxy.lan",
|
||||
"box.internal",
|
||||
"server.localdomain",
|
||||
"gateway.intranet",
|
||||
] {
|
||||
assert!(
|
||||
!host_is_remote_reachable(host),
|
||||
"{host} should not be remote-reachable"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn host_port_and_credentials_are_stripped_before_classifying() {
|
||||
assert!(!host_is_remote_reachable("127.0.0.1:8080"));
|
||||
assert!(!host_is_remote_reachable("user:pass@127.0.0.1:8080"));
|
||||
assert!(!host_is_remote_reachable("[::1]:1080"));
|
||||
assert!(host_is_remote_reachable("user:p@ss@proxy.example.com:8080"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_vless_proxy_is_judged_by_its_server_not_its_local_port() {
|
||||
// THE asymmetry. Donut points the browser at a local xray worker, so the
|
||||
// browser-facing address of every VLESS proxy is 127.0.0.1 — but the stored
|
||||
// config names a real server, and that is what a fleet host would dial.
|
||||
// Classifying VLESS off `settings.host` would refuse every VLESS profile.
|
||||
let mut settings = proxy("vless", "127.0.0.1");
|
||||
settings.vless_uri =
|
||||
Some("vless://6d6e21a1-4829-4d2b-bc7f-1b25707b61e4@vpn.example.com:443?type=tcp#node".into());
|
||||
|
||||
assert_eq!(classify_proxy(&settings), ExitReachability::Remote);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_vless_uri_pointing_at_loopback_is_still_local() {
|
||||
let mut settings = proxy("vless", "127.0.0.1");
|
||||
settings.vless_uri = Some("vless://uuid@127.0.0.1:443?type=tcp".into());
|
||||
|
||||
assert_eq!(
|
||||
classify_proxy(&settings),
|
||||
ExitReachability::LocalOnly {
|
||||
host: "127.0.0.1".to_string(),
|
||||
source: "proxy",
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vless_host_parsing_survives_query_and_fragment() {
|
||||
assert_eq!(
|
||||
vless_uri_host("vless://uuid@example.com:443?sni=a@b.com&x=1#my@label"),
|
||||
Some("example.com".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
vless_uri_host("vless://uuid@[2606:4700::1111]:443?type=ws"),
|
||||
Some("2606:4700::1111".to_string())
|
||||
);
|
||||
assert_eq!(vless_uri_host("not-a-vless-uri"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unreadable_config_fails_closed() {
|
||||
// Unknown must never be treated as usable: the point of the check is that
|
||||
// we could not confirm reachability, and guessing "yes" reintroduces the
|
||||
// exact failure it prevents.
|
||||
let mut settings = proxy("vless", "");
|
||||
settings.vless_uri = None;
|
||||
let verdict = classify_proxy(&settings);
|
||||
|
||||
assert!(matches!(verdict, ExitReachability::Unknown { .. }));
|
||||
assert!(!verdict.is_remote());
|
||||
assert!(verdict.refusal_detail().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ordinary_proxies_are_classified_by_host() {
|
||||
assert_eq!(
|
||||
classify_proxy(&proxy("socks5", "gate.example.com")),
|
||||
ExitReachability::Remote
|
||||
);
|
||||
assert_eq!(
|
||||
classify_proxy(&proxy("http", "192.168.0.10")),
|
||||
ExitReachability::LocalOnly {
|
||||
host: "192.168.0.10".to_string(),
|
||||
source: "proxy",
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wireguard_endpoints_are_classified_by_their_peer() {
|
||||
assert_eq!(
|
||||
classify_wireguard_endpoint("vpn.example.com:51820"),
|
||||
ExitReachability::Remote
|
||||
);
|
||||
assert_eq!(
|
||||
classify_wireguard_endpoint("10.0.0.1:51820"),
|
||||
ExitReachability::LocalOnly {
|
||||
host: "10.0.0.1".to_string(),
|
||||
source: "VPN",
|
||||
}
|
||||
);
|
||||
assert!(matches!(
|
||||
classify_wireguard_endpoint(" "),
|
||||
ExitReachability::Unknown { .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_remote_permits_a_run() {
|
||||
assert!(ExitReachability::Remote.is_remote());
|
||||
assert!(!ExitReachability::None.is_remote());
|
||||
assert!(!ExitReachability::LocalOnly {
|
||||
host: "127.0.0.1".into(),
|
||||
source: "proxy"
|
||||
}
|
||||
.is_remote());
|
||||
// `None` has no detail: the caller's existing "no exit at all" refusal is
|
||||
// the better message, and two refusals for one condition read as a bug.
|
||||
assert!(ExitReachability::None.refusal_detail().is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,543 @@
|
||||
//! What a remote session owes this machine, and the gate that collects it.
|
||||
//!
|
||||
//! A profile that runs on the leased fleet is written by the host, not here.
|
||||
//! The host pushes it back to cloud storage when the session ends, and until
|
||||
//! this machine has pulled that push, the local profile directory is a stale
|
||||
//! copy of something that has moved on.
|
||||
//!
|
||||
//! Opening that stale copy is not a cosmetic problem, it is destructive. The
|
||||
//! local browser writes, every local mtime jumps past the host's push, and the
|
||||
//! next ordinary sync therefore reads local as the newer side: it uploads the
|
||||
//! pre-session files and puts everything the host wrote into
|
||||
//! `files_to_delete_remote`. A night of cookie warming is deleted with no error
|
||||
//! anywhere. Nothing in the manifest can prevent this, because by then the local
|
||||
//! clock genuinely IS later.
|
||||
//!
|
||||
//! So the gate is here instead, and it is deliberately a LOCAL, per-machine
|
||||
//! fact rather than a synced one. "This computer has not yet pulled" is true of
|
||||
//! one computer at a time; putting it in the profile's synced metadata would let
|
||||
//! a second device that had already pulled clear it for a first device that had
|
||||
//! not.
|
||||
//!
|
||||
//! Two states, and the difference matters to the user:
|
||||
//!
|
||||
//! - [`HandoffState::Running`]: a session is live on the fleet. The profile lock
|
||||
//! is held server-side, so a launch would be refused anyway; this makes the
|
||||
//! refusal instant and legible instead of a round trip and a raw string.
|
||||
//! - [`HandoffState::PendingSync`]: the session is over, the lock is released,
|
||||
//! and the work is sitting in cloud storage. This is the window that used to
|
||||
//! be wide open.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::RwLock;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Emitted whenever the set of gated profiles changes.
|
||||
pub const EVENT_REMOTE_HANDOFF: &str = "remote-handoff-changed";
|
||||
|
||||
/// Attempts at pulling a finished session's work before giving up for now.
|
||||
///
|
||||
/// The entry survives a failure, so "giving up" only means this burst stops;
|
||||
/// the next stream event, app start or manual sync tries again. What the retries
|
||||
/// buy is the common case: the profile lock is released server-side a moment
|
||||
/// before this machine's cached copy of it expires, and a single attempt would
|
||||
/// hit `Skipped("profile is locked elsewhere")` and leave the user blocked for
|
||||
/// no reason.
|
||||
const PULL_ATTEMPTS: u32 = 5;
|
||||
|
||||
/// Delay before the second pull attempt. Doubles, capped by [`PULL_RETRY_MAX`].
|
||||
const PULL_RETRY_BASE: Duration = Duration::from_secs(2);
|
||||
|
||||
/// Ceiling on the pull backoff. Above the 30s profile-lock refresh, so a run of
|
||||
/// attempts is guaranteed to span at least one refresh of the lock cache.
|
||||
const PULL_RETRY_MAX: Duration = Duration::from_secs(45);
|
||||
|
||||
/// Where a profile stands with respect to the fleet.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum HandoffState {
|
||||
/// A session is live on the fleet right now.
|
||||
Running,
|
||||
/// A session has finished and its work has not been pulled down yet.
|
||||
PendingSync,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct HandoffEntry {
|
||||
session_id: String,
|
||||
state: HandoffState,
|
||||
/// When this entry last changed, unix seconds. Diagnostics only; the gate
|
||||
/// never expires on its own, because an entry that timed out would reopen
|
||||
/// exactly the window it exists to close.
|
||||
observed_at: u64,
|
||||
}
|
||||
|
||||
type Store = HashMap<String, HandoffEntry>;
|
||||
|
||||
static STORE: RwLock<Option<Store>> = RwLock::new(None);
|
||||
|
||||
fn store_path() -> std::path::PathBuf {
|
||||
crate::app_dirs::settings_dir().join("remote_handoff.json")
|
||||
}
|
||||
|
||||
fn load_from_disk() -> Store {
|
||||
let path = store_path();
|
||||
let Ok(bytes) = std::fs::read(&path) else {
|
||||
return Store::new();
|
||||
};
|
||||
match serde_json::from_slice::<Store>(&bytes) {
|
||||
Ok(store) => store,
|
||||
Err(e) => {
|
||||
// Losing the file means losing the gate, so say so loudly rather than
|
||||
// starting empty and quietly permitting a launch over pending work.
|
||||
log::error!(
|
||||
"Could not read {}: {e}. Profiles with unsynced remote work will not be gated until \
|
||||
the next session event.",
|
||||
path.display()
|
||||
);
|
||||
Store::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn persist(store: &Store) {
|
||||
let path = store_path();
|
||||
if let Some(parent) = path.parent() {
|
||||
if let Err(e) = std::fs::create_dir_all(parent) {
|
||||
log::warn!("Could not create {}: {e}", parent.display());
|
||||
return;
|
||||
}
|
||||
}
|
||||
match serde_json::to_vec_pretty(store) {
|
||||
Ok(bytes) => {
|
||||
if let Err(e) = crate::app_dirs::write_owner_only(&path, &bytes) {
|
||||
log::warn!("Could not write {}: {e}", path.display());
|
||||
}
|
||||
}
|
||||
Err(e) => log::warn!("Could not encode the remote handoff store: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn with_store<T>(f: impl FnOnce(&mut Store) -> T) -> T {
|
||||
let mut guard = STORE
|
||||
.write()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let store = guard.get_or_insert_with(load_from_disk);
|
||||
f(store)
|
||||
}
|
||||
|
||||
/// Apply a mutation, and persist plus announce it only if it changed anything.
|
||||
fn mutate(f: impl FnOnce(&mut Store) -> bool) {
|
||||
let changed = with_store(|store| {
|
||||
let changed = f(store);
|
||||
if changed {
|
||||
persist(store);
|
||||
}
|
||||
changed
|
||||
});
|
||||
if changed {
|
||||
announce();
|
||||
}
|
||||
}
|
||||
|
||||
fn now_secs() -> u64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn announce() {
|
||||
let _ = crate::events::emit(EVENT_REMOTE_HANDOFF, states());
|
||||
}
|
||||
|
||||
/// Every gated profile, for the UI and for one-shot reads.
|
||||
pub fn states() -> HashMap<String, HandoffState> {
|
||||
with_store(|store| {
|
||||
store
|
||||
.iter()
|
||||
.map(|(profile_id, entry)| (profile_id.clone(), entry.state))
|
||||
.collect()
|
||||
})
|
||||
}
|
||||
|
||||
/// Where this profile stands, if it is gated at all.
|
||||
pub fn state_for(profile_id: &str) -> Option<HandoffState> {
|
||||
with_store(|store| store.get(profile_id).map(|entry| entry.state))
|
||||
}
|
||||
|
||||
/// The session currently holding this profile on the fleet, if any.
|
||||
///
|
||||
/// Answers for a `provisioning` session too, which the drivable-session index
|
||||
/// deliberately does not. Stopping a session that has not finished coming up is
|
||||
/// the single most common thing a user does after starting one by mistake, and
|
||||
/// an index built for "where do I attach a CDP client" cannot serve it.
|
||||
pub fn running_session_for_profile(profile_id: &str) -> Option<String> {
|
||||
with_store(|store| {
|
||||
store
|
||||
.get(profile_id)
|
||||
.filter(|entry| entry.state == HandoffState::Running)
|
||||
.map(|entry| entry.session_id.clone())
|
||||
})
|
||||
}
|
||||
|
||||
/// Which profile a session belongs to, as this machine last recorded it.
|
||||
///
|
||||
/// The backend's stop reply carries a session id and a duration but no profile,
|
||||
/// and the caller that pressed stop needs to know whose work to pull. Reading it
|
||||
/// back from the gate avoids a second round trip for something already known.
|
||||
pub fn profile_for_session(session_id: &str) -> Option<String> {
|
||||
with_store(|store| {
|
||||
store
|
||||
.iter()
|
||||
.find(|(_, entry)| entry.session_id == session_id)
|
||||
.map(|(profile_id, _)| profile_id.clone())
|
||||
})
|
||||
}
|
||||
|
||||
/// Record that a session is live on the fleet for this profile.
|
||||
///
|
||||
/// Written to disk immediately, and this is the point of the whole store: if the
|
||||
/// app is closed while a session runs, nothing on restart would otherwise
|
||||
/// distinguish "this profile is fine" from "a host has been writing to this
|
||||
/// profile for the last hour".
|
||||
pub fn note_running(profile_id: &str, session_id: &str) {
|
||||
mutate(|store| {
|
||||
let entry = store.get(profile_id);
|
||||
if entry
|
||||
.is_some_and(|held| held.state == HandoffState::Running && held.session_id == session_id)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
store.insert(
|
||||
profile_id.to_string(),
|
||||
HandoffEntry {
|
||||
session_id: session_id.to_string(),
|
||||
state: HandoffState::Running,
|
||||
observed_at: now_secs(),
|
||||
},
|
||||
);
|
||||
true
|
||||
});
|
||||
}
|
||||
|
||||
/// Record that a session has finished and its work is waiting in cloud storage.
|
||||
///
|
||||
/// Returns whether this call is the one that moved the profile into
|
||||
/// `PendingSync`, so the caller starts exactly one pull for a transition that
|
||||
/// the stream may well deliver more than once.
|
||||
pub fn note_ended(profile_id: &str, session_id: &str) -> bool {
|
||||
let mut transitioned = false;
|
||||
mutate(|store| {
|
||||
// Only a session this machine was watching can hand work over to it.
|
||||
//
|
||||
// No entry means one of two things and both say "do nothing": the pull for
|
||||
// this session already completed and cleared the gate, or this machine
|
||||
// never held the profile. The backend's listing returns closed sessions
|
||||
// alongside live ones, so the snapshot on every reconnect replays each
|
||||
// finished session — treating those as fresh handoffs would gate a
|
||||
// perfectly current profile on every app start, and keep it blocked for as
|
||||
// long as the machine happened to be offline.
|
||||
let Some(entry) = store.get(profile_id) else {
|
||||
return false;
|
||||
};
|
||||
// A late `closed` for a session that has already been replaced by a newer
|
||||
// one must not mark the newer one's profile as finished.
|
||||
if entry.session_id != session_id {
|
||||
return false;
|
||||
}
|
||||
if entry.state == HandoffState::PendingSync {
|
||||
return false;
|
||||
}
|
||||
transitioned = true;
|
||||
store.insert(
|
||||
profile_id.to_string(),
|
||||
HandoffEntry {
|
||||
session_id: session_id.to_string(),
|
||||
state: HandoffState::PendingSync,
|
||||
observed_at: now_secs(),
|
||||
},
|
||||
);
|
||||
true
|
||||
});
|
||||
transitioned
|
||||
}
|
||||
|
||||
/// Drop the gate. Called only after a pull has actually completed.
|
||||
pub fn clear(profile_id: &str) {
|
||||
mutate(|store| store.remove(profile_id).is_some());
|
||||
}
|
||||
|
||||
/// Bring stored `Running` entries back in line with what the backend reports.
|
||||
///
|
||||
/// The stream is how a transition normally arrives, and it cannot deliver one
|
||||
/// that happened while the app was shut. Any profile this machine last saw
|
||||
/// running, whose session the backend no longer reports as live, finished
|
||||
/// without being observed — and its work is sitting in cloud storage unpulled.
|
||||
/// Returns the profiles that just moved into `PendingSync`.
|
||||
pub fn reconcile(live_session_ids: &std::collections::HashSet<String>) -> Vec<String> {
|
||||
let mut ended = Vec::new();
|
||||
mutate(|store| {
|
||||
let stale: Vec<(String, String)> = store
|
||||
.iter()
|
||||
.filter(|(_, entry)| entry.state == HandoffState::Running)
|
||||
.filter(|(_, entry)| !live_session_ids.contains(&entry.session_id))
|
||||
.map(|(profile_id, entry)| (profile_id.clone(), entry.session_id.clone()))
|
||||
.collect();
|
||||
for (profile_id, session_id) in stale {
|
||||
log::info!(
|
||||
"Remote session {session_id} for profile {profile_id} ended while this machine was not \
|
||||
watching; its work is still in cloud storage"
|
||||
);
|
||||
store.insert(
|
||||
profile_id.clone(),
|
||||
HandoffEntry {
|
||||
session_id,
|
||||
state: HandoffState::PendingSync,
|
||||
observed_at: now_secs(),
|
||||
},
|
||||
);
|
||||
ended.push(profile_id);
|
||||
}
|
||||
!ended.is_empty()
|
||||
});
|
||||
ended
|
||||
}
|
||||
|
||||
/// Refuse a local launch that would run over unsynced remote work.
|
||||
///
|
||||
/// Returns the `{"code":…}` string a Tauri command and the REST layer both
|
||||
/// surface. Every local launch path calls this: the two that did not are how a
|
||||
/// profile could be opened locally while a host was still writing to it.
|
||||
pub fn ensure_local_launch_allowed(profile_id: &str) -> Result<(), String> {
|
||||
match state_for(profile_id) {
|
||||
None => Ok(()),
|
||||
Some(HandoffState::Running) => Err(crate::backend_error("PROFILE_RUNNING_REMOTELY")),
|
||||
Some(HandoffState::PendingSync) => Err(crate::backend_error("PROFILE_REMOTE_SYNC_PENDING")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Restart the pull for every profile still waiting on one.
|
||||
///
|
||||
/// A pull can fail for as long as the machine is offline, and its retries are
|
||||
/// bounded, so without this a profile could stay blocked from launching until
|
||||
/// the user found the manual sync button. Called whenever the app has a cloud
|
||||
/// session again, which is exactly when a previously impossible pull becomes
|
||||
/// possible.
|
||||
pub fn resume_pending_pulls(app_handle: &tauri::AppHandle) {
|
||||
let pending: Vec<String> = with_store(|store| {
|
||||
store
|
||||
.iter()
|
||||
.filter(|(_, entry)| entry.state == HandoffState::PendingSync)
|
||||
.map(|(profile_id, _)| profile_id.clone())
|
||||
.collect()
|
||||
});
|
||||
for profile_id in pending {
|
||||
log::info!("Resuming the post-session pull for profile {profile_id}");
|
||||
schedule_pull(app_handle.clone(), profile_id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Pull one profile's finished session down, then lift its gate.
|
||||
///
|
||||
/// Spawned rather than awaited by its callers: a stream frame and a stop button
|
||||
/// must not block on a transfer that can take minutes. The gate stays up for the
|
||||
/// whole attempt, so there is no window in which the user can open the stale
|
||||
/// copy while this is in flight.
|
||||
pub fn schedule_pull(app_handle: tauri::AppHandle, profile_id: String) {
|
||||
tauri::async_runtime::spawn(async move {
|
||||
for attempt in 0..PULL_ATTEMPTS {
|
||||
if state_for(&profile_id) != Some(HandoffState::PendingSync) {
|
||||
// A new session started, or another pull got there first.
|
||||
return;
|
||||
}
|
||||
if attempt > 0 {
|
||||
let delay = PULL_RETRY_BASE
|
||||
.saturating_mul(1u32 << (attempt - 1).min(16))
|
||||
.min(PULL_RETRY_MAX);
|
||||
tokio::time::sleep(delay).await;
|
||||
}
|
||||
|
||||
match crate::sync::pull_profile_after_remote_session(&app_handle, &profile_id).await {
|
||||
Ok(outcome) if outcome.is_completed() => {
|
||||
log::info!("Pulled remote session work for profile {profile_id}");
|
||||
clear(&profile_id);
|
||||
return;
|
||||
}
|
||||
Ok(crate::sync::ProfileSyncOutcome::Skipped(reason)) => {
|
||||
log::info!("Post-session pull for profile {profile_id} did nothing ({reason}); retrying");
|
||||
}
|
||||
Ok(_) => unreachable!("is_completed covers every completed outcome"),
|
||||
Err(e) => {
|
||||
log::warn!("Post-session pull for profile {profile_id} failed: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log::warn!(
|
||||
"Could not pull remote session work for profile {profile_id} yet; it stays blocked from \
|
||||
launching locally until the pull succeeds"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::collections::HashSet;
|
||||
|
||||
/// Serialises the tests.
|
||||
///
|
||||
/// `TEST_DATA_DIR` is thread-local but [`STORE`] is process-global, so two
|
||||
/// tests running at once would share one store while pointing at different
|
||||
/// directories. That fails intermittently, which is the worst way for a test
|
||||
/// guarding a data-loss bug to fail.
|
||||
static TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
|
||||
/// Point the store at a scratch directory and start it empty.
|
||||
///
|
||||
/// Everything returned must outlive the test body: dropping the guard
|
||||
/// restores the real data directory, and a test that let it drop early would
|
||||
/// write a gate file into the developer's own app data.
|
||||
fn isolated() -> (
|
||||
tempfile::TempDir,
|
||||
crate::app_dirs::TestDirGuard,
|
||||
std::sync::MutexGuard<'static, ()>,
|
||||
) {
|
||||
let lock = TEST_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let dir = tempfile::TempDir::new().expect("a scratch directory");
|
||||
let guard = crate::app_dirs::set_test_data_dir(dir.path().to_path_buf());
|
||||
*STORE
|
||||
.write()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner) = Some(Store::new());
|
||||
(dir, guard, lock)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_live_session_blocks_a_local_launch() {
|
||||
let _iso = isolated();
|
||||
note_running("p1", "s1");
|
||||
let err = ensure_local_launch_allowed("p1").expect_err("a live session must block a launch");
|
||||
assert!(err.contains("PROFILE_RUNNING_REMOTELY"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_finished_session_still_blocks_until_the_work_is_pulled() {
|
||||
// The whole point. The profile lock is released the moment the session
|
||||
// closes, so without this the user can open the stale copy and the next
|
||||
// sync deletes everything the host wrote.
|
||||
let _iso = isolated();
|
||||
note_running("p1", "s1");
|
||||
assert!(note_ended("p1", "s1"));
|
||||
let err = ensure_local_launch_allowed("p1").expect_err("pending work must block a launch");
|
||||
assert!(err.contains("PROFILE_REMOTE_SYNC_PENDING"));
|
||||
|
||||
clear("p1");
|
||||
assert!(ensure_local_launch_allowed("p1").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_ungated_profile_is_not_blocked() {
|
||||
let _iso = isolated();
|
||||
note_running("p1", "s1");
|
||||
assert!(ensure_local_launch_allowed("p2").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_end_transition_is_reported_once_however_often_the_frame_arrives() {
|
||||
// The stream re-delivers a snapshot on every reconnect, and `closed` can
|
||||
// arrive alongside it. Starting a pull per frame would run several
|
||||
// concurrent transfers of the same profile.
|
||||
let _iso = isolated();
|
||||
note_running("p1", "s1");
|
||||
assert!(note_ended("p1", "s1"));
|
||||
assert!(!note_ended("p1", "s1"));
|
||||
assert!(!note_ended("p1", "s1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_closed_session_this_machine_never_watched_does_not_gate_anything() {
|
||||
// `listForUser` returns closed sessions next to live ones, so the snapshot
|
||||
// on every reconnect replays every session that ever finished. Treating
|
||||
// those as fresh handoffs would block the Run button on a perfectly current
|
||||
// profile at each app start, and block it indefinitely while offline.
|
||||
let _iso = isolated();
|
||||
assert!(!note_ended("p1", "s-finished-last-week"));
|
||||
assert_eq!(state_for("p1"), None);
|
||||
assert!(ensure_local_launch_allowed("p1").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pulled_profile_is_not_re_gated_by_a_replayed_close() {
|
||||
// Same frame, one step later: the pull completed and cleared the gate. The
|
||||
// next reconnect must not put it back.
|
||||
let _iso = isolated();
|
||||
note_running("p1", "s1");
|
||||
note_ended("p1", "s1");
|
||||
clear("p1");
|
||||
assert!(!note_ended("p1", "s1"));
|
||||
assert!(ensure_local_launch_allowed("p1").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_late_close_for_a_replaced_session_does_not_gate_the_new_one() {
|
||||
// Session s1 finished and was pulled; s2 is now live on the same profile. A
|
||||
// straggling `closed` for s1 must not declare s2's profile finished, or the
|
||||
// gate lifts while a host is still writing.
|
||||
let _iso = isolated();
|
||||
note_running("p1", "s2");
|
||||
assert!(!note_ended("p1", "s1"));
|
||||
assert_eq!(state_for("p1"), Some(HandoffState::Running));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_session_that_ended_while_the_app_was_shut_is_recovered() {
|
||||
// Nothing streams a transition to a process that is not running. Without
|
||||
// this the profile reads as still-running for ever and can never be
|
||||
// launched again, and its work is never pulled.
|
||||
let _iso = isolated();
|
||||
note_running("p1", "s1");
|
||||
let live: HashSet<String> = HashSet::new();
|
||||
assert_eq!(reconcile(&live), vec!["p1".to_string()]);
|
||||
assert_eq!(state_for("p1"), Some(HandoffState::PendingSync));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reconcile_leaves_a_session_that_is_genuinely_still_live() {
|
||||
let _iso = isolated();
|
||||
note_running("p1", "s1");
|
||||
let live: HashSet<String> = ["s1".to_string()].into_iter().collect();
|
||||
assert!(reconcile(&live).is_empty());
|
||||
assert_eq!(state_for("p1"), Some(HandoffState::Running));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reconcile_does_not_reopen_a_pending_profile() {
|
||||
// `PendingSync` is not a session state and no listing will ever contain it.
|
||||
// Re-deriving it from the snapshot would report the same handoff as new on
|
||||
// every reconnect and start a pull each time.
|
||||
let _iso = isolated();
|
||||
note_running("p1", "s1");
|
||||
note_ended("p1", "s1");
|
||||
let live: HashSet<String> = HashSet::new();
|
||||
assert!(reconcile(&live).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_gate_survives_a_restart() {
|
||||
// Held on disk precisely because the dangerous window outlives the process:
|
||||
// an app killed mid-session comes back with no memory of it.
|
||||
let (_dir, _guard, _lock) = isolated();
|
||||
note_running("p1", "s1");
|
||||
note_ended("p1", "s1");
|
||||
|
||||
*STORE
|
||||
.write()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner) = None;
|
||||
|
||||
assert_eq!(state_for("p1"), Some(HandoffState::PendingSync));
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,9 @@
|
||||
|
||||
use crate::cloud_errors::{self, FailureCodes};
|
||||
use crate::profile::types::BrowserProfile;
|
||||
use crate::remote_exit::ExitReachability;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Mutex;
|
||||
use std::time::Duration;
|
||||
@@ -114,6 +116,54 @@ pub fn idempotency_key(profile_id: &str, attempt: &str) -> String {
|
||||
format!("run-remote:{profile_id}:{attempt}")
|
||||
}
|
||||
|
||||
/// Whether this profile's exit rules out running it on a leased host.
|
||||
///
|
||||
/// A session runs on a fleet host that pulls the profile — and its proxy record
|
||||
/// — out of the user's sync namespace, rewriting no addresses along the way. A
|
||||
/// proxy stored as `127.0.0.1:8080` therefore arrives meaning THAT host's
|
||||
/// loopback: the browser either cannot connect and the leased hour is burned on
|
||||
/// a session that never worked, or it falls through and the user's identity
|
||||
/// egresses from our datacenter. The Cookie Bot has refused this since
|
||||
/// `remote_exit` existed; interactive sessions take the same profile onto the
|
||||
/// same hosts and did not, so the same mistake cost a leased hour here.
|
||||
///
|
||||
/// A profile with NO exit at all is deliberately allowed through. The Cookie
|
||||
/// Bot refuses that separately because a night of unattended browsing from a
|
||||
/// hosting ASN damages an identity, but an interactive session is a person at a
|
||||
/// keyboard who chose to open this profile and can see where it comes out —
|
||||
/// and no rule has ever required an exit here. Refusing it would be a new
|
||||
/// product restriction wearing this bug's error code.
|
||||
///
|
||||
/// Split out from the launch because that is the only testable seam:
|
||||
/// `exit_reachability` reads this machine's proxy and VPN stores and the launch
|
||||
/// itself needs a fleet.
|
||||
fn local_exit_refusal(verdict: &ExitReachability) -> Option<RemoteSessionError> {
|
||||
match verdict {
|
||||
// An address anyone can dial, so the leased host can dial it too.
|
||||
ExitReachability::Remote => None,
|
||||
// See the second paragraph above: allowed on purpose, not overlooked.
|
||||
ExitReachability::None => None,
|
||||
// `LocalOnly`, plus `Unknown` — which `remote_exit` produces when it could
|
||||
// not read the config and which fails closed by design, because "we could
|
||||
// not confirm it" guessed as "yes" is the failure this whole check exists
|
||||
// to stop.
|
||||
unusable => {
|
||||
// The prose names the offending host, which belongs in the log where
|
||||
// support can read it. The toast gets the code so it stays translated.
|
||||
if let Some(detail) = unusable.refusal_detail() {
|
||||
log::warn!("Refusing an interactive remote session: {detail}");
|
||||
}
|
||||
// `Other` rather than a typed variant: the other three are each pinned to
|
||||
// a status and a meaning — "the fleet is busy", "already open somewhere",
|
||||
// "not on your plan" — and this refusal is none of them. The code in the
|
||||
// body is what every surface renders.
|
||||
Some(RemoteSessionError::Other(
|
||||
serde_json::json!({ "code": "REMOTE_REQUIRES_REMOTE_EXIT_NODE" }).to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Ask donutbrowser-infra to start a remote session for this profile.
|
||||
///
|
||||
/// Goes through `api_call_with_retry` so an expired access token is refreshed
|
||||
@@ -132,12 +182,20 @@ pub async fn start_remote_session(
|
||||
.to_string();
|
||||
let profile_id = profile.id.to_string();
|
||||
|
||||
// Checked here, before the request: the backend is told which profile to
|
||||
// start but never sees the proxy record, so it cannot derive this — and by
|
||||
// the time it could, an hour is already leased and billed. Resolving a proxy
|
||||
// id to an address is only possible on the machine that stores it.
|
||||
if let Some(refusal) = local_exit_refusal(&crate::cookie_bot::exit_reachability(profile)) {
|
||||
return Err(refusal);
|
||||
}
|
||||
|
||||
// One key for this user action: a retry inside api_call_with_retry must
|
||||
// de-duplicate rather than open a second browser on the same profile.
|
||||
let key = idempotency_key(&profile_id, &uuid::Uuid::new_v4().to_string());
|
||||
let endpoint = format!("{}/api/remote-sessions", crate::cloud_auth::CLOUD_API_URL);
|
||||
|
||||
crate::cloud_auth::CLOUD_AUTH
|
||||
let outcome = crate::cloud_auth::CLOUD_AUTH
|
||||
.api_call_with_retry(|token| {
|
||||
let endpoint = endpoint.clone();
|
||||
let body = StartRemoteRequest {
|
||||
@@ -169,7 +227,14 @@ pub async fn start_remote_session(
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|e| classify_error_string(&e))
|
||||
.map_err(|e| classify_error_string(&e))?;
|
||||
|
||||
// Gate the profile here rather than waiting for the stream to say so. A host
|
||||
// starts pulling this profile the instant the backend accepts, and the first
|
||||
// transition can arrive seconds later or, on a machine whose stream is down,
|
||||
// not at all. Those seconds are enough for a user to press Run.
|
||||
note_session_started(&profile.id.to_string(), &outcome.session_id);
|
||||
Ok(outcome)
|
||||
}
|
||||
|
||||
/// What the backend returns when a session is stopped.
|
||||
@@ -181,6 +246,30 @@ pub struct EndRemoteSessionOutcome {
|
||||
pub billed_seconds: u64,
|
||||
}
|
||||
|
||||
/// Gate a profile the moment a launch is accepted, and pull when one is stopped.
|
||||
///
|
||||
/// The event stream is the normal way this machine learns a session's state, but
|
||||
/// it is not the only way a session starts or ends and it is not guaranteed to
|
||||
/// be connected. Both of these are called directly by the launch and stop paths
|
||||
/// so the gate never depends on a socket being up: a launch whose first
|
||||
/// transition is missed would leave the profile openable locally while a host
|
||||
/// wrote to it, and a stop whose `closed` frame is missed would leave the
|
||||
/// session's work sitting in cloud storage with nothing to pull it.
|
||||
pub fn note_session_started(profile_id: &str, session_id: &str) {
|
||||
crate::remote_handoff::note_running(profile_id, session_id);
|
||||
}
|
||||
|
||||
pub fn note_session_stopped(app: &AppHandle, session_id: &str) {
|
||||
let Some(profile_id) = crate::remote_handoff::profile_for_session(session_id) else {
|
||||
// A session this machine never saw start. There is nothing recorded to
|
||||
// pull for, and inventing a profile id would gate the wrong profile.
|
||||
return;
|
||||
};
|
||||
if crate::remote_handoff::note_ended(&profile_id, session_id) {
|
||||
crate::remote_handoff::schedule_pull(app.clone(), profile_id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Ask donutbrowser-infra to stop a remote session.
|
||||
///
|
||||
/// Without this the only thing that ends a session is the fleet's own two-hour
|
||||
@@ -343,6 +432,257 @@ async fn get_json<T: serde::de::DeserializeOwned>(
|
||||
.map_err(|e| classify_error_string(&e))
|
||||
}
|
||||
|
||||
// --- Driving a session ------------------------------------------------------
|
||||
|
||||
/// Where to attach a CDP client for one session.
|
||||
///
|
||||
/// The descriptor is deliberately OPAQUE and server-decided. The desktop knows
|
||||
/// nothing about the fleet — not its hostname, not its paths, not a credential
|
||||
/// it would accept — and switches only on `auth`. That is what lets the server
|
||||
/// move the endpoint, or hand out a different kind of credential, without a
|
||||
/// desktop release; a hard-coded URL in a shipped binary could not be moved at
|
||||
/// all.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct CdpEndpoint {
|
||||
#[serde(default)]
|
||||
pub session_id: String,
|
||||
pub ws_url: String,
|
||||
/// Wire protocol the endpoint speaks.
|
||||
#[serde(default)]
|
||||
pub protocol: String,
|
||||
/// How to authenticate: `bearer` means the same access token used for REST.
|
||||
#[serde(default)]
|
||||
pub auth: String,
|
||||
}
|
||||
|
||||
/// The only credential scheme this build can present.
|
||||
const AUTH_BEARER: &str = "bearer";
|
||||
|
||||
/// The only relay protocol this build speaks.
|
||||
const PROTOCOL_CDP_RELAY_1: &str = "cdp-relay/1";
|
||||
|
||||
/// Endpoints already resolved, keyed by session id.
|
||||
///
|
||||
/// A session's endpoint does not move while it lives, and every tool call would
|
||||
/// otherwise pay a cloud round trip before it could send its first byte.
|
||||
static CDP_ENDPOINTS: Mutex<Option<HashMap<String, CdpEndpoint>>> = Mutex::new(None);
|
||||
|
||||
/// Ask the backend where to attach for `session_id`.
|
||||
pub async fn cdp_endpoint(session_id: &str) -> Result<CdpEndpoint, RemoteSessionError> {
|
||||
if let Some(cached) = with_endpoints(|map| map.get(session_id).cloned()) {
|
||||
return Ok(cached);
|
||||
}
|
||||
|
||||
let endpoint = format!(
|
||||
"{}/api/remote-sessions/{}/cdp",
|
||||
crate::cloud_auth::CLOUD_API_URL,
|
||||
urlencoding::encode(session_id)
|
||||
);
|
||||
let mut resolved: CdpEndpoint = get_json(endpoint).await?;
|
||||
if resolved.session_id.is_empty() {
|
||||
resolved.session_id = session_id.to_string();
|
||||
}
|
||||
|
||||
if let Some(reason) = unsupported_descriptor(&resolved) {
|
||||
return Err(RemoteSessionError::Other(reason));
|
||||
}
|
||||
|
||||
with_endpoints(|map| map.insert(session_id.to_string(), resolved.clone()));
|
||||
Ok(resolved)
|
||||
}
|
||||
|
||||
/// Why this build cannot use a descriptor, if it cannot.
|
||||
///
|
||||
/// A scheme or protocol this version does not implement has to fail loudly.
|
||||
/// Guessing at a credential scheme would send the user's access token somewhere
|
||||
/// it was never meant to go, and ignoring the fields would present the wrong
|
||||
/// credential on a wire expecting another — both of which read as "remote
|
||||
/// driving is broken" rather than "this app is out of date".
|
||||
///
|
||||
/// An empty field means the server stated nothing, which is how a descriptor
|
||||
/// that predates the field looks; the historic behaviour is then the answer.
|
||||
fn unsupported_descriptor(endpoint: &CdpEndpoint) -> Option<String> {
|
||||
if !endpoint.auth.is_empty() && endpoint.auth != AUTH_BEARER {
|
||||
return Some(format!(
|
||||
"this version cannot attach to a remote browser using {:?} authentication; update Donut Browser",
|
||||
endpoint.auth
|
||||
));
|
||||
}
|
||||
if !endpoint.protocol.is_empty() && endpoint.protocol != PROTOCOL_CDP_RELAY_1 {
|
||||
return Some(format!(
|
||||
"this version does not speak {:?}; update Donut Browser",
|
||||
endpoint.protocol
|
||||
));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn with_endpoints<T>(f: impl FnOnce(&mut HashMap<String, CdpEndpoint>) -> T) -> T {
|
||||
let mut guard = CDP_ENDPOINTS
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
f(guard.get_or_insert_with(HashMap::new))
|
||||
}
|
||||
|
||||
fn forget_endpoint(session_id: &str) {
|
||||
with_endpoints(|map| map.remove(session_id));
|
||||
}
|
||||
|
||||
/// The access token a relay attach presents.
|
||||
///
|
||||
/// One place, so the credential a WebSocket carries is provably the same one
|
||||
/// every REST call already carries, and no second copy of the load-and-check
|
||||
/// logic can drift from it.
|
||||
pub fn access_token_for_cdp() -> Result<String, String> {
|
||||
crate::cloud_auth::CloudAuthManager::load_access_token()?
|
||||
.filter(|token| !token.is_empty())
|
||||
.ok_or_else(|| "not signed in to Donut cloud".to_string())
|
||||
}
|
||||
|
||||
/// Sessions that can be driven right now, keyed by the profile they hold.
|
||||
///
|
||||
/// Maintained from the event stream so deciding "is this profile running on the
|
||||
/// fleet?" costs a lock rather than a cloud round trip on every tool call.
|
||||
static LIVE_BY_PROFILE: Mutex<Option<HashMap<String, RemoteSessionState>>> = Mutex::new(None);
|
||||
|
||||
/// Whether the stream has delivered a snapshot and has not dropped since.
|
||||
///
|
||||
/// Without this the index cannot distinguish "no session for that profile" from
|
||||
/// "nothing has told us about any session yet", and the second answered as the
|
||||
/// first is exactly how a live remote profile reports itself as not running.
|
||||
static INDEX_AUTHORITATIVE: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
fn with_index<T>(f: impl FnOnce(&mut HashMap<String, RemoteSessionState>) -> T) -> T {
|
||||
let mut guard = LIVE_BY_PROFILE
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
f(guard.get_or_insert_with(HashMap::new))
|
||||
}
|
||||
|
||||
/// A session that is up AND attachable.
|
||||
///
|
||||
/// `provisioning` and `ready` are both "the browser is not there yet"; treating
|
||||
/// either as drivable is what makes a client attach into a connection that
|
||||
/// never establishes.
|
||||
pub fn is_drivable(session: &RemoteSessionState) -> bool {
|
||||
session.state == "live" && session.cdp_ready
|
||||
}
|
||||
|
||||
/// A session that will never write to the profile again.
|
||||
///
|
||||
/// Deliberately NOT the negation of [`is_drivable`]. A `provisioning` session
|
||||
/// has already taken the profile lock and its host is about to pull the profile
|
||||
/// down and launch a browser on it, so it owns the profile every bit as much as
|
||||
/// a `live` one does — it is simply not attachable yet. Treating "not drivable"
|
||||
/// as "finished" would lift the local launch gate during the one minute a host
|
||||
/// spends starting up, which is the window in which two writers do the most
|
||||
/// damage.
|
||||
pub fn is_terminal(session: &RemoteSessionState) -> bool {
|
||||
matches!(session.state.as_str(), "closed" | "error")
|
||||
}
|
||||
|
||||
/// Apply one session to the index, and to the local launch gate.
|
||||
///
|
||||
/// A session that stopped being drivable is removed, but only by the session
|
||||
/// that owns the slot: a late `closed` for a finished session must not evict
|
||||
/// the live one that replaced it.
|
||||
fn index_session(app: Option<&AppHandle>, session: &RemoteSessionState) {
|
||||
let Some(profile_id) = session.profile_id.clone() else {
|
||||
return;
|
||||
};
|
||||
|
||||
// The gate is maintained from the same frames as the index, because these are
|
||||
// the only frames that exist. It is deliberately keyed off `is_terminal`
|
||||
// rather than `is_drivable`: a provisioning host already owns the profile.
|
||||
if is_terminal(session) {
|
||||
if crate::remote_handoff::note_ended(&profile_id, &session.session_id) {
|
||||
if let Some(app) = app {
|
||||
crate::remote_handoff::schedule_pull(app.clone(), profile_id.clone());
|
||||
}
|
||||
}
|
||||
} else {
|
||||
crate::remote_handoff::note_running(&profile_id, &session.session_id);
|
||||
}
|
||||
|
||||
if is_drivable(session) {
|
||||
with_index(|map| map.insert(profile_id, session.clone()));
|
||||
return;
|
||||
}
|
||||
forget_endpoint(&session.session_id);
|
||||
with_index(|map| {
|
||||
let owns_slot = map
|
||||
.get(&profile_id)
|
||||
.is_some_and(|held| held.session_id == session.session_id);
|
||||
if owns_slot {
|
||||
map.remove(&profile_id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Replace the whole index from a full listing, and reconcile the launch gate.
|
||||
fn reindex(app: Option<&AppHandle>, sessions: &[RemoteSessionState]) {
|
||||
// Every session the backend still considers unfinished. A profile this
|
||||
// machine last saw running whose session is not in here finished while
|
||||
// nothing was watching — its work is in cloud storage and has not been pulled.
|
||||
let unfinished: std::collections::HashSet<String> = sessions
|
||||
.iter()
|
||||
.filter(|session| !is_terminal(session))
|
||||
.map(|session| session.session_id.clone())
|
||||
.collect();
|
||||
|
||||
for session in sessions {
|
||||
index_session(app, session);
|
||||
}
|
||||
for profile_id in crate::remote_handoff::reconcile(&unfinished) {
|
||||
if let Some(app) = app {
|
||||
crate::remote_handoff::schedule_pull(app.clone(), profile_id);
|
||||
}
|
||||
}
|
||||
|
||||
let next: HashMap<String, RemoteSessionState> = sessions
|
||||
.iter()
|
||||
.filter(|session| is_drivable(session))
|
||||
.filter_map(|session| {
|
||||
session
|
||||
.profile_id
|
||||
.clone()
|
||||
.map(|profile_id| (profile_id, session.clone()))
|
||||
})
|
||||
.collect();
|
||||
let live: std::collections::HashSet<&str> = next
|
||||
.values()
|
||||
.map(|session| session.session_id.as_str())
|
||||
.collect();
|
||||
with_endpoints(|map| map.retain(|session_id, _| live.contains(session_id.as_str())));
|
||||
with_index(|map| *map = next);
|
||||
}
|
||||
|
||||
/// The drivable session holding `profile_id`, if there is one.
|
||||
///
|
||||
/// Consults the in-process index first. Only when the stream is not delivering
|
||||
/// transitions does it spend a cloud round trip, because in that state the
|
||||
/// index cannot be trusted to be complete and answering "not running" from it
|
||||
/// would hide a session the user is already paying for.
|
||||
pub async fn live_session_for_profile(profile_id: &str) -> Option<RemoteSessionState> {
|
||||
if let Some(session) = with_index(|map| map.get(profile_id).cloned()) {
|
||||
return Some(session);
|
||||
}
|
||||
if INDEX_AUTHORITATIVE.load(Ordering::SeqCst) {
|
||||
return None;
|
||||
}
|
||||
|
||||
match list_remote_sessions().await {
|
||||
Ok(sessions) => {
|
||||
reindex(None, &sessions);
|
||||
with_index(|map| map.get(profile_id).cloned())
|
||||
}
|
||||
Err(e) => {
|
||||
log::debug!("Could not refresh remote sessions while resolving a CDP target: {e}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Live state, without polling -------------------------------------------
|
||||
|
||||
/// A session transition. Payload is the session as the backend sees it.
|
||||
@@ -550,6 +890,10 @@ pub fn start_session_events(app: AppHandle) {
|
||||
|
||||
/// Stop receiving. Safe to call when nothing is running.
|
||||
pub fn stop_session_events() {
|
||||
// Cleared unconditionally: unsubscribing is what sign-out does, and an index
|
||||
// left marked authoritative would keep answering from state nothing is
|
||||
// maintaining any more.
|
||||
INDEX_AUTHORITATIVE.store(false, Ordering::SeqCst);
|
||||
if !STREAM_RUNNING.swap(false, Ordering::SeqCst) {
|
||||
return;
|
||||
}
|
||||
@@ -723,6 +1067,7 @@ fn dispatch_frame(app: &AppHandle, frame: &SseFrame) {
|
||||
let Some((target, payload)) = route_frame(frame.event.as_deref(), &frame.data) else {
|
||||
return;
|
||||
};
|
||||
apply_to_index(Some(app), target, &payload);
|
||||
|
||||
use tauri::Emitter;
|
||||
if let Err(e) = app.emit(target, payload) {
|
||||
@@ -730,7 +1075,47 @@ fn dispatch_frame(app: &AppHandle, frame: &SseFrame) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep the drivable-session index in step with what the stream just said.
|
||||
///
|
||||
/// The same frames that tell the frontend a session went live are the only
|
||||
/// thing that can tell the CDP resolver so without polling, and a resolver that
|
||||
/// polls would put a cloud round trip in front of every automation call.
|
||||
pub fn apply_to_index(app: Option<&AppHandle>, target: &str, payload: &serde_json::Value) {
|
||||
if target == EVENT_SESSION_SNAPSHOT {
|
||||
let Some(array) = payload.get("sessions").and_then(|v| v.as_array()) else {
|
||||
// Marking the index authoritative off a frame that carried no list would
|
||||
// answer "no session" for every profile until the next reconnect.
|
||||
log::warn!("Ignoring a remote-session snapshot that carried no session list");
|
||||
return;
|
||||
};
|
||||
let mut sessions = Vec::with_capacity(array.len());
|
||||
for value in array {
|
||||
match serde_json::from_value::<RemoteSessionState>(value.clone()) {
|
||||
Ok(session) => sessions.push(session),
|
||||
Err(e) => log::warn!("Skipping an undecodable session in the snapshot: {e}"),
|
||||
}
|
||||
}
|
||||
reindex(app, &sessions);
|
||||
INDEX_AUTHORITATIVE.store(true, Ordering::SeqCst);
|
||||
return;
|
||||
}
|
||||
|
||||
if target == EVENT_SESSION_STATE {
|
||||
match serde_json::from_value::<RemoteSessionState>(payload.clone()) {
|
||||
Ok(session) => index_session(app, &session),
|
||||
Err(e) => log::warn!("Ignoring an undecodable session transition: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn emit_stream_status(app: &AppHandle, connected: bool, reason: Option<&str>) {
|
||||
if !connected {
|
||||
// A dropped stream means transitions are being missed, so the index stops
|
||||
// being an answer and becomes a cache: a miss now costs one cloud read
|
||||
// rather than silently reporting a live session as absent.
|
||||
INDEX_AUTHORITATIVE.store(false, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
use tauri::Emitter;
|
||||
let payload = serde_json::json!({ "connected": connected, "reason": reason });
|
||||
if let Err(e) = app.emit(EVENT_STREAM_STATUS, payload) {
|
||||
@@ -852,6 +1237,50 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_local_only_exit_is_refused_before_a_host_is_leased() {
|
||||
// The profile and its proxy record are copied onto the fleet unrewritten,
|
||||
// so this loopback address would mean the FLEET's loopback. Accepting the
|
||||
// launch bills an hour for a session that cannot reach the user's exit.
|
||||
let refusal = local_exit_refusal(&ExitReachability::LocalOnly {
|
||||
host: "127.0.0.1".to_string(),
|
||||
source: "proxy",
|
||||
})
|
||||
.expect("a loopback proxy is unusable from a leased host");
|
||||
|
||||
assert_eq!(
|
||||
refusal.to_error_json(),
|
||||
r#"{"code":"REMOTE_REQUIRES_REMOTE_EXIT_NODE"}"#
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_exit_that_could_not_be_read_is_refused_too() {
|
||||
// `Unknown` is "we could not confirm this works from elsewhere". Treating
|
||||
// that as a yes reintroduces exactly the burned hour above, so it fails
|
||||
// closed here as it does everywhere else `remote_exit` is consulted.
|
||||
let refusal = local_exit_refusal(&ExitReachability::Unknown {
|
||||
reason: "the profile references a proxy that no longer exists".to_string(),
|
||||
source: "proxy",
|
||||
})
|
||||
.expect("an unreadable exit is not evidence of a reachable one");
|
||||
|
||||
assert_eq!(
|
||||
refusal.to_error_json(),
|
||||
r#"{"code":"REMOTE_REQUIRES_REMOTE_EXIT_NODE"}"#
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_reachable_exit_and_no_exit_at_all_are_both_allowed_to_launch() {
|
||||
assert!(local_exit_refusal(&ExitReachability::Remote).is_none());
|
||||
// Deliberate, and the reason this gate is not simply `!is_remote()`: a
|
||||
// proxyless interactive session has always been permitted, and refusing it
|
||||
// with a code that says "your proxy is local" would be both a new product
|
||||
// rule and a sentence that does not describe the profile.
|
||||
assert!(local_exit_refusal(&ExitReachability::None).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_backend_supplied_code_survives_the_trip_through_the_typed_error() {
|
||||
// Once infra sends an envelope, its code must win over the status default
|
||||
@@ -1123,4 +1552,263 @@ mod tests {
|
||||
stop_session_events();
|
||||
assert!(!session_events_running());
|
||||
}
|
||||
|
||||
// --- The drivable-session index ------------------------------------------
|
||||
//
|
||||
// This index is what lets an automation call decide "is this profile running
|
||||
// on the fleet?" without a cloud round trip. Everything below drives it
|
||||
// through the SAME two steps production uses — decode the wire, route the
|
||||
// frame, apply it — because the whole class of bug this replaced came from a
|
||||
// test that agreed with the client and neither agreeing with the server.
|
||||
|
||||
/// The statics below are process-wide, and `cargo test` runs these threads in
|
||||
/// parallel. Without this every index test would be racing every other one.
|
||||
static INDEX_TESTS: Mutex<()> = Mutex::new(());
|
||||
|
||||
fn index_test<T>(body: impl FnOnce() -> T) -> T {
|
||||
let _guard = INDEX_TESTS
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
with_index(|map| map.clear());
|
||||
with_endpoints(|map| map.clear());
|
||||
INDEX_AUTHORITATIVE.store(false, Ordering::SeqCst);
|
||||
body()
|
||||
}
|
||||
|
||||
/// Decode, route and apply a literal wire capture, exactly as
|
||||
/// `dispatch_frame` does minus the emit to the frontend.
|
||||
fn feed(bytes: &[u8]) {
|
||||
let mut decoder = SseDecoder::new();
|
||||
for frame in decoder.push(bytes) {
|
||||
if let Some((target, payload)) = route_frame(frame.event.as_deref(), &frame.data) {
|
||||
apply_to_index(None, target, &payload);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn indexed(profile_id: &str) -> Option<RemoteSessionState> {
|
||||
with_index(|map| map.get(profile_id).cloned())
|
||||
}
|
||||
|
||||
fn transition(session_id: &str, profile_id: &str, state: &str, cdp_ready: bool) -> Vec<u8> {
|
||||
format!(
|
||||
"data: {{\"type\":\"state\",\"session\":{{\"session_id\":\"{session_id}\",\"profile_id\":\"{profile_id}\",\"state\":\"{state}\",\"cdp_ready\":{cdp_ready}}}}}\n\n"
|
||||
)
|
||||
.into_bytes()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_session_becoming_drivable_is_indexed_by_the_profile_it_holds() {
|
||||
index_test(|| {
|
||||
feed(&transition("sess-1", "p1", "live", true));
|
||||
let held = indexed("p1").expect("a live session must be resolvable by profile");
|
||||
assert_eq!(held.session_id, "sess-1");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_browser_that_is_up_but_not_attachable_is_not_offered_for_driving() {
|
||||
index_test(|| {
|
||||
// `ready` without CDP is a browser that exists and cannot be driven.
|
||||
// Offering it is what makes a client attach into a connection that never
|
||||
// establishes, and then blame the fleet for the timeout.
|
||||
feed(&transition("sess-1", "p1", "ready", false));
|
||||
assert!(indexed("p1").is_none());
|
||||
feed(&transition("sess-1", "p1", "provisioning", false));
|
||||
assert!(indexed("p1").is_none());
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_session_that_closes_frees_the_profile_and_forgets_its_endpoint() {
|
||||
index_test(|| {
|
||||
feed(&transition("sess-1", "p1", "live", true));
|
||||
with_endpoints(|map| {
|
||||
map.insert(
|
||||
"sess-1".to_string(),
|
||||
CdpEndpoint {
|
||||
session_id: "sess-1".to_string(),
|
||||
ws_url: "wss://example/cdp".to_string(),
|
||||
protocol: PROTOCOL_CDP_RELAY_1.to_string(),
|
||||
auth: AUTH_BEARER.to_string(),
|
||||
},
|
||||
)
|
||||
});
|
||||
|
||||
feed(&transition("sess-1", "p1", "closed", false));
|
||||
assert!(indexed("p1").is_none());
|
||||
// A cached endpoint for a dead session would be handed to the next
|
||||
// attach, which would then fail against a relay that has nothing left to
|
||||
// relay to.
|
||||
assert!(with_endpoints(|map| map.get("sess-1").cloned()).is_none());
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_late_close_for_a_finished_session_does_not_evict_the_one_that_replaced_it() {
|
||||
index_test(|| {
|
||||
feed(&transition("sess-1", "p1", "live", true));
|
||||
feed(&transition("sess-1", "p1", "closed", false));
|
||||
feed(&transition("sess-2", "p1", "live", true));
|
||||
|
||||
// Out-of-order frames are normal: the reconciler polls the fleet while
|
||||
// the user is already starting the next session. A stale close arriving
|
||||
// after the new session went live must not make a working browser
|
||||
// unreachable.
|
||||
feed(&transition("sess-1", "p1", "closed", false));
|
||||
assert_eq!(
|
||||
indexed("p1").map(|s| s.session_id),
|
||||
Some("sess-2".to_string())
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_opening_snapshot_replaces_the_index_and_makes_it_authoritative() {
|
||||
index_test(|| {
|
||||
feed(&transition("stale", "p-gone", "live", true));
|
||||
feed(
|
||||
concat!(
|
||||
r#"data: {"type":"snapshot","at":"2026-08-03T00:00:00.000Z","sessions":["#,
|
||||
r#"{"session_id":"sess-1","profile_id":"p1","state":"live","cdp_ready":true},"#,
|
||||
r#"{"session_id":"sess-2","profile_id":"p2","state":"ready","cdp_ready":false}]}"#,
|
||||
"\n\n"
|
||||
)
|
||||
.as_bytes(),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
indexed("p1").map(|s| s.session_id),
|
||||
Some("sess-1".to_string())
|
||||
);
|
||||
// Not attachable, so not in the index even though the snapshot listed it.
|
||||
assert!(indexed("p2").is_none());
|
||||
// A session the snapshot did not mention is gone, however live the index
|
||||
// last believed it to be.
|
||||
assert!(indexed("p-gone").is_none());
|
||||
assert!(INDEX_AUTHORITATIVE.load(Ordering::SeqCst));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_snapshot_carrying_no_session_list_does_not_blind_the_resolver() {
|
||||
index_test(|| {
|
||||
feed(&transition("sess-1", "p1", "live", true));
|
||||
// Trusting a malformed snapshot would answer "no session" for every
|
||||
// profile until the next reconnect, which is exactly the blindness the
|
||||
// index exists to remove.
|
||||
apply_to_index(None, EVENT_SESSION_SNAPSHOT, &serde_json::json!({}));
|
||||
assert_eq!(
|
||||
indexed("p1").map(|s| s.session_id),
|
||||
Some("sess-1".to_string())
|
||||
);
|
||||
assert!(!INDEX_AUTHORITATIVE.load(Ordering::SeqCst));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_undecodable_session_does_not_cost_the_whole_snapshot() {
|
||||
index_test(|| {
|
||||
feed(
|
||||
concat!(
|
||||
r#"data: {"type":"snapshot","sessions":[{"nonsense":true},"#,
|
||||
r#"{"session_id":"sess-1","profile_id":"p1","state":"live","cdp_ready":true}]}"#,
|
||||
"\n\n"
|
||||
)
|
||||
.as_bytes(),
|
||||
);
|
||||
assert_eq!(
|
||||
indexed("p1").map(|s| s.session_id),
|
||||
Some("sess-1".to_string())
|
||||
);
|
||||
assert!(INDEX_AUTHORITATIVE.load(Ordering::SeqCst));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsubscribing_stops_the_index_being_an_answer() {
|
||||
index_test(|| {
|
||||
INDEX_AUTHORITATIVE.store(true, Ordering::SeqCst);
|
||||
STREAM_RUNNING.store(false, Ordering::SeqCst);
|
||||
// Sign-out unsubscribes. An index still marked authoritative would keep
|
||||
// answering from state nothing is maintaining any more, so a session
|
||||
// started by the next account would report as absent.
|
||||
stop_session_events();
|
||||
assert!(!INDEX_AUTHORITATIVE.load(Ordering::SeqCst));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_session_with_no_profile_is_ignored_rather_than_indexed_under_nothing() {
|
||||
index_test(|| {
|
||||
feed(b"data: {\"type\":\"state\",\"session\":{\"session_id\":\"s1\",\"state\":\"live\",\"cdp_ready\":true}}\n\n");
|
||||
assert!(with_index(|map| map.is_empty()));
|
||||
});
|
||||
}
|
||||
|
||||
// --- The CDP endpoint descriptor -----------------------------------------
|
||||
|
||||
#[test]
|
||||
fn the_endpoint_descriptor_matches_what_the_backend_sends() {
|
||||
// Pinned against `GET /api/remote-sessions/:id/cdp` in donutbrowser-infra.
|
||||
// A field name that does not match makes every remote attach fail at the
|
||||
// decode step, and the desktop reports a live session as undrivable.
|
||||
let endpoint: CdpEndpoint = serde_json::from_str(
|
||||
r#"{"session_id":"sess-1",
|
||||
"ws_url":"wss://api.donutbrowser.com/api/remote-sessions/cdp?session_id=sess-1",
|
||||
"protocol":"cdp-relay/1","auth":"bearer"}"#,
|
||||
)
|
||||
.expect("the backend's CDP descriptor must deserialize");
|
||||
assert_eq!(endpoint.session_id, "sess-1");
|
||||
assert!(endpoint.ws_url.starts_with("wss://"));
|
||||
assert!(unsupported_descriptor(&endpoint).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_descriptor_this_build_cannot_honour_is_refused_rather_than_guessed_at() {
|
||||
// The descriptor is opaque and server-decided so the endpoint can move
|
||||
// without a desktop release. The other side of that bargain is that a
|
||||
// scheme this build does not implement must say so, not present the user's
|
||||
// access token on a wire that expected something else.
|
||||
let ticketed = CdpEndpoint {
|
||||
session_id: "sess-1".to_string(),
|
||||
ws_url: "wss://fleet.example/cdp".to_string(),
|
||||
protocol: PROTOCOL_CDP_RELAY_1.to_string(),
|
||||
auth: "ticket".to_string(),
|
||||
};
|
||||
assert!(unsupported_descriptor(&ticketed)
|
||||
.expect("an unknown auth scheme must be refused")
|
||||
.contains("update Donut Browser"));
|
||||
|
||||
let future_protocol = CdpEndpoint {
|
||||
auth: AUTH_BEARER.to_string(),
|
||||
protocol: "cdp-relay/2".to_string(),
|
||||
..ticketed
|
||||
};
|
||||
assert!(unsupported_descriptor(&future_protocol).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_descriptor_that_states_nothing_is_treated_as_todays_behaviour() {
|
||||
// An older backend that predates the fields must keep working; the fields
|
||||
// are a forward-compatibility hook, not a required handshake.
|
||||
let bare: CdpEndpoint = serde_json::from_str(r#"{"ws_url":"wss://example/cdp"}"#)
|
||||
.expect("a descriptor with only a URL must deserialize");
|
||||
assert!(bare.session_id.is_empty());
|
||||
assert!(unsupported_descriptor(&bare).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_session_that_is_both_live_and_attachable_is_drivable() {
|
||||
let mut session: RemoteSessionState =
|
||||
serde_json::from_str(r#"{"session_id":"s1","state":"live","cdp_ready":true}"#).unwrap();
|
||||
assert!(is_drivable(&session));
|
||||
|
||||
session.cdp_ready = false;
|
||||
assert!(!is_drivable(&session));
|
||||
|
||||
session.cdp_ready = true;
|
||||
session.state = "ready".to_string();
|
||||
assert!(!is_drivable(&session));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,6 +54,14 @@ pub struct AppSettings {
|
||||
pub language: Option<String>, // ISO 639-1: "en", "es", "pt", "fr", "zh", "ja", "ko", "ru", or None for system default
|
||||
#[serde(default)]
|
||||
pub window_resize_warning_dismissed: bool,
|
||||
/// Stop blocking launches whose proxy exit disagrees with the fingerprint.
|
||||
/// Lives here rather than in localStorage because the Rust launch path is
|
||||
/// what enforces the block and cannot read the frontend's storage.
|
||||
#[serde(default)]
|
||||
pub fingerprint_gate_disabled: bool,
|
||||
/// Stop warning about VPN/proxy extensions found in a profile.
|
||||
#[serde(default)]
|
||||
pub vpn_extension_warning_disabled: bool,
|
||||
#[serde(default)]
|
||||
pub onboarding_completed: bool, // First-launch onboarding has been shown/handled (one-shot)
|
||||
#[serde(default)]
|
||||
@@ -96,6 +104,8 @@ impl Default for AppSettings {
|
||||
mcp_token: None,
|
||||
language: None,
|
||||
window_resize_warning_dismissed: false,
|
||||
fingerprint_gate_disabled: false,
|
||||
vpn_extension_warning_disabled: false,
|
||||
onboarding_completed: false,
|
||||
disable_auto_updates: false,
|
||||
keep_decrypted_profiles_in_ram: false,
|
||||
@@ -1190,6 +1200,8 @@ mod tests {
|
||||
mcp_token: None,
|
||||
language: None,
|
||||
window_resize_warning_dismissed: false,
|
||||
fingerprint_gate_disabled: false,
|
||||
vpn_extension_warning_disabled: false,
|
||||
onboarding_completed: false,
|
||||
disable_auto_updates: false,
|
||||
keep_decrypted_profiles_in_ram: false,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
use super::client::SyncClient;
|
||||
use super::encryption;
|
||||
use super::manifest::{compute_diff, generate_manifest, get_cache_path, HashCache, SyncManifest};
|
||||
use super::manifest::{
|
||||
compute_diff_with_bias, generate_manifest, get_cache_path, DiffBias, HashCache, SyncManifest,
|
||||
};
|
||||
use super::types::*;
|
||||
use crate::events;
|
||||
use crate::profile::types::{BrowserProfile, SyncMode};
|
||||
@@ -20,6 +22,22 @@ use tokio::sync::{Mutex as TokioMutex, Semaphore};
|
||||
/// (last-write-wins) from a HEAD request without downloading the object body.
|
||||
const UPDATED_AT_META_KEY: &str = "updated-at";
|
||||
|
||||
/// What one profile reconcile actually did.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ProfileSyncOutcome {
|
||||
/// The local directory and the remote copy now agree.
|
||||
Completed,
|
||||
/// Nothing was transferred, and the reason is not an error. A caller waiting
|
||||
/// on the remote copy has NOT got it and must try again.
|
||||
Skipped(&'static str),
|
||||
}
|
||||
|
||||
impl ProfileSyncOutcome {
|
||||
pub fn is_completed(&self) -> bool {
|
||||
matches!(self, Self::Completed)
|
||||
}
|
||||
}
|
||||
|
||||
lazy_static::lazy_static! {
|
||||
static ref SYNC_CANCEL_FLAGS: StdMutex<HashMap<String, Arc<AtomicBool>>> =
|
||||
StdMutex::new(HashMap::new());
|
||||
@@ -313,7 +331,7 @@ impl SyncProgressTracker {
|
||||
/// Check if sync is configured (cloud or self-hosted)
|
||||
pub fn is_sync_configured() -> bool {
|
||||
// Cloud backup is a plan capability. Every paid plan (incl. the future
|
||||
// "starter" tier) grants it, but gating on the capability — not just "is paid"
|
||||
// "solo" tier) grants it, but gating on the capability — not just "is paid"
|
||||
// — keeps this correct if a plan without cloud backup is ever added.
|
||||
if crate::cloud_auth::CLOUD_AUTH.can_use_cloud_backup_sync() {
|
||||
return true;
|
||||
@@ -450,13 +468,35 @@ impl SyncEngine {
|
||||
app_handle: &tauri::AppHandle,
|
||||
profile: &BrowserProfile,
|
||||
) -> SyncResult<()> {
|
||||
self
|
||||
.sync_profile_with_bias(app_handle, profile, DiffBias::Auto)
|
||||
.await
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
/// Reconcile a profile, stating which side wins and whether anything happened.
|
||||
///
|
||||
/// The outcome matters to exactly one caller: the pull that follows a remote
|
||||
/// session. Every skip below returns `Ok(())` from `sync_profile`, so a caller
|
||||
/// that treated success as "the profile is now current" would clear the local
|
||||
/// launch gate without having downloaded a single byte — and the user would
|
||||
/// then open a stale profile over the session's work. `Skipped` says so.
|
||||
pub async fn sync_profile_with_bias(
|
||||
&self,
|
||||
app_handle: &tauri::AppHandle,
|
||||
profile: &BrowserProfile,
|
||||
bias: DiffBias,
|
||||
) -> SyncResult<ProfileSyncOutcome> {
|
||||
if profile.is_cross_os() {
|
||||
log::info!(
|
||||
"Cross-OS profile: {} ({}) — syncing metadata only",
|
||||
profile.name,
|
||||
profile.id
|
||||
);
|
||||
return self.sync_cross_os_metadata(app_handle, profile).await;
|
||||
self.sync_cross_os_metadata(app_handle, profile).await?;
|
||||
// The browser files are the thing a remote session changes, and a cross-OS
|
||||
// profile syncs none of them here, so this is not a completed pull.
|
||||
return Ok(ProfileSyncOutcome::Skipped("cross-OS profile"));
|
||||
}
|
||||
|
||||
// Skip team profiles for self-hosted sync
|
||||
@@ -466,7 +506,9 @@ impl SyncEngine {
|
||||
profile.name,
|
||||
profile.id
|
||||
);
|
||||
return Ok(());
|
||||
return Ok(ProfileSyncOutcome::Skipped(
|
||||
"team profile, self-hosted sync",
|
||||
));
|
||||
}
|
||||
|
||||
// Skip if profile is currently running locally
|
||||
@@ -476,20 +518,21 @@ impl SyncEngine {
|
||||
profile.name,
|
||||
profile.id
|
||||
);
|
||||
return Ok(());
|
||||
return Ok(ProfileSyncOutcome::Skipped("profile is running locally"));
|
||||
}
|
||||
|
||||
// Skip if profile is locked by another team member
|
||||
// Skip if profile is locked by another team member, or by one of this
|
||||
// user's own remote sessions.
|
||||
if crate::team_lock::TEAM_LOCK
|
||||
.is_locked_by_another(&profile.id.to_string())
|
||||
.await
|
||||
{
|
||||
log::info!(
|
||||
"Skipping sync for profile locked by another team member: {} ({})",
|
||||
"Skipping sync for profile locked by another holder: {} ({})",
|
||||
profile.name,
|
||||
profile.id
|
||||
);
|
||||
return Ok(());
|
||||
return Ok(ProfileSyncOutcome::Skipped("profile is locked elsewhere"));
|
||||
}
|
||||
|
||||
let reconciled_profile = self.reconcile_profile_metadata(profile).await?;
|
||||
@@ -591,7 +634,7 @@ impl SyncEngine {
|
||||
.await?;
|
||||
|
||||
// Compute diff
|
||||
let diff = compute_diff(&local_manifest, remote_manifest.as_ref());
|
||||
let diff = compute_diff_with_bias(&local_manifest, remote_manifest.as_ref(), bias);
|
||||
|
||||
if diff.is_empty() {
|
||||
log::info!("Profile {} is already in sync", profile_id);
|
||||
@@ -603,7 +646,9 @@ impl SyncEngine {
|
||||
"status": "synced"
|
||||
}),
|
||||
);
|
||||
return Ok(());
|
||||
// Nothing to transfer IS a completed reconcile: the local copy already
|
||||
// matches what the host pushed, which is exactly what the caller waits for.
|
||||
return Ok(ProfileSyncOutcome::Completed);
|
||||
}
|
||||
|
||||
let upload_bytes: u64 = diff.files_to_upload.iter().map(|f| f.size).sum();
|
||||
@@ -769,7 +814,7 @@ impl SyncEngine {
|
||||
);
|
||||
|
||||
log::info!("Profile {} synced successfully", profile_id);
|
||||
Ok(())
|
||||
Ok(ProfileSyncOutcome::Completed)
|
||||
}
|
||||
|
||||
async fn download_manifest(
|
||||
@@ -3546,6 +3591,40 @@ pub async fn trigger_sync_for_profile(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Pull a profile back down after a remote session wrote to it.
|
||||
///
|
||||
/// Not `trigger_sync_for_profile` with a different name. Two things differ, and
|
||||
/// both of them are the reason the session's work used to be destroyed:
|
||||
///
|
||||
/// - The diff is biased to the remote copy. The host has just written the
|
||||
/// authoritative profile; local mtimes may nonetheless be newer, and under the
|
||||
/// ordinary rule that uploads the stale copy and deletes the host's files.
|
||||
/// - The outcome is reported. Every skip inside `sync_profile` returns success,
|
||||
/// so the caller could otherwise mark the profile current without a byte
|
||||
/// having moved.
|
||||
pub async fn pull_profile_after_remote_session(
|
||||
app_handle: &tauri::AppHandle,
|
||||
profile_id: &str,
|
||||
) -> Result<ProfileSyncOutcome, String> {
|
||||
let engine = SyncEngine::create_from_settings(app_handle)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to create sync engine: {e}"))?;
|
||||
|
||||
let profile_uuid =
|
||||
uuid::Uuid::parse_str(profile_id).map_err(|_| format!("Invalid profile ID: {profile_id}"))?;
|
||||
let profile = ProfileManager::instance()
|
||||
.list_profiles()
|
||||
.map_err(|e| format!("Failed to list profiles: {e}"))?
|
||||
.into_iter()
|
||||
.find(|p| p.id == profile_uuid)
|
||||
.ok_or_else(|| format!("Profile with ID '{profile_id}' not found"))?;
|
||||
|
||||
engine
|
||||
.sync_profile_with_bias(app_handle, &profile, DiffBias::PreferRemote)
|
||||
.await
|
||||
.map_err(|e| format!("Sync failed: {e}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn set_proxy_sync_enabled(
|
||||
app_handle: tauri::AppHandle,
|
||||
|
||||
@@ -414,11 +414,41 @@ impl ManifestDiff {
|
||||
}
|
||||
|
||||
/// Compute what needs to be synced between local and remote
|
||||
/// Which side a sync should believe when both have moved.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum DiffBias {
|
||||
/// Newest `updated_at` wins. What an ordinary background sync uses.
|
||||
#[default]
|
||||
Auto,
|
||||
/// Remote wins regardless of timestamps.
|
||||
///
|
||||
/// Used for exactly one thing: the pull that follows a remote session. A
|
||||
/// leased host has just written the authoritative copy of this profile, and
|
||||
/// the local directory is whatever it was before the session started. If the
|
||||
/// user launched locally in between, local mtimes are NEWER than the host's
|
||||
/// push, so `Auto` would upload the stale copy and put every file the host
|
||||
/// wrote into `files_to_delete_remote` — the whole session's work destroyed,
|
||||
/// silently. There is no timestamp comparison that gets this right, because
|
||||
/// the local clock genuinely is later; only the caller knows that the remote
|
||||
/// copy is the one that matters.
|
||||
PreferRemote,
|
||||
}
|
||||
|
||||
pub fn compute_diff(local: &SyncManifest, remote: Option<&SyncManifest>) -> ManifestDiff {
|
||||
compute_diff_with_bias(local, remote, DiffBias::Auto)
|
||||
}
|
||||
|
||||
pub fn compute_diff_with_bias(
|
||||
local: &SyncManifest,
|
||||
remote: Option<&SyncManifest>,
|
||||
bias: DiffBias,
|
||||
) -> ManifestDiff {
|
||||
let mut diff = ManifestDiff::default();
|
||||
|
||||
let Some(remote) = remote else {
|
||||
// No remote manifest - upload everything
|
||||
// No remote manifest - upload everything. Even under PreferRemote: there is
|
||||
// no remote copy to prefer, and refusing to upload would leave the profile
|
||||
// with no cloud copy at all.
|
||||
diff.files_to_upload = local.files.clone();
|
||||
return diff;
|
||||
};
|
||||
@@ -446,11 +476,14 @@ pub fn compute_diff(local: &SyncManifest, remote: Option<&SyncManifest>) -> Mani
|
||||
let local_updated = local.updated_at_datetime();
|
||||
let remote_updated = remote.updated_at_datetime();
|
||||
|
||||
let local_is_newer = match (local_updated, remote_updated) {
|
||||
(Some(l), Some(r)) => l > r,
|
||||
(Some(_), None) => true,
|
||||
(None, Some(_)) => false,
|
||||
(None, None) => true, // Default to uploading
|
||||
let local_is_newer = match bias {
|
||||
DiffBias::PreferRemote => false,
|
||||
DiffBias::Auto => match (local_updated, remote_updated) {
|
||||
(Some(l), Some(r)) => l > r,
|
||||
(Some(_), None) => true,
|
||||
(None, Some(_)) => false,
|
||||
(None, None) => true, // Default to uploading
|
||||
},
|
||||
};
|
||||
|
||||
if local_is_newer {
|
||||
@@ -674,6 +707,68 @@ mod tests {
|
||||
assert!(diff.files_to_delete_remote.is_empty());
|
||||
}
|
||||
|
||||
/// A manifest with one file, at a stated time.
|
||||
fn manifest_at(updated_at: &str, files: &[(&str, &str)]) -> SyncManifest {
|
||||
SyncManifest {
|
||||
version: 1,
|
||||
profile_id: "test".to_string(),
|
||||
generated_at: updated_at.to_string(),
|
||||
updated_at: updated_at.to_string(),
|
||||
exclude_globs: vec![],
|
||||
files: files
|
||||
.iter()
|
||||
.map(|(path, hash)| ManifestFileEntry {
|
||||
path: (*path).to_string(),
|
||||
size: 10,
|
||||
mtime: 1000,
|
||||
hash: (*hash).to_string(),
|
||||
})
|
||||
.collect(),
|
||||
encrypted: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prefer_remote_downloads_even_though_the_local_clock_is_later() {
|
||||
// The exact shape of the data-loss bug. A remote session finishes and the
|
||||
// host pushes the profile; the user then launches locally before the pull
|
||||
// lands, so every local mtime is newer than the host's write. Under Auto
|
||||
// that uploads the stale copy and deletes the session's own files.
|
||||
let local = manifest_at("2026-01-02T00:00:00Z", &[("Cookies", "before-session")]);
|
||||
let remote = manifest_at(
|
||||
"2026-01-01T00:00:00Z",
|
||||
&[("Cookies", "after-session"), ("History", "warmed")],
|
||||
);
|
||||
|
||||
let lossy = compute_diff_with_bias(&local, Some(&remote), DiffBias::Auto);
|
||||
assert_eq!(lossy.files_to_delete_remote, vec!["History".to_string()]);
|
||||
assert_eq!(lossy.files_to_upload.len(), 1);
|
||||
|
||||
let safe = compute_diff_with_bias(&local, Some(&remote), DiffBias::PreferRemote);
|
||||
assert!(
|
||||
safe.files_to_delete_remote.is_empty(),
|
||||
"a post-session pull must never delete what the host just wrote"
|
||||
);
|
||||
assert!(safe.files_to_upload.is_empty());
|
||||
let downloaded: Vec<&str> = safe
|
||||
.files_to_download
|
||||
.iter()
|
||||
.map(|f| f.path.as_str())
|
||||
.collect();
|
||||
assert_eq!(downloaded.len(), 2);
|
||||
assert!(downloaded.contains(&"Cookies"));
|
||||
assert!(downloaded.contains(&"History"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prefer_remote_still_uploads_when_there_is_no_remote_copy() {
|
||||
// Nothing to prefer. Refusing to upload here would leave a profile with no
|
||||
// cloud copy because a session once ran against it.
|
||||
let local = manifest_at("2026-01-02T00:00:00Z", &[("Cookies", "only-local")]);
|
||||
let diff = compute_diff_with_bias(&local, None, DiffBias::PreferRemote);
|
||||
assert_eq!(diff.files_to_upload.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compute_diff_detect_changes() {
|
||||
let old_time = "2024-01-01T00:00:00Z";
|
||||
|
||||
@@ -15,12 +15,16 @@ pub use engine::{
|
||||
enable_proxy_sync_if_needed, enable_sync_for_all_entities, enable_vpn_sync_if_needed,
|
||||
get_unsynced_entity_counts, is_group_in_use_by_synced_profile, is_group_used_by_synced_profile,
|
||||
is_proxy_in_use_by_synced_profile, is_proxy_used_by_synced_profile, is_sync_configured,
|
||||
is_vpn_in_use_by_synced_profile, is_vpn_used_by_synced_profile, request_profile_sync,
|
||||
rollover_encryption_for_all_entities, set_extension_group_sync_enabled,
|
||||
set_extension_sync_enabled, set_group_sync_enabled, set_profile_sync_mode,
|
||||
set_proxy_sync_enabled, set_vpn_sync_enabled, sync_profile, trigger_sync_for_profile, SyncEngine,
|
||||
is_vpn_in_use_by_synced_profile, is_vpn_used_by_synced_profile,
|
||||
pull_profile_after_remote_session, request_profile_sync, rollover_encryption_for_all_entities,
|
||||
set_extension_group_sync_enabled, set_extension_sync_enabled, set_group_sync_enabled,
|
||||
set_profile_sync_mode, set_proxy_sync_enabled, set_vpn_sync_enabled, sync_profile,
|
||||
trigger_sync_for_profile, ProfileSyncOutcome, SyncEngine,
|
||||
};
|
||||
pub use manifest::{
|
||||
compute_diff, compute_diff_with_bias, generate_manifest, DiffBias, HashCache, ManifestDiff,
|
||||
SyncManifest,
|
||||
};
|
||||
pub use manifest::{compute_diff, generate_manifest, HashCache, ManifestDiff, SyncManifest};
|
||||
pub use scheduler::{get_global_scheduler, set_global_scheduler, SyncScheduler};
|
||||
pub use subscription::{SubscriptionManager, SyncWorkItem};
|
||||
pub use types::{SyncError, SyncResult};
|
||||
|
||||
@@ -168,7 +168,7 @@ impl SynchronizerManager {
|
||||
);
|
||||
|
||||
// Launch leader first so it gets focus
|
||||
crate::browser_runner::launch_browser_profile(app_handle.clone(), leader.clone(), None)
|
||||
crate::browser_runner::launch_browser_profile(app_handle.clone(), leader.clone(), None, None)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to launch leader: {e}"))?;
|
||||
|
||||
@@ -179,7 +179,7 @@ impl SynchronizerManager {
|
||||
let ah = app_handle.clone();
|
||||
let fp = fp.clone();
|
||||
set.spawn(async move {
|
||||
crate::browser_runner::launch_browser_profile(ah, fp.clone(), None)
|
||||
crate::browser_runner::launch_browser_profile(ah, fp.clone(), None, None)
|
||||
.await
|
||||
.map_err(|e| (fp.name.clone(), e.to_string()))
|
||||
});
|
||||
|
||||
+102
-20
@@ -95,8 +95,8 @@ impl ProfileLockManager {
|
||||
|
||||
pub async fn acquire_lock(&self, profile_id: &str) -> Result<(), String> {
|
||||
let client = Client::new();
|
||||
let access_token =
|
||||
CloudAuthManager::load_access_token()?.ok_or_else(|| "Not logged in".to_string())?;
|
||||
let access_token = CloudAuthManager::load_access_token()?
|
||||
.ok_or_else(|| crate::backend_error("PROFILE_LOCK_UNAVAILABLE"))?;
|
||||
|
||||
let url = format!("{CLOUD_API_URL}/api/profile-locks/{profile_id}");
|
||||
let response = client
|
||||
@@ -104,24 +104,29 @@ impl ProfileLockManager {
|
||||
.header("Authorization", format!("Bearer {access_token}"))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to acquire lock: {e}"))?;
|
||||
.map_err(|e| {
|
||||
log::warn!("Failed to acquire profile lock for {profile_id}: {e}");
|
||||
crate::backend_error("PROFILE_LOCK_UNAVAILABLE")
|
||||
})?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
return Err(format!("Lock acquisition failed ({status}): {body}"));
|
||||
log::warn!("Profile lock acquisition for {profile_id} failed ({status}): {body}");
|
||||
return Err(crate::backend_error("PROFILE_LOCK_UNAVAILABLE"));
|
||||
}
|
||||
|
||||
let result: AcquireLockResponse = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse lock response: {e}"))?;
|
||||
let result: AcquireLockResponse = response.json().await.map_err(|e| {
|
||||
log::warn!("Could not parse the profile lock response for {profile_id}: {e}");
|
||||
crate::backend_error("PROFILE_LOCK_UNAVAILABLE")
|
||||
})?;
|
||||
|
||||
if !result.success {
|
||||
let email = result
|
||||
.locked_by_email
|
||||
.unwrap_or_else(|| "another device".to_string());
|
||||
return Err(format!("Profile is in use by {email}"));
|
||||
return Err(lock_conflict_error(
|
||||
profile_id,
|
||||
result.locked_by.as_deref(),
|
||||
result.locked_by_email.as_deref(),
|
||||
));
|
||||
}
|
||||
|
||||
// Update local cache
|
||||
@@ -274,15 +279,51 @@ impl ProfileLockManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// Separator the backend puts between a user id and a non-desktop holder's
|
||||
/// sub-identity. Mirrors `HOLDER_SEPARATOR` in donutbrowser-infra's
|
||||
/// `profile-locks.service.ts`.
|
||||
///
|
||||
/// A remote VM session takes the lock under `<user id>:vm:<session id>` so it
|
||||
/// contends with this desktop instead of silently sharing its lock. That makes
|
||||
/// the holder string the one place a client can tell "a teammate has this open"
|
||||
/// apart from "this is my own profile, running on the fleet" — two refusals that
|
||||
/// need completely different words.
|
||||
const VM_HOLDER_SEPARATOR: &str = ":vm:";
|
||||
|
||||
/// The `{"code":…}` for a lock this caller could not take.
|
||||
fn lock_conflict_error(
|
||||
profile_id: &str,
|
||||
holder: Option<&str>,
|
||||
holder_email: Option<&str>,
|
||||
) -> String {
|
||||
if holder.is_some_and(|id| id.contains(VM_HOLDER_SEPARATOR)) {
|
||||
// The user's own remote session. Saying "in use by you@example.com" here,
|
||||
// which is what the raw backend message did, reads as a bug.
|
||||
log::info!("Profile {profile_id} is held by a remote session");
|
||||
return crate::backend_error("PROFILE_RUNNING_REMOTELY");
|
||||
}
|
||||
match holder_email {
|
||||
Some(email) if !email.is_empty() => serde_json::json!({
|
||||
"code": "PROFILE_LOCKED_BY_MEMBER",
|
||||
"params": { "email": email }
|
||||
})
|
||||
.to_string(),
|
||||
_ => crate::backend_error("PROFILE_LOCKED_ELSEWHERE"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Acquire profile lock if profile is sync-enabled and user has a paid subscription.
|
||||
/// Returns whether a lock was actually taken, so a caller that unwinds a failed
|
||||
/// launch releases only what it acquired. Releasing unconditionally would drop
|
||||
/// a lock a REST handler up the stack still owns.
|
||||
pub async fn acquire_team_lock_if_needed(
|
||||
profile: &crate::profile::BrowserProfile,
|
||||
) -> Result<(), String> {
|
||||
) -> Result<bool, String> {
|
||||
if !profile.is_sync_enabled() {
|
||||
return Ok(());
|
||||
return Ok(false);
|
||||
}
|
||||
if !CLOUD_AUTH.has_active_paid_subscription().await {
|
||||
return Ok(());
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
// Ensure lock manager is connected
|
||||
@@ -294,13 +335,18 @@ pub async fn acquire_team_lock_if_needed(
|
||||
.is_locked_by_another(&profile.id.to_string())
|
||||
.await
|
||||
{
|
||||
if let Some(lock) = PROFILE_LOCK.get_lock_status(&profile.id.to_string()).await {
|
||||
return Err(format!("Profile is in use by {}", lock.locked_by_email));
|
||||
}
|
||||
return Err("Profile is in use on another device".to_string());
|
||||
let held = PROFILE_LOCK.get_lock_status(&profile.id.to_string()).await;
|
||||
return Err(lock_conflict_error(
|
||||
&profile.id.to_string(),
|
||||
held.as_ref().map(|lock| lock.locked_by.as_str()),
|
||||
held.as_ref().map(|lock| lock.locked_by_email.as_str()),
|
||||
));
|
||||
}
|
||||
|
||||
PROFILE_LOCK.acquire_lock(&profile.id.to_string()).await
|
||||
PROFILE_LOCK
|
||||
.acquire_lock(&profile.id.to_string())
|
||||
.await
|
||||
.map(|()| true)
|
||||
}
|
||||
|
||||
/// Release profile lock if profile is sync-enabled and user has a paid subscription.
|
||||
@@ -328,3 +374,39 @@ pub async fn get_team_locks() -> Result<Vec<ProfileLockInfo>, String> {
|
||||
pub async fn get_team_lock_status(profile_id: String) -> Result<Option<ProfileLockInfo>, String> {
|
||||
Ok(PROFILE_LOCK.get_lock_status(&profile_id).await)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_users_own_remote_session_is_not_reported_as_a_teammate() {
|
||||
// The holder for a fleet session is `<user id>:vm:<session id>` and the row
|
||||
// carries the OWNER's email, so the previous message read "Profile is in use
|
||||
// by you@example.com" — the user's own address, about their own profile.
|
||||
let err = lock_conflict_error(
|
||||
"p1",
|
||||
Some("11111111-2222-3333-4444-555555555555:vm:run-remote:p1:abc"),
|
||||
Some("owner@example.com"),
|
||||
);
|
||||
assert_eq!(err, r#"{"code":"PROFILE_RUNNING_REMOTELY"}"#);
|
||||
assert!(!err.contains("owner@example.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_teammates_lock_names_them_through_a_translatable_code() {
|
||||
let err = lock_conflict_error("p1", Some("other-user-id"), Some("mate@example.com"));
|
||||
let json: serde_json::Value = serde_json::from_str(&err).expect("a code envelope");
|
||||
assert_eq!(json["code"], "PROFILE_LOCKED_BY_MEMBER");
|
||||
assert_eq!(json["params"]["email"], "mate@example.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_lock_with_no_identifiable_holder_still_produces_a_code() {
|
||||
// Raw English here is what reaches a Russian user untranslated.
|
||||
for holder in [None, Some("")] {
|
||||
let err = lock_conflict_error("p1", holder, None);
|
||||
assert_eq!(err, r#"{"code":"PROFILE_LOCKED_ELSEWHERE"}"#);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,664 @@
|
||||
//! Enumerates extensions the user installed from inside the browser, by
|
||||
//! walking the Chromium profile directory on disk.
|
||||
//!
|
||||
//! Depends only on `std`, `serde_json`, the sibling `rules` module, and the
|
||||
//! shared profile-directory-name predicate, so the directory-layout handling —
|
||||
//! the riskiest part of detection — stays testable without app state.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use super::rules::{
|
||||
classify, keyword_hit, lookup_message, manifest_str, message_placeholder_key, signal_labels,
|
||||
signals_from_manifest, version_dir_sort_key, DetectedVpnExtension,
|
||||
};
|
||||
|
||||
/// Upper bound on extension directories walked per profile. A launch must not
|
||||
/// stall behind a pathological profile; whatever was found is still reported,
|
||||
/// flagged `partial`.
|
||||
const MAX_EXTENSION_DIRS: usize = 300;
|
||||
/// Manifests are a few KiB. Anything past this is not a manifest we can use.
|
||||
const MAX_MANIFEST_BYTES: u64 = 512 * 1024;
|
||||
/// Wall-clock ceiling for the whole scan. This runs on the launch path.
|
||||
const SCAN_DEADLINE: Duration = Duration::from_millis(750);
|
||||
/// Chromium preference files are larger than a manifest but still bounded; a
|
||||
/// pathological one must not be parsed while the user waits for a browser.
|
||||
const MAX_PREFERENCES_BYTES: u64 = 32 * 1024 * 1024;
|
||||
|
||||
/// Chromium profile directories to search inside a user-data dir.
|
||||
///
|
||||
/// Two layouts are real here: Donut launches Wayfern with only
|
||||
/// `--user-data-dir`, so Chromium uses `Default/`; but an imported profile is
|
||||
/// copied in as the profile directory itself, putting `Extensions/` at the
|
||||
/// root. Checking only one layout misses every profile of the other kind.
|
||||
fn candidate_profile_dirs(user_data_dir: &Path) -> Vec<PathBuf> {
|
||||
let mut dirs = Vec::new();
|
||||
|
||||
if user_data_dir.join("Preferences").exists() || user_data_dir.join("Extensions").is_dir() {
|
||||
dirs.push(user_data_dir.to_path_buf());
|
||||
}
|
||||
|
||||
if let Ok(entries) = std::fs::read_dir(user_data_dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if !path.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
|
||||
continue;
|
||||
};
|
||||
if crate::profile::clear_on_close::is_profile_dir_name(name)
|
||||
|| path.join("Preferences").exists()
|
||||
{
|
||||
dirs.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dirs.sort();
|
||||
dirs.dedup();
|
||||
dirs
|
||||
}
|
||||
|
||||
fn read_json_file(path: &Path, max_bytes: Option<u64>) -> Option<serde_json::Value> {
|
||||
let metadata = std::fs::metadata(path).ok()?;
|
||||
if !metadata.is_file() {
|
||||
return None;
|
||||
}
|
||||
if max_bytes.is_some_and(|cap| metadata.len() > cap) {
|
||||
return None;
|
||||
}
|
||||
serde_json::from_str(&std::fs::read_to_string(path).ok()?).ok()
|
||||
}
|
||||
|
||||
/// Chromium locale directory names are `[A-Za-z0-9_-]` (`en`, `en_GB`,
|
||||
/// `zh_CN`). Anything else in a manifest we did not write is untrusted input
|
||||
/// being joined into a filesystem path, so it is refused rather than sanitized
|
||||
/// — this runs on the launch path against extensions the user may have
|
||||
/// sideloaded.
|
||||
fn is_safe_locale_name(name: &str) -> bool {
|
||||
!name.is_empty()
|
||||
&& name.len() <= 32
|
||||
&& name
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
|
||||
}
|
||||
|
||||
fn resolve_dir_i18n(
|
||||
version_dir: &Path,
|
||||
manifest: &serde_json::Value,
|
||||
value: &str,
|
||||
) -> Option<String> {
|
||||
let key = message_placeholder_key(value)?;
|
||||
let default_locale = manifest.get("default_locale")?.as_str()?;
|
||||
if !is_safe_locale_name(default_locale) {
|
||||
log::warn!("Ignoring extension with a suspicious default_locale: {default_locale:?}");
|
||||
return None;
|
||||
}
|
||||
let messages = read_json_file(
|
||||
&version_dir
|
||||
.join("_locales")
|
||||
.join(default_locale)
|
||||
.join("messages.json"),
|
||||
Some(MAX_MANIFEST_BYTES),
|
||||
)?;
|
||||
lookup_message(&messages, &key)
|
||||
}
|
||||
|
||||
/// What the profile's preference files say about installed extensions.
|
||||
///
|
||||
/// Read-only on purpose: `Secure Preferences` is MAC-protected, and rewriting
|
||||
/// it invalidates the signature, which disables every extension in the profile.
|
||||
#[derive(Default)]
|
||||
struct PreferenceExtensions {
|
||||
/// Explicitly disabled. A disabled extension cannot touch the proxy, so
|
||||
/// warning about it would be a false alarm.
|
||||
disabled: HashSet<String>,
|
||||
/// Unpacked ("Load unpacked" / developer mode) extensions, which live
|
||||
/// OUTSIDE `Extensions/` and are therefore invisible to the directory walk.
|
||||
/// Sideloading is exactly how someone gets a VPN extension in without the
|
||||
/// Web Store, so missing these would leave the obvious hole open.
|
||||
unpacked: Vec<(String, PathBuf)>,
|
||||
}
|
||||
|
||||
fn preference_extensions(profile_dir: &Path) -> PreferenceExtensions {
|
||||
let mut out = PreferenceExtensions::default();
|
||||
for file in ["Secure Preferences", "Preferences"] {
|
||||
// Larger than a manifest, but still capped: this is parsed while the user
|
||||
// waits for a browser to start.
|
||||
let Some(prefs) = read_json_file(&profile_dir.join(file), Some(MAX_PREFERENCES_BYTES)) else {
|
||||
continue;
|
||||
};
|
||||
let Some(settings) = prefs
|
||||
.get("extensions")
|
||||
.and_then(|e| e.get("settings"))
|
||||
.and_then(|s| s.as_object())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
for (id, entry) in settings {
|
||||
// Chromium's Extension::State: 0 = disabled.
|
||||
if entry.get("state").and_then(serde_json::Value::as_i64) == Some(0) {
|
||||
out.disabled.insert(id.clone());
|
||||
continue;
|
||||
}
|
||||
// A packed extension's `path` is relative to Extensions/; an unpacked
|
||||
// one records an absolute path elsewhere on disk.
|
||||
if let Some(path) = entry.get("path").and_then(|p| p.as_str()) {
|
||||
let path = PathBuf::from(path);
|
||||
if path.is_absolute() {
|
||||
out.unpacked.push((id.clone(), path));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Walk a user-data dir for VPN/proxy extensions.
|
||||
///
|
||||
/// Returns false when the walk was cut short by a cap or the deadline, so the
|
||||
/// caller can report the scan as incomplete rather than clean.
|
||||
pub(super) fn scan_browser_extensions(
|
||||
user_data_dir: &Path,
|
||||
out: &mut Vec<DetectedVpnExtension>,
|
||||
started: Instant,
|
||||
) -> bool {
|
||||
let mut walked = 0usize;
|
||||
|
||||
for profile_dir in candidate_profile_dirs(user_data_dir) {
|
||||
// Read preferences first: unpacked extensions live outside Extensions/, so
|
||||
// a profile that has only sideloaded ones has no Extensions/ dir at all and
|
||||
// must not be skipped before they are considered.
|
||||
if started.elapsed() > SCAN_DEADLINE {
|
||||
return false;
|
||||
}
|
||||
let prefs = preference_extensions(&profile_dir);
|
||||
let disabled = &prefs.disabled;
|
||||
|
||||
for (crx_id, unpacked_dir) in &prefs.unpacked {
|
||||
if walked >= MAX_EXTENSION_DIRS || started.elapsed() > SCAN_DEADLINE {
|
||||
return false;
|
||||
}
|
||||
walked += 1;
|
||||
if let Some(found) = detect_in_version_dir(crx_id, unpacked_dir) {
|
||||
out.push(found);
|
||||
}
|
||||
}
|
||||
|
||||
let Ok(entries) = std::fs::read_dir(profile_dir.join("Extensions")) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
for entry in entries.flatten() {
|
||||
if walked >= MAX_EXTENSION_DIRS || started.elapsed() > SCAN_DEADLINE {
|
||||
return false;
|
||||
}
|
||||
walked += 1;
|
||||
|
||||
let ext_dir = entry.path();
|
||||
if !ext_dir.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let Some(crx_id) = ext_dir
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.map(str::to_string)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if disabled.contains(&crx_id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Several versions can coexist on disk; Chromium runs the highest.
|
||||
let Ok(version_entries) = std::fs::read_dir(&ext_dir) else {
|
||||
continue;
|
||||
};
|
||||
let mut versions: Vec<PathBuf> = version_entries
|
||||
.flatten()
|
||||
.map(|e| e.path())
|
||||
.filter(|p| p.is_dir())
|
||||
.collect();
|
||||
versions.sort_by_key(|p| {
|
||||
p.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.map(version_dir_sort_key)
|
||||
.unwrap_or_default()
|
||||
});
|
||||
let Some(version_dir) = versions.last() else {
|
||||
continue;
|
||||
};
|
||||
|
||||
if let Some(found) = detect_in_version_dir(&crx_id, version_dir) {
|
||||
out.push(found);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
/// Classify the extension whose unpacked files live in `version_dir`.
|
||||
/// Shared by the packed walk and the unpacked (developer-mode) entries.
|
||||
fn detect_in_version_dir(crx_id: &str, version_dir: &Path) -> Option<DetectedVpnExtension> {
|
||||
let manifest = read_json_file(&version_dir.join("manifest.json"), Some(MAX_MANIFEST_BYTES))?;
|
||||
|
||||
let raw_name = manifest_str(&manifest, "name").unwrap_or_else(|| crx_id.to_string());
|
||||
let name = resolve_dir_i18n(version_dir, &manifest, &raw_name).unwrap_or_else(|| {
|
||||
if message_placeholder_key(&raw_name).is_some() {
|
||||
crx_id.to_string()
|
||||
} else {
|
||||
raw_name.clone()
|
||||
}
|
||||
});
|
||||
let description = manifest_str(&manifest, "description").and_then(|d| {
|
||||
resolve_dir_i18n(version_dir, &manifest, &d).or(if message_placeholder_key(&d).is_some() {
|
||||
None
|
||||
} else {
|
||||
Some(d)
|
||||
})
|
||||
});
|
||||
|
||||
let signals = signals_from_manifest(&manifest);
|
||||
let keyword = keyword_hit(&name, description.as_deref());
|
||||
let confidence = classify(&signals, keyword)?;
|
||||
|
||||
Some(DetectedVpnExtension {
|
||||
key: format!("crx:{crx_id}"),
|
||||
name,
|
||||
version: manifest_str(&manifest, "version"),
|
||||
source: "browser".to_string(),
|
||||
confidence: confidence.to_string(),
|
||||
signals: signal_labels(&signals, keyword),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
|
||||
fn write(path: &Path, contents: &str) {
|
||||
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
fs::write(path, contents).unwrap();
|
||||
}
|
||||
|
||||
/// Build a Chromium `Preferences` blob for one extension.
|
||||
///
|
||||
/// Serialized rather than string-interpolated on purpose: a Windows path is
|
||||
/// `C:\Users\...`, and pasting it into a JSON string literal produces invalid
|
||||
/// escape sequences, so the file silently fails to parse and every assertion
|
||||
/// about it passes for the wrong reason.
|
||||
fn preferences_json(crx_id: &str, state: i64, path: &Path) -> String {
|
||||
serde_json::json!({
|
||||
"extensions": {
|
||||
"settings": {
|
||||
crx_id: { "state": state, "path": path.to_string_lossy() }
|
||||
}
|
||||
}
|
||||
})
|
||||
.to_string()
|
||||
}
|
||||
|
||||
const VPN_MANIFEST: &str = r#"{"name":"Turbo VPN","version":"2.1.0","permissions":["proxy"]}"#;
|
||||
const CRX_ID: &str = "abcdefghijklmnopabcdefghijklmnop";
|
||||
|
||||
#[test]
|
||||
fn candidate_dirs_accepts_the_default_layout() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
write(&root.join("Default").join("Preferences"), "{}");
|
||||
assert_eq!(candidate_profile_dirs(root), vec![root.join("Default")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn candidate_dirs_accepts_the_imported_root_layout() {
|
||||
// profile_importer copies a Chromium profile dir in as the root, so
|
||||
// Preferences and Extensions/ sit at the top level.
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
write(&root.join("Preferences"), "{}");
|
||||
assert_eq!(candidate_profile_dirs(root), vec![root.to_path_buf()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn candidate_dirs_finds_named_profile_dirs_without_preferences() {
|
||||
// Chromium writes Preferences lazily, so a populated Default/ can lack it.
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
fs::create_dir_all(root.join("Profile 2").join("Extensions")).unwrap();
|
||||
assert_eq!(candidate_profile_dirs(root), vec![root.join("Profile 2")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_finds_a_vpn_extension_in_the_default_layout() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
write(
|
||||
&root
|
||||
.join("Default")
|
||||
.join("Extensions")
|
||||
.join(CRX_ID)
|
||||
.join("2.1.0_0")
|
||||
.join("manifest.json"),
|
||||
VPN_MANIFEST,
|
||||
);
|
||||
|
||||
let mut out = Vec::new();
|
||||
assert!(scan_browser_extensions(root, &mut out, Instant::now()));
|
||||
assert_eq!(out.len(), 1);
|
||||
assert_eq!(out[0].key, format!("crx:{CRX_ID}"));
|
||||
assert_eq!(out[0].name, "Turbo VPN");
|
||||
assert_eq!(out[0].confidence, "confirmed");
|
||||
assert_eq!(out[0].source, "browser");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_finds_a_vpn_extension_in_the_imported_root_layout() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
write(&root.join("Preferences"), "{}");
|
||||
write(
|
||||
&root
|
||||
.join("Extensions")
|
||||
.join(CRX_ID)
|
||||
.join("2.1.0_0")
|
||||
.join("manifest.json"),
|
||||
VPN_MANIFEST,
|
||||
);
|
||||
|
||||
let mut out = Vec::new();
|
||||
assert!(scan_browser_extensions(root, &mut out, Instant::now()));
|
||||
assert_eq!(out.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_reads_the_highest_version_directory() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
let ext = root.join("Default").join("Extensions").join(CRX_ID);
|
||||
// Lexicographically "1.9.0_0" > "1.10.0_0"; numerically it is not.
|
||||
write(
|
||||
&ext.join("1.9.0_0").join("manifest.json"),
|
||||
r#"{"name":"Old","version":"1.9.0","permissions":["storage"]}"#,
|
||||
);
|
||||
write(
|
||||
&ext.join("1.10.0_0").join("manifest.json"),
|
||||
r#"{"name":"New VPN","version":"1.10.0","permissions":["proxy"]}"#,
|
||||
);
|
||||
|
||||
let mut out = Vec::new();
|
||||
assert!(scan_browser_extensions(root, &mut out, Instant::now()));
|
||||
assert_eq!(out.len(), 1);
|
||||
assert_eq!(out[0].version.as_deref(), Some("1.10.0"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_skips_extensions_chromium_has_disabled() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
let profile = root.join("Default");
|
||||
write(
|
||||
&profile
|
||||
.join("Extensions")
|
||||
.join(CRX_ID)
|
||||
.join("2.1.0_0")
|
||||
.join("manifest.json"),
|
||||
VPN_MANIFEST,
|
||||
);
|
||||
write(
|
||||
&profile.join("Secure Preferences"),
|
||||
&format!(r#"{{"extensions":{{"settings":{{"{CRX_ID}":{{"state":0}}}}}}}}"#),
|
||||
);
|
||||
|
||||
let mut out = Vec::new();
|
||||
assert!(scan_browser_extensions(root, &mut out, Instant::now()));
|
||||
assert!(
|
||||
out.is_empty(),
|
||||
"a disabled extension cannot touch the proxy"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_keeps_enabled_extensions_listed_in_preferences() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
let profile = root.join("Default");
|
||||
write(
|
||||
&profile
|
||||
.join("Extensions")
|
||||
.join(CRX_ID)
|
||||
.join("2.1.0_0")
|
||||
.join("manifest.json"),
|
||||
VPN_MANIFEST,
|
||||
);
|
||||
write(
|
||||
&profile.join("Secure Preferences"),
|
||||
&format!(r#"{{"extensions":{{"settings":{{"{CRX_ID}":{{"state":1}}}}}}}}"#),
|
||||
);
|
||||
|
||||
let mut out = Vec::new();
|
||||
assert!(scan_browser_extensions(root, &mut out, Instant::now()));
|
||||
assert_eq!(out.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_resolves_a_localized_extension_name() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
let version_dir = root
|
||||
.join("Default")
|
||||
.join("Extensions")
|
||||
.join(CRX_ID)
|
||||
.join("2.1.0_0");
|
||||
write(
|
||||
&version_dir.join("manifest.json"),
|
||||
r#"{"name":"__MSG_appName__","version":"2.1.0","default_locale":"en","permissions":["proxy"]}"#,
|
||||
);
|
||||
write(
|
||||
&version_dir
|
||||
.join("_locales")
|
||||
.join("en")
|
||||
.join("messages.json"),
|
||||
r#"{"appName":{"message":"Nord VPN"}}"#,
|
||||
);
|
||||
|
||||
let mut out = Vec::new();
|
||||
assert!(scan_browser_extensions(root, &mut out, Instant::now()));
|
||||
assert_eq!(out[0].name, "Nord VPN");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_falls_back_to_the_crx_id_when_a_placeholder_cannot_be_resolved() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
write(
|
||||
&root
|
||||
.join("Default")
|
||||
.join("Extensions")
|
||||
.join(CRX_ID)
|
||||
.join("2.1.0_0")
|
||||
.join("manifest.json"),
|
||||
r#"{"name":"__MSG_appName__","version":"2.1.0","permissions":["proxy"]}"#,
|
||||
);
|
||||
|
||||
let mut out = Vec::new();
|
||||
assert!(scan_browser_extensions(root, &mut out, Instant::now()));
|
||||
assert_eq!(out[0].name, CRX_ID, "never show a raw __MSG_ placeholder");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_ignores_an_ordinary_extension() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
write(
|
||||
&root
|
||||
.join("Default")
|
||||
.join("Extensions")
|
||||
.join(CRX_ID)
|
||||
.join("1.0.0_0")
|
||||
.join("manifest.json"),
|
||||
r#"{"name":"Dark Reader","version":"1.0.0","permissions":["storage","activeTab"]}"#,
|
||||
);
|
||||
|
||||
let mut out = Vec::new();
|
||||
assert!(scan_browser_extensions(root, &mut out, Instant::now()));
|
||||
assert!(out.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_reports_incomplete_when_the_deadline_has_passed() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
write(
|
||||
&root
|
||||
.join("Default")
|
||||
.join("Extensions")
|
||||
.join(CRX_ID)
|
||||
.join("2.1.0_0")
|
||||
.join("manifest.json"),
|
||||
VPN_MANIFEST,
|
||||
);
|
||||
|
||||
let mut out = Vec::new();
|
||||
// A start time already past the deadline stands in for a slow disk.
|
||||
let expired = Instant::now() - SCAN_DEADLINE - Duration::from_millis(10);
|
||||
assert!(!scan_browser_extensions(root, &mut out, expired));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_survives_a_malformed_manifest() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
write(
|
||||
&root
|
||||
.join("Default")
|
||||
.join("Extensions")
|
||||
.join(CRX_ID)
|
||||
.join("2.1.0_0")
|
||||
.join("manifest.json"),
|
||||
"{ not json",
|
||||
);
|
||||
|
||||
let mut out = Vec::new();
|
||||
assert!(scan_browser_extensions(root, &mut out, Instant::now()));
|
||||
assert!(out.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preferences_json_escapes_windows_style_paths() {
|
||||
// The Windows CI failure this guards: a raw `C:\Users\...` pasted into a
|
||||
// JSON string literal is invalid (`\U` is not an escape), so Preferences
|
||||
// failed to parse, `preference_extensions` returned nothing, and the
|
||||
// unpacked extension silently vanished — on Linux the same test passed
|
||||
// because POSIX paths contain no backslashes.
|
||||
let json = preferences_json(CRX_ID, 1, Path::new(r"C:\Users\runner\ext\my-vpn"));
|
||||
let parsed: serde_json::Value =
|
||||
serde_json::from_str(&json).expect("Preferences must be valid JSON on every platform");
|
||||
assert_eq!(
|
||||
parsed["extensions"]["settings"][CRX_ID]["path"],
|
||||
r"C:\Users\runner\ext\my-vpn"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_finds_an_unpacked_developer_mode_extension() {
|
||||
// Sideloading via "Load unpacked" is exactly how a VPN extension gets in
|
||||
// without the Web Store, and those files live outside Extensions/.
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
let unpacked = root.join("somewhere-else").join("my-vpn");
|
||||
write(&unpacked.join("manifest.json"), VPN_MANIFEST);
|
||||
write(
|
||||
&root.join("Default").join("Preferences"),
|
||||
&preferences_json(CRX_ID, 1, &unpacked),
|
||||
);
|
||||
|
||||
let mut out = Vec::new();
|
||||
assert!(scan_browser_extensions(root, &mut out, Instant::now()));
|
||||
assert_eq!(out.len(), 1, "unpacked extensions must not be invisible");
|
||||
assert_eq!(out[0].key, format!("crx:{CRX_ID}"));
|
||||
assert_eq!(out[0].confidence, "confirmed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_ignores_a_disabled_unpacked_extension() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
let unpacked = root.join("somewhere-else").join("my-vpn");
|
||||
write(&unpacked.join("manifest.json"), VPN_MANIFEST);
|
||||
write(
|
||||
&root.join("Default").join("Preferences"),
|
||||
&preferences_json(CRX_ID, 0, &unpacked),
|
||||
);
|
||||
|
||||
let mut out = Vec::new();
|
||||
assert!(scan_browser_extensions(root, &mut out, Instant::now()));
|
||||
assert!(out.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn packed_relative_paths_are_not_treated_as_unpacked() {
|
||||
// A packed extension records a path relative to Extensions/; following it
|
||||
// as if absolute would read the wrong place (or nothing).
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
write(
|
||||
&root.join("Default").join("Preferences"),
|
||||
&preferences_json(CRX_ID, 1, Path::new(&format!("{CRX_ID}/2.1.0_0"))),
|
||||
);
|
||||
|
||||
let mut out = Vec::new();
|
||||
assert!(scan_browser_extensions(root, &mut out, Instant::now()));
|
||||
assert!(out.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_traversing_default_locale_is_refused() {
|
||||
// `default_locale` comes from a manifest we did not write. Joined naively
|
||||
// it reads any file the user can read, on the launch path.
|
||||
assert!(!is_safe_locale_name("../../../../etc"));
|
||||
assert!(!is_safe_locale_name("..\\..\\windows"));
|
||||
assert!(!is_safe_locale_name("/etc/passwd"));
|
||||
assert!(!is_safe_locale_name(""));
|
||||
assert!(is_safe_locale_name("en"));
|
||||
assert!(is_safe_locale_name("en_GB"));
|
||||
assert!(is_safe_locale_name("zh-CN"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_localized_name_with_a_traversing_locale_is_not_resolved() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
let version_dir = root
|
||||
.join("Default")
|
||||
.join("Extensions")
|
||||
.join(CRX_ID)
|
||||
.join("2.1.0_0");
|
||||
write(
|
||||
&version_dir.join("manifest.json"),
|
||||
r#"{"name":"__MSG_appName__","version":"2.1.0","default_locale":"../../../../etc","permissions":["proxy"]}"#,
|
||||
);
|
||||
|
||||
let mut out = Vec::new();
|
||||
assert!(scan_browser_extensions(root, &mut out, Instant::now()));
|
||||
// Still detected (the proxy permission is what matters), but the name
|
||||
// falls back rather than the traversal being followed.
|
||||
assert_eq!(out.len(), 1);
|
||||
assert_eq!(out[0].name, CRX_ID);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_tolerates_a_missing_user_data_dir() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mut out = Vec::new();
|
||||
assert!(scan_browser_extensions(
|
||||
&tmp.path().join("nope"),
|
||||
&mut out,
|
||||
Instant::now()
|
||||
));
|
||||
assert!(out.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
//! Detects VPN/proxy browser extensions present in a profile.
|
||||
//!
|
||||
//! An extension holding Chromium's `proxy` permission can override the proxy
|
||||
//! Donut passes on the command line, so the browser's real exit stops being the
|
||||
//! one Donut measured and generated the fingerprint against. That produces
|
||||
//! exactly the geo/timezone/language mismatch the fingerprint exists to avoid,
|
||||
//! except Donut cannot observe it from the outside — hence a launch-time
|
||||
//! warning rather than a measurement.
|
||||
//!
|
||||
//! Two sources, deliberately both: Donut-managed extensions live in the app's
|
||||
//! own store and are handed to Chromium via `--load-extension` from *outside*
|
||||
//! the profile directory, while extensions the user installed from the Web
|
||||
//! Store live *inside* it. Neither set appears in the other.
|
||||
|
||||
mod browser_scan;
|
||||
mod rules;
|
||||
|
||||
// `message_placeholder_key`/`lookup_message` are shared with
|
||||
// `extension_manager`, which resolves the same placeholders out of a zip.
|
||||
use rules::{classify, keyword_hit, manifest_str, signal_labels, signals_from_manifest};
|
||||
pub use rules::{lookup_message, message_placeholder_key, DetectedVpnExtension};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashSet;
|
||||
use std::time::Instant;
|
||||
|
||||
use crate::profile::types::BrowserProfile;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct ExtensionScan {
|
||||
pub extensions: Vec<DetectedVpnExtension>,
|
||||
/// `scanned` | `partial` | `encrypted` | `ephemeral` | `missing`.
|
||||
///
|
||||
/// Reported honestly so the dialog can say the scan was incomplete rather
|
||||
/// than implying a clean profile it never managed to read.
|
||||
pub scan_state: String,
|
||||
}
|
||||
|
||||
/// Donut-managed extensions, reached through the profile's extension group.
|
||||
///
|
||||
/// Read live from the stored archive rather than from the metadata cached on
|
||||
/// `Extension`, so replacing an extension's file cannot leave a stale verdict
|
||||
/// behind. N is the group size — typically a handful.
|
||||
fn scan_donut_extensions(profile: &BrowserProfile, out: &mut Vec<DetectedVpnExtension>) {
|
||||
let Some(group_id) = &profile.extension_group_id else {
|
||||
return;
|
||||
};
|
||||
let Ok(manager) = crate::extension_manager::EXTENSION_MANAGER.lock() else {
|
||||
log::warn!("VPN extension scan: extension manager lock poisoned, skipping managed extensions");
|
||||
return;
|
||||
};
|
||||
let Ok(group) = manager.get_group(group_id) else {
|
||||
return;
|
||||
};
|
||||
|
||||
for ext_id in &group.extension_ids {
|
||||
let Ok(ext) = manager.get_extension(ext_id) else {
|
||||
continue;
|
||||
};
|
||||
let path = manager.get_file_dir_public(ext_id).join(&ext.file_name);
|
||||
let Ok(data) = std::fs::read(&path) else {
|
||||
continue;
|
||||
};
|
||||
let Some(manifest) =
|
||||
crate::extension_manager::read_manifest_from_archive(&data, &ext.file_type)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let raw_name = manifest_str(&manifest, "name").unwrap_or_else(|| ext.name.clone());
|
||||
let name =
|
||||
crate::extension_manager::resolve_archive_i18n(&data, &ext.file_type, &manifest, &raw_name)
|
||||
.unwrap_or_else(|| {
|
||||
// An unresolvable placeholder is not a name — fall back to the one
|
||||
// the extension carries in Donut.
|
||||
if message_placeholder_key(&raw_name).is_some() {
|
||||
ext.name.clone()
|
||||
} else {
|
||||
raw_name.clone()
|
||||
}
|
||||
});
|
||||
let description = manifest_str(&manifest, "description").and_then(|d| {
|
||||
crate::extension_manager::resolve_archive_i18n(&data, &ext.file_type, &manifest, &d).or(
|
||||
if message_placeholder_key(&d).is_some() {
|
||||
None
|
||||
} else {
|
||||
Some(d)
|
||||
},
|
||||
)
|
||||
});
|
||||
|
||||
let signals = signals_from_manifest(&manifest);
|
||||
let keyword = keyword_hit(&name, description.as_deref());
|
||||
let Some(confidence) = classify(&signals, keyword) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
out.push(DetectedVpnExtension {
|
||||
key: format!("donut:{ext_id}"),
|
||||
name,
|
||||
version: manifest_str(&manifest, "version").or_else(|| ext.version.clone()),
|
||||
source: "donut".to_string(),
|
||||
confidence: confidence.to_string(),
|
||||
signals: signal_labels(&signals, keyword),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Scan a profile for VPN/proxy extensions from both sources.
|
||||
///
|
||||
/// Never fails: an unreadable profile reports whatever it could see plus a
|
||||
/// `scan_state` explaining why the picture is incomplete.
|
||||
pub fn scan_profile(profile: &BrowserProfile) -> ExtensionScan {
|
||||
let started = Instant::now();
|
||||
let mut extensions = Vec::new();
|
||||
|
||||
scan_donut_extensions(profile, &mut extensions);
|
||||
|
||||
let profiles_dir = crate::app_dirs::profiles_dir();
|
||||
let user_data_dir = crate::ephemeral_dirs::get_effective_profile_path(profile, &profiles_dir);
|
||||
|
||||
// `get_effective_profile_path` only returns the decrypted RAM copy while the
|
||||
// profile is unlocked; locked, it falls back to the on-disk directory, which
|
||||
// exists but is ciphertext. Walking that finds nothing — so the state has to
|
||||
// be decided on whether we actually got a readable copy, not on the path
|
||||
// existing, or a locked profile reports as verified-clean.
|
||||
let has_plaintext_dir = !(profile.password_protected || profile.ephemeral)
|
||||
|| crate::ephemeral_dirs::get_ephemeral_dir(&profile.id.to_string()).is_some();
|
||||
|
||||
let scan_state = if !has_plaintext_dir {
|
||||
if profile.password_protected {
|
||||
"encrypted"
|
||||
} else {
|
||||
"ephemeral"
|
||||
}
|
||||
} else if !user_data_dir.is_dir() {
|
||||
// Never launched, so there is no profile directory to inspect yet.
|
||||
"missing"
|
||||
} else if browser_scan::scan_browser_extensions(&user_data_dir, &mut extensions, started) {
|
||||
"scanned"
|
||||
} else {
|
||||
"partial"
|
||||
};
|
||||
|
||||
// Collapse only exact duplicates of the same extension. `key` is the real
|
||||
// identity (`donut:<uuid>` / `crx:<id>`); name+version is not, and two
|
||||
// distinct extensions sharing a display name would silently fold into one —
|
||||
// dropping a `confirmed` detection would then flip `has_confirmed()` and stop
|
||||
// the gate treating its own exit measurement as unreliable.
|
||||
let mut seen = HashSet::new();
|
||||
extensions.retain(|e| seen.insert(e.key.clone()));
|
||||
|
||||
ExtensionScan {
|
||||
extensions,
|
||||
scan_state: scan_state.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// True when at least one detection is `confirmed` — the extension holds the
|
||||
/// `proxy` permission and can actually redirect the browser's traffic.
|
||||
pub fn has_confirmed(scan: &ExtensionScan) -> bool {
|
||||
scan.extensions.iter().any(|e| e.confidence == "confirmed")
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
//! Pure classification rules for VPN/proxy extension detection.
|
||||
//!
|
||||
//! Deliberately free of crate-internal dependencies (`std` + `serde_json`
|
||||
//! only): these rules are the heart of the feature and the part most worth
|
||||
//! testing in isolation, so nothing here may reach for app state, the
|
||||
//! filesystem, or the network. Enumerating the two extension sources and
|
||||
//! reading them off disk lives in the parent module.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Substrings that corroborate a request-blocking extension being a VPN.
|
||||
/// Matched case-insensitively against name + description.
|
||||
const KEYWORDS: &[&str] = &[
|
||||
"vpn",
|
||||
"proxy",
|
||||
"tunnel",
|
||||
"unblock",
|
||||
"wireguard",
|
||||
"shadowsocks",
|
||||
"socks",
|
||||
];
|
||||
|
||||
/// Matched as a whole token rather than a substring — too short to be safe
|
||||
/// inside other words ("warped", "warpaint").
|
||||
const TOKEN_KEYWORDS: &[&str] = &["warp"];
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct DetectedVpnExtension {
|
||||
/// Stable acknowledgement identity: `donut:<uuid>` or `crx:<32-char-id>`.
|
||||
pub key: String,
|
||||
pub name: String,
|
||||
pub version: Option<String>,
|
||||
/// `"donut"` (managed by Donut) or `"browser"` (installed inside the profile).
|
||||
pub source: String,
|
||||
/// `"confirmed"` or `"likely"`.
|
||||
pub confidence: String,
|
||||
/// Why it matched, for the dialog's detail line.
|
||||
pub signals: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct ManifestSignals {
|
||||
pub proxy_permission: bool,
|
||||
pub optional_proxy_permission: bool,
|
||||
pub declarative_net_request: bool,
|
||||
pub web_request_blocking: bool,
|
||||
pub broad_host_permissions: bool,
|
||||
}
|
||||
|
||||
fn string_list<'a>(manifest: &'a serde_json::Value, key: &str) -> Vec<&'a str> {
|
||||
manifest
|
||||
.get(key)
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|a| a.iter().filter_map(|v| v.as_str()).collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn is_broad_host(pattern: &str) -> bool {
|
||||
matches!(pattern, "<all_urls>" | "*://*/*")
|
||||
}
|
||||
|
||||
pub fn signals_from_manifest(manifest: &serde_json::Value) -> ManifestSignals {
|
||||
let permissions = string_list(manifest, "permissions");
|
||||
let optional_permissions = string_list(manifest, "optional_permissions");
|
||||
let host_permissions = string_list(manifest, "host_permissions");
|
||||
let optional_host_permissions = string_list(manifest, "optional_host_permissions");
|
||||
|
||||
let has = |list: &[&str], name: &str| list.contains(&name);
|
||||
|
||||
// MV2 keeps host patterns inside `permissions`; MV3 splits them into
|
||||
// `host_permissions`. Look in both so one manifest version isn't silently
|
||||
// under-detected.
|
||||
let all_hosts: Vec<&str> = permissions
|
||||
.iter()
|
||||
.chain(host_permissions.iter())
|
||||
.chain(optional_host_permissions.iter())
|
||||
.copied()
|
||||
.collect();
|
||||
let broad = all_hosts.iter().any(|p| is_broad_host(p))
|
||||
|| (all_hosts.contains(&"http://*/*") && all_hosts.contains(&"https://*/*"));
|
||||
|
||||
ManifestSignals {
|
||||
proxy_permission: has(&permissions, "proxy"),
|
||||
optional_proxy_permission: has(&optional_permissions, "proxy"),
|
||||
declarative_net_request: has(&permissions, "declarativeNetRequest")
|
||||
|| has(&permissions, "declarativeNetRequestWithHostAccess"),
|
||||
web_request_blocking: has(&permissions, "webRequest")
|
||||
&& has(&permissions, "webRequestBlocking"),
|
||||
broad_host_permissions: broad,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn keyword_hit(name: &str, description: Option<&str>) -> bool {
|
||||
let mut haystack = name.to_lowercase();
|
||||
if let Some(d) = description {
|
||||
haystack.push(' ');
|
||||
haystack.push_str(&d.to_lowercase());
|
||||
}
|
||||
if KEYWORDS.iter().any(|k| haystack.contains(k)) {
|
||||
return true;
|
||||
}
|
||||
haystack
|
||||
.split(|c: char| !c.is_alphanumeric())
|
||||
.any(|token| TOKEN_KEYWORDS.contains(&token))
|
||||
}
|
||||
|
||||
/// Classify an extension from its manifest signals.
|
||||
///
|
||||
/// The `proxy` permission is the only signal that *proves* the capability: it
|
||||
/// is what Chromium requires to call `chrome.proxy`, and it stays in
|
||||
/// `permissions` under both manifest versions because it is an API permission,
|
||||
/// not a host pattern.
|
||||
///
|
||||
/// The request-blocking tier additionally requires a keyword, and that
|
||||
/// corroboration is not optional: `declarativeNetRequest` plus `<all_urls>`
|
||||
/// describes every content blocker in the ecosystem, so without it the warning
|
||||
/// fires on uBlock Origin — which would teach users to dismiss the dialog on
|
||||
/// sight, destroying the value of the mismatch block that shares it.
|
||||
pub fn classify(signals: &ManifestSignals, keyword: bool) -> Option<&'static str> {
|
||||
if signals.proxy_permission {
|
||||
return Some("confirmed");
|
||||
}
|
||||
if signals.optional_proxy_permission {
|
||||
return Some("likely");
|
||||
}
|
||||
if (signals.declarative_net_request || signals.web_request_blocking)
|
||||
&& signals.broad_host_permissions
|
||||
&& keyword
|
||||
{
|
||||
return Some("likely");
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub fn signal_labels(signals: &ManifestSignals, keyword: bool) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
if signals.proxy_permission {
|
||||
out.push("permissions:proxy".to_string());
|
||||
}
|
||||
if signals.optional_proxy_permission {
|
||||
out.push("optionalPermissions:proxy".to_string());
|
||||
}
|
||||
if signals.declarative_net_request {
|
||||
out.push("declarativeNetRequest".to_string());
|
||||
}
|
||||
if signals.web_request_blocking {
|
||||
out.push("webRequestBlocking".to_string());
|
||||
}
|
||||
if signals.broad_host_permissions {
|
||||
out.push("broadHostPermissions".to_string());
|
||||
}
|
||||
if keyword {
|
||||
out.push("keyword".to_string());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// `__MSG_someKey__` -> `someKey`.
|
||||
pub fn message_placeholder_key(value: &str) -> Option<String> {
|
||||
value
|
||||
.strip_prefix("__MSG_")
|
||||
.and_then(|rest| rest.strip_suffix("__"))
|
||||
.map(str::to_string)
|
||||
}
|
||||
|
||||
/// Chromium's `messages.json` shape: `{ "key": { "message": "..." } }`, with
|
||||
/// keys compared case-insensitively.
|
||||
pub fn lookup_message(messages: &serde_json::Value, key: &str) -> Option<String> {
|
||||
let obj = messages.as_object()?;
|
||||
obj
|
||||
.iter()
|
||||
.find(|(k, _)| k.eq_ignore_ascii_case(key))
|
||||
.and_then(|(_, v)| v.get("message"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::to_string)
|
||||
}
|
||||
|
||||
pub fn manifest_str(manifest: &serde_json::Value, key: &str) -> Option<String> {
|
||||
manifest
|
||||
.get(key)
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::to_string)
|
||||
}
|
||||
|
||||
/// Sort key for an extension version directory (`1.10.0_0`), compared
|
||||
/// numerically so `1.10.0` sorts above `1.9.0` where a lexicographic compare
|
||||
/// would put it below.
|
||||
pub fn version_dir_sort_key(name: &str) -> Vec<u64> {
|
||||
name
|
||||
.split(['.', '_'])
|
||||
.map(|part| part.parse::<u64>().unwrap_or(0))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn signals_of(manifest: serde_json::Value) -> ManifestSignals {
|
||||
signals_from_manifest(&manifest)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_confirms_on_proxy_permission() {
|
||||
let s = signals_of(json!({ "permissions": ["proxy", "storage"] }));
|
||||
assert!(s.proxy_permission);
|
||||
assert_eq!(classify(&s, false), Some("confirmed"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_confirms_proxy_permission_in_mv2() {
|
||||
// `proxy` is an API permission, so MV3's host_permissions split does not
|
||||
// move it — the same key works for both manifest versions.
|
||||
let s = signals_of(json!({
|
||||
"manifest_version": 2,
|
||||
"permissions": ["proxy", "<all_urls>", "webRequest"]
|
||||
}));
|
||||
assert_eq!(classify(&s, false), Some("confirmed"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_likely_on_optional_proxy() {
|
||||
let s = signals_of(json!({ "optional_permissions": ["proxy"] }));
|
||||
assert_eq!(classify(&s, false), Some("likely"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_ignores_content_blocker() {
|
||||
// The regression guard: a content blocker declares exactly these and is
|
||||
// not a VPN. Firing here would train users to dismiss the dialog.
|
||||
let s = signals_of(json!({
|
||||
"permissions": ["declarativeNetRequest"],
|
||||
"host_permissions": ["<all_urls>"]
|
||||
}));
|
||||
assert!(s.declarative_net_request && s.broad_host_permissions);
|
||||
assert_eq!(classify(&s, keyword_hit("uBlock Origin", None)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_likely_on_dnr_plus_keyword() {
|
||||
let s = signals_of(json!({
|
||||
"permissions": ["declarativeNetRequest"],
|
||||
"host_permissions": ["<all_urls>"]
|
||||
}));
|
||||
assert_eq!(
|
||||
classify(&s, keyword_hit("Free VPN Proxy", None)),
|
||||
Some("likely")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_ignores_keyword_only() {
|
||||
// A name alone proves nothing; without a capability signal this is noise.
|
||||
let s = signals_of(json!({ "permissions": ["storage"] }));
|
||||
assert_eq!(classify(&s, keyword_hit("VPN Deals Finder", None)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_requires_broad_hosts_for_the_blocking_tier() {
|
||||
let s = signals_of(json!({
|
||||
"permissions": ["declarativeNetRequest"],
|
||||
"host_permissions": ["https://example.com/*"]
|
||||
}));
|
||||
assert_eq!(classify(&s, keyword_hit("Some VPN", None)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn broad_hosts_detected_from_split_http_and_https() {
|
||||
let s = signals_of(json!({
|
||||
"permissions": ["webRequest", "webRequestBlocking"],
|
||||
"host_permissions": ["http://*/*", "https://*/*"]
|
||||
}));
|
||||
assert!(s.broad_host_permissions);
|
||||
assert!(s.web_request_blocking);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn broad_hosts_detected_from_mv2_permissions_array() {
|
||||
// MV2 puts host patterns in `permissions`; the split-out key is absent.
|
||||
let s = signals_of(json!({
|
||||
"manifest_version": 2,
|
||||
"permissions": ["webRequest", "webRequestBlocking", "<all_urls>"]
|
||||
}));
|
||||
assert!(s.broad_host_permissions);
|
||||
assert_eq!(classify(&s, keyword_hit("Turbo VPN", None)), Some("likely"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keyword_matching_is_substring_but_token_bound_for_short_terms() {
|
||||
assert!(keyword_hit("TouchVPN", None));
|
||||
assert!(keyword_hit("Unblock Sites", None));
|
||||
assert!(keyword_hit("Cloudflare WARP", None));
|
||||
// "warp" only matches as a whole token, so this must not hit.
|
||||
assert!(!keyword_hit("Time Warped Clock", None));
|
||||
assert!(keyword_hit(
|
||||
"Anything",
|
||||
Some("a fast tunnel for your browser")
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn message_placeholder_round_trip() {
|
||||
assert_eq!(
|
||||
message_placeholder_key("__MSG_appName__").as_deref(),
|
||||
Some("appName")
|
||||
);
|
||||
assert_eq!(message_placeholder_key("Plain Name"), None);
|
||||
let messages = json!({ "appName": { "message": "Nord VPN" } });
|
||||
assert_eq!(
|
||||
lookup_message(&messages, "appName").as_deref(),
|
||||
Some("Nord VPN")
|
||||
);
|
||||
// Chromium compares message keys case-insensitively.
|
||||
assert_eq!(
|
||||
lookup_message(&messages, "APPNAME").as_deref(),
|
||||
Some("Nord VPN")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_dirs_sort_numerically_not_lexicographically() {
|
||||
let mut dirs = ["1.9.0_0", "1.10.0_0", "1.2.0_0"];
|
||||
dirs.sort_by_key(|d| version_dir_sort_key(d));
|
||||
assert_eq!(dirs.last(), Some(&"1.10.0_0"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_manifest_yields_no_signals() {
|
||||
// Arrays of non-strings, wrong types, and missing keys must not panic.
|
||||
let s = signals_of(json!({ "permissions": [1, 2, {"a": "b"}], "host_permissions": "nope" }));
|
||||
assert_eq!(s, ManifestSignals::default());
|
||||
assert_eq!(classify(&s, true), None);
|
||||
}
|
||||
}
|
||||
@@ -99,7 +99,50 @@ async fn wait_for_vpn_worker_ready(
|
||||
}
|
||||
}
|
||||
|
||||
/// Serializes worker startup for a given VPN, so two concurrent launches cannot
|
||||
/// both observe "no worker" and both believe they created it. Benign until a
|
||||
/// launch guard may stop one on failure; then double-ownership means a
|
||||
/// cancelled launch tears down a tunnel another profile is using. Mirrors
|
||||
/// `xray_worker_runner::XRAY_START_LOCK`.
|
||||
static VPN_START_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
|
||||
|
||||
/// A started VPN worker plus whether *this* call spawned it.
|
||||
pub struct VpnWorkerStart {
|
||||
pub config: VpnWorkerConfig,
|
||||
/// False when an already-running worker was adopted. Only the creator may
|
||||
/// stop it while unwinding a failed launch.
|
||||
pub created: bool,
|
||||
}
|
||||
|
||||
/// Whether any profile with a live browser process is routing through this VPN.
|
||||
///
|
||||
/// Extracted from the startup sweep so the launch guard and the sweep agree on
|
||||
/// what "in use" means instead of each carrying its own copy.
|
||||
pub fn vpn_id_in_use_by_running_browser(vpn_id: &str) -> bool {
|
||||
let Ok(profiles) = crate::profile::ProfileManager::instance().list_profiles() else {
|
||||
// Unable to tell — assume in use rather than tear down a live tunnel.
|
||||
return true;
|
||||
};
|
||||
profiles
|
||||
.iter()
|
||||
.filter(|p| p.process_id.is_some_and(is_process_running))
|
||||
.any(|p| p.vpn_id.as_deref() == Some(vpn_id))
|
||||
}
|
||||
|
||||
/// Hold the start lock across an adopt-sensitive section (a launch guard
|
||||
/// deciding whether to stop a worker it created).
|
||||
pub async fn lock_vpn_starts() -> tokio::sync::MutexGuard<'static, ()> {
|
||||
VPN_START_LOCK.lock().await
|
||||
}
|
||||
|
||||
pub async fn start_vpn_worker(vpn_id: &str) -> Result<VpnWorkerConfig, Box<dyn std::error::Error>> {
|
||||
start_vpn_worker_tracked(vpn_id).await.map(|s| s.config)
|
||||
}
|
||||
|
||||
pub async fn start_vpn_worker_tracked(
|
||||
vpn_id: &str,
|
||||
) -> Result<VpnWorkerStart, Box<dyn std::error::Error>> {
|
||||
let _start_guard = VPN_START_LOCK.lock().await;
|
||||
crate::proxy_runner::ensure_sidecar_version().await?;
|
||||
|
||||
for config in list_vpn_worker_configs() {
|
||||
@@ -117,10 +160,18 @@ pub async fn start_vpn_worker(vpn_id: &str) -> Result<VpnWorkerConfig, Box<dyn s
|
||||
if let Some(pid) = existing.pid {
|
||||
if is_process_running(pid) {
|
||||
if vpn_worker_accepting_connections(&existing).await {
|
||||
return Ok(existing);
|
||||
return Ok(VpnWorkerStart {
|
||||
config: existing,
|
||||
created: false,
|
||||
});
|
||||
}
|
||||
|
||||
return wait_for_vpn_worker_ready(&existing.id).await;
|
||||
return wait_for_vpn_worker_ready(&existing.id)
|
||||
.await
|
||||
.map(|config| VpnWorkerStart {
|
||||
config,
|
||||
created: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
// Worker config exists but process is dead, clean up
|
||||
@@ -263,7 +314,12 @@ pub async fn start_vpn_worker(vpn_id: &str) -> Result<VpnWorkerConfig, Box<dyn s
|
||||
drop(child);
|
||||
}
|
||||
|
||||
wait_for_vpn_worker_ready(&id).await
|
||||
wait_for_vpn_worker_ready(&id)
|
||||
.await
|
||||
.map(|config| VpnWorkerStart {
|
||||
config,
|
||||
created: true,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn stop_vpn_worker(id: &str) -> Result<bool, Box<dyn std::error::Error>> {
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
//! Client-side window decorations on Linux.
|
||||
//!
|
||||
//! The app draws its own titlebar (as it already does on macOS and Windows), so
|
||||
//! the window is built without server-side decorations. That means the app also
|
||||
//! owns the window *controls*, and their side and order are a desktop-wide user
|
||||
//! preference that differs between environments — GNOME defaults to
|
||||
//! `:minimize,maximize,close` (all on the right), and a user who has moved them
|
||||
//! to the left expects every app to follow.
|
||||
//!
|
||||
//! `GtkSettings::gtk-decoration-layout` is the one place every desktop
|
||||
//! publishes that preference to GTK applications: GNOME mirrors
|
||||
//! `org.gnome.desktop.wm.preferences button-layout` into it, and on KDE Plasma
|
||||
//! `kde-gtk-config` mirrors KWin's decoration button configuration into it.
|
||||
//! Reading this property therefore gets both environments right without any
|
||||
//! desktop-specific branching.
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
/// Whether this window draws its own titlebar, and how.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct WindowDecorations {
|
||||
/// True when the app owns the titlebar and must draw controls and resize
|
||||
/// edges. False means the platform still draws a real titlebar and the
|
||||
/// frontend must render nothing.
|
||||
pub client_side: bool,
|
||||
/// The desktop's button layout, e.g. `":minimize,maximize,close"`. Only
|
||||
/// meaningful when `client_side` is true.
|
||||
pub layout: Option<String>,
|
||||
}
|
||||
|
||||
/// Whether to drop server-side decorations on this Linux session.
|
||||
///
|
||||
/// Enabled everywhere except KDE Plasma on Wayland, and overridable with
|
||||
/// `DONUT_LINUX_CLIENT_DECORATIONS=1|0`.
|
||||
///
|
||||
/// The KDE/Wayland exclusion is deliberate and is about a failure mode, not a
|
||||
/// preference. GTK3 speaks no `xdg-decoration`; when a window is built
|
||||
/// undecorated, GTK does not mark it client-decorated, and on Wayland it
|
||||
/// therefore *announces server-side decorations* to the compositor. mutter
|
||||
/// ignores that (it never decorates Wayland toplevels), which is why GNOME
|
||||
/// works. KWin honors it, so Plasma would be free to draw a Breeze titlebar
|
||||
/// directly above the one the app draws — two titlebars, worse than the
|
||||
/// feature is good. Whether it actually does depends on the decoration mode
|
||||
/// KWin advertises, which could not be established from documentation and
|
||||
/// cannot be tested from here, so this stays off until somebody can run it.
|
||||
///
|
||||
/// KDE on X11 is *not* excluded: there the request travels as `_MOTIF_WM_HINTS`,
|
||||
/// which KWin has honored for as long as it has existed.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn use_client_side_decorations() -> bool {
|
||||
if let Ok(value) = std::env::var("DONUT_LINUX_CLIENT_DECORATIONS") {
|
||||
let forced = matches!(value.trim(), "1" | "true" | "yes");
|
||||
log::info!("Client-side decorations forced to {forced} by DONUT_LINUX_CLIENT_DECORATIONS");
|
||||
return forced;
|
||||
}
|
||||
|
||||
let env = |key: &str| std::env::var(key).unwrap_or_default().to_lowercase();
|
||||
|
||||
// GDK_BACKEND is a comma-separated preference list ("wayland,x11"), and GDK
|
||||
// takes the FIRST entry it can open. Testing for a substring would read
|
||||
// "wayland,x11" as X11 and hand a Plasma Wayland session the undecorated
|
||||
// path this guard exists to withhold.
|
||||
let backend = env("GDK_BACKEND");
|
||||
let preferred = backend
|
||||
.split(',')
|
||||
.map(str::trim)
|
||||
.find(|value| !value.is_empty());
|
||||
let on_wayland = match preferred {
|
||||
Some("x11") => false,
|
||||
Some("wayland") => true,
|
||||
// Unset or something exotic: fall back to what the session advertises.
|
||||
_ => {
|
||||
!std::env::var("WAYLAND_DISPLAY")
|
||||
.unwrap_or_default()
|
||||
.is_empty()
|
||||
&& env("XDG_SESSION_TYPE") != "x11"
|
||||
}
|
||||
};
|
||||
|
||||
let on_kde = env("XDG_CURRENT_DESKTOP").contains("kde")
|
||||
|| env("XDG_SESSION_DESKTOP").contains("plasma")
|
||||
|| env("DESKTOP_SESSION").contains("plasma")
|
||||
|| !std::env::var("KDE_FULL_SESSION")
|
||||
.unwrap_or_default()
|
||||
.is_empty();
|
||||
|
||||
if on_kde && on_wayland {
|
||||
log::info!(
|
||||
"Keeping server-side decorations: KWin on Wayland may draw its own titlebar over the \
|
||||
app's. Set DONUT_LINUX_CLIENT_DECORATIONS=1 to override."
|
||||
);
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
mod imp {
|
||||
use std::sync::Mutex;
|
||||
|
||||
lazy_static::lazy_static! {
|
||||
static ref LAYOUT: Mutex<Option<String>> = Mutex::new(None);
|
||||
}
|
||||
|
||||
fn store(layout: Option<String>) {
|
||||
if let Ok(mut slot) = LAYOUT.lock() {
|
||||
*slot = layout;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn cached() -> Option<String> {
|
||||
LAYOUT.lock().ok().and_then(|slot| slot.clone())
|
||||
}
|
||||
|
||||
/// Read the layout and subscribe to changes.
|
||||
///
|
||||
/// MUST be called on the GTK main thread — `gtk::Settings::default()` panics
|
||||
/// elsewhere, and the notify subscription has to be attached from the thread
|
||||
/// owning the GTK main context. The app's `setup` hook already runs there.
|
||||
pub fn init<R: tauri::Runtime>(app: &tauri::AppHandle<R>) {
|
||||
use gtk::prelude::*;
|
||||
use tauri::Emitter;
|
||||
|
||||
let Some(settings) = gtk::Settings::default() else {
|
||||
log::warn!("No GTK settings available; using the default decoration layout");
|
||||
return;
|
||||
};
|
||||
|
||||
store(settings.gtk_decoration_layout().map(|v| v.to_string()));
|
||||
log::info!(
|
||||
"Window decoration layout: {}",
|
||||
cached().as_deref().unwrap_or("<unset>")
|
||||
);
|
||||
|
||||
// The user can rearrange titlebar buttons while the app is running, and
|
||||
// every other application follows immediately. Note this never fires where
|
||||
// the value comes only from gtk-3.0/settings.ini (a KDE X11 box with no
|
||||
// xsettings daemon) — there it is simply static until restart.
|
||||
let handle = app.clone();
|
||||
settings.connect_gtk_decoration_layout_notify(move |settings| {
|
||||
let layout = settings.gtk_decoration_layout().map(|v| v.to_string());
|
||||
log::info!(
|
||||
"Window decoration layout changed to: {}",
|
||||
layout.as_deref().unwrap_or("<unset>")
|
||||
);
|
||||
store(layout.clone());
|
||||
if let Err(e) = handle.emit("window-decoration-layout-changed", layout) {
|
||||
log::warn!("Failed to emit window decoration layout change: {e}");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
mod imp {
|
||||
pub fn init<R: tauri::Runtime>(_app: &tauri::AppHandle<R>) {}
|
||||
}
|
||||
|
||||
pub use imp::init;
|
||||
|
||||
/// How this window is decorated, and the desktop's button layout when the app
|
||||
/// owns the titlebar.
|
||||
#[tauri::command]
|
||||
pub fn get_window_decoration_layout() -> WindowDecorations {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let client_side = use_client_side_decorations();
|
||||
WindowDecorations {
|
||||
client_side,
|
||||
layout: if client_side { imp::cached() } else { None },
|
||||
}
|
||||
}
|
||||
// Every other platform keeps a real titlebar: macOS makes the native one
|
||||
// transparent, Windows draws its own controls on a fixed layout.
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
WindowDecorations {
|
||||
client_side: false,
|
||||
layout: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -27,3 +27,42 @@ pub enum XrayError {
|
||||
#[error("failed to serialize Xray client configuration")]
|
||||
Serialization,
|
||||
}
|
||||
|
||||
impl XrayError {
|
||||
/// A stable, translatable identifier for *why* a URI was rejected.
|
||||
///
|
||||
/// Donut supports one VLESS shape — REALITY + XTLS Vision over TCP — so most
|
||||
/// rejections are "your setup is a kind we do not support", not "you made a
|
||||
/// typo". The frontend turns these into a sentence naming the unsupported
|
||||
/// part; without them every rejection reads as a malformed URI and a user
|
||||
/// with a working WebSocket or plain-TLS server has no idea why it failed.
|
||||
pub fn reason_code(&self) -> &'static str {
|
||||
match self {
|
||||
Self::UnsupportedScheme => "scheme",
|
||||
Self::UnsupportedValue { field, .. } | Self::InvalidField { field, .. } => match *field {
|
||||
"security" => "security",
|
||||
"flow" => "flow",
|
||||
"type" => "transport",
|
||||
"encryption" => "encryption",
|
||||
"headerType" => "headerType",
|
||||
"fp" => "fingerprint",
|
||||
// A malformed sni/public key is the same user-facing problem as a
|
||||
// missing one, so it earns the same specific help rather than the
|
||||
// generic "invalid URI".
|
||||
"sni" | "server_name" => "sni",
|
||||
"pbk" | "public_key" => "publicKey",
|
||||
_ => "malformed",
|
||||
},
|
||||
Self::MissingField(field) => match *field {
|
||||
"sni" => "sni",
|
||||
"pbk" => "publicKey",
|
||||
"security" => "security",
|
||||
"flow" => "flow",
|
||||
_ => "malformed",
|
||||
},
|
||||
Self::UnsupportedParameter(_) => "parameter",
|
||||
Self::DuplicateParameter(_) => "malformed",
|
||||
Self::InvalidUri | Self::Serialization => "malformed",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+133
-8
@@ -61,10 +61,10 @@ pub fn parse_vless_uri(input: &str) -> XrayResult<ParsedVlessUri> {
|
||||
};
|
||||
let port = url.port().ok_or(XrayError::MissingField("port"))?;
|
||||
|
||||
let parameters = parse_parameters(&url)?;
|
||||
require_value(¶meters, "security", "reality")?;
|
||||
require_value(¶meters, "flow", VlessFlow::Vision.as_str())?;
|
||||
optional_value(¶meters, "encryption", "none")?;
|
||||
let (parameters, unsupported) = parse_parameters(&url)?;
|
||||
|
||||
// Transport first: it is the most common reason a real-world VLESS server is
|
||||
// unusable here, and it explains the stray parameters that come with it.
|
||||
match parameters.get("type").map(String::as_str) {
|
||||
None | Some("tcp" | "raw") => {}
|
||||
Some(_) => {
|
||||
@@ -74,8 +74,17 @@ pub fn parse_vless_uri(input: &str) -> XrayResult<ParsedVlessUri> {
|
||||
});
|
||||
}
|
||||
}
|
||||
require_value(¶meters, "security", "reality")?;
|
||||
require_value(¶meters, "flow", VlessFlow::Vision.as_str())?;
|
||||
optional_value(¶meters, "encryption", "none")?;
|
||||
optional_value(¶meters, "headerType", "none")?;
|
||||
|
||||
// Only once the shape is known-good does an unrecognized parameter become
|
||||
// the most useful thing to report.
|
||||
if let Some(name) = unsupported.into_iter().next() {
|
||||
return Err(XrayError::UnsupportedParameter(name));
|
||||
}
|
||||
|
||||
let server_name = required_parameter(¶meters, "sni")?.to_string();
|
||||
let public_key = required_parameter(¶meters, "pbk")?.to_string();
|
||||
let short_id = parameters.get("sid").cloned().unwrap_or_default();
|
||||
@@ -162,15 +171,28 @@ pub fn export_vless_uri(config: &VlessRealityConfig, name: Option<&str>) -> Xray
|
||||
query.append_pair("type", "tcp");
|
||||
query.append_pair("headerType", "none");
|
||||
}
|
||||
url.set_fragment(name);
|
||||
// The parser percent-DECODES the fragment, so the exporter must encode it or
|
||||
// a name containing `%` (or `#`) comes back different every time the URI is
|
||||
// canonicalized — the name mutates a little more on each save.
|
||||
let encoded_name = name.map(|value| urlencoding::encode(value).into_owned());
|
||||
url.set_fragment(encoded_name.as_deref());
|
||||
Ok(url.into())
|
||||
}
|
||||
|
||||
fn parse_parameters(url: &Url) -> XrayResult<HashMap<String, String>> {
|
||||
/// Split the query into recognized parameters and the names of the rest.
|
||||
///
|
||||
/// Unrecognized names are returned rather than rejected on the spot so the
|
||||
/// caller can report the *shape* problem first. A WebSocket URI always carries
|
||||
/// `path` (and usually `host`), gRPC carries `serviceName` — naming those keys
|
||||
/// instead of the transport sends the user deleting parameters when the real
|
||||
/// answer is that Donut only speaks plain TCP.
|
||||
fn parse_parameters(url: &Url) -> XrayResult<(HashMap<String, String>, Vec<String>)> {
|
||||
let mut parameters = HashMap::new();
|
||||
let mut unsupported = Vec::new();
|
||||
for (name, value) in url.query_pairs() {
|
||||
if !SUPPORTED_PARAMETERS.contains(&name.as_ref()) {
|
||||
return Err(XrayError::UnsupportedParameter(name.into_owned()));
|
||||
unsupported.push(name.into_owned());
|
||||
continue;
|
||||
}
|
||||
if parameters
|
||||
.insert(name.to_string(), value.into_owned())
|
||||
@@ -179,7 +201,7 @@ fn parse_parameters(url: &Url) -> XrayResult<HashMap<String, String>> {
|
||||
return Err(XrayError::DuplicateParameter(name.into_owned()));
|
||||
}
|
||||
}
|
||||
Ok(parameters)
|
||||
Ok((parameters, unsupported))
|
||||
}
|
||||
|
||||
fn required_parameter<'a>(
|
||||
@@ -230,6 +252,109 @@ mod tests {
|
||||
|
||||
const ID: &str = "6d6e21a1-4829-4d2b-bc7f-1b25707b61e4";
|
||||
|
||||
/// Donut accepts exactly one VLESS shape, so most rejections mean "your
|
||||
/// server is a kind we do not support" rather than "you mistyped". These pin
|
||||
/// the reason each rejection reports, because the UI turns it into the one
|
||||
/// sentence that tells a user with a working WebSocket or plain-TLS server
|
||||
/// why Donut will not take it.
|
||||
#[test]
|
||||
fn unsupported_setups_report_which_part_is_unsupported() {
|
||||
let good = format!(
|
||||
"vless://{ID}@example.com:443?security=reality&flow=xtls-rprx-vision\
|
||||
&encryption=none&type=tcp&sni=a.com&pbk=mQB9jxUDHO7g49VaNXLEdcNQ_jLhTbLolUsMUNwb6W4&sid=00&fp=chrome"
|
||||
);
|
||||
assert!(parse_vless_uri(&good).is_ok(), "baseline URI must parse");
|
||||
|
||||
let reason = |uri: &str| parse_vless_uri(uri).unwrap_err().reason_code();
|
||||
|
||||
// Plain TLS instead of REALITY — the most common real-world setup.
|
||||
assert_eq!(
|
||||
reason(&good.replace("security=reality", "security=tls")),
|
||||
"security"
|
||||
);
|
||||
assert_eq!(
|
||||
reason(&good.replace("flow=xtls-rprx-vision", "flow=none")),
|
||||
"flow"
|
||||
);
|
||||
// WebSocket / gRPC transports.
|
||||
assert_eq!(reason(&good.replace("type=tcp", "type=ws")), "transport");
|
||||
assert_eq!(reason(&good.replace("type=tcp", "type=grpc")), "transport");
|
||||
assert_eq!(reason(&good.replace("&sni=a.com", "")), "sni");
|
||||
assert_eq!(
|
||||
reason(&good.replace("&pbk=mQB9jxUDHO7g49VaNXLEdcNQ_jLhTbLolUsMUNwb6W4", "")),
|
||||
"publicKey"
|
||||
);
|
||||
assert_eq!(reason(&good.replace("vless://", "vmess://")), "scheme");
|
||||
assert_eq!(reason("not a uri"), "malformed");
|
||||
}
|
||||
|
||||
/// The URIs users actually paste, not canonical-REALITY-with-one-field-changed.
|
||||
///
|
||||
/// A real WebSocket link carries `path` (and usually `host`); a gRPC link
|
||||
/// carries `serviceName`. Those keys are not in SUPPORTED_PARAMETERS, so
|
||||
/// before the shape was checked first they produced "unsupported option"
|
||||
/// and sent the user deleting query parameters instead of telling them
|
||||
/// Donut only speaks plain TCP.
|
||||
#[test]
|
||||
fn a_display_name_survives_an_export_parse_round_trip() {
|
||||
// Percent signs are legal in a fragment, so they used to pass through
|
||||
// unencoded and then get decoded on the way back in — "50% off" became
|
||||
// "50 off"-ish and drifted further on every canonicalizing save.
|
||||
for name in ["50% off", "a#b", "spaced name", "100%25", "üñî"] {
|
||||
let parsed = parse_vless_uri(&format!(
|
||||
"vless://{ID}@example.com:443?security=reality&flow=xtls-rprx-vision\
|
||||
&encryption=none&type=tcp&sni=a.com&pbk=mQB9jxUDHO7g49VaNXLEdcNQ_jLhTbLolUsMUNwb6W4"
|
||||
))
|
||||
.expect("baseline parses");
|
||||
|
||||
let exported = export_vless_uri(&parsed.config, Some(name)).expect("exports");
|
||||
let reparsed = parse_vless_uri(&exported).expect("re-parses");
|
||||
assert_eq!(
|
||||
reparsed.name.as_deref(),
|
||||
Some(name),
|
||||
"display name mutated across a round trip: {exported}"
|
||||
);
|
||||
|
||||
// And a second round trip must be a fixed point, not drift again.
|
||||
let exported_again =
|
||||
export_vless_uri(&reparsed.config, reparsed.name.as_deref()).expect("re-exports");
|
||||
assert_eq!(exported, exported_again);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn real_world_websocket_and_grpc_links_name_the_transport() {
|
||||
let ws = format!(
|
||||
"vless://{ID}@cdn.example.com:443?encryption=none&security=tls&type=ws\
|
||||
&path=%2Fray&host=cdn.example.com&sni=cdn.example.com#WS%20node"
|
||||
);
|
||||
assert_eq!(
|
||||
parse_vless_uri(&ws).unwrap_err().reason_code(),
|
||||
"transport",
|
||||
"a WebSocket link must be told its transport is unsupported"
|
||||
);
|
||||
|
||||
let grpc = format!(
|
||||
"vless://{ID}@grpc.example.com:443?encryption=none&security=reality&type=grpc\
|
||||
&serviceName=gun&sni=a.com&pbk=mQB9jxUDHO7g49VaNXLEdcNQ_jLhTbLolUsMUNwb6W4"
|
||||
);
|
||||
assert_eq!(
|
||||
parse_vless_uri(&grpc).unwrap_err().reason_code(),
|
||||
"transport"
|
||||
);
|
||||
|
||||
// A genuinely unknown option on an otherwise-supported URI still reports
|
||||
// as a parameter problem, which is the accurate answer there.
|
||||
let odd = format!(
|
||||
"vless://{ID}@example.com:443?security=reality&flow=xtls-rprx-vision\
|
||||
&encryption=none&type=tcp&sni=a.com&pbk=mQB9jxUDHO7g49VaNXLEdcNQ_jLhTbLolUsMUNwb6W4&madeUpKey=1"
|
||||
);
|
||||
assert_eq!(
|
||||
parse_vless_uri(&odd).unwrap_err().reason_code(),
|
||||
"parameter"
|
||||
);
|
||||
}
|
||||
|
||||
fn public_key() -> String {
|
||||
URL_SAFE_NO_PAD.encode([7_u8; 32])
|
||||
}
|
||||
|
||||
@@ -165,7 +165,7 @@ pub async fn start_xray_worker(
|
||||
) -> Result<XrayWorkerConfig, Box<dyn std::error::Error>> {
|
||||
let _start_guard = XRAY_START_LOCK.lock().await;
|
||||
parse_vless_uri(vless_uri)
|
||||
.map_err(|error| structured_error_with_detail("VLESS_CONFIG_INVALID", error))?;
|
||||
.map_err(|error| -> Box<dyn std::error::Error> { crate::vless_config_error(&error).into() })?;
|
||||
crate::proxy_runner::ensure_sidecar_version().await?;
|
||||
ensure_xray_binary()?;
|
||||
let owner_pid = std::process::id();
|
||||
@@ -521,14 +521,14 @@ pub async fn run_xray_worker(config_path: &Path) -> Result<(), Box<dyn std::erro
|
||||
save_xray_worker_config_to_path(&config, config_path)
|
||||
.map_err(|error| structured_error_with_detail("XRAY_START_FAILED", error))?;
|
||||
let parsed = parse_vless_uri(&config.vless_uri)
|
||||
.map_err(|error| structured_error_with_detail("VLESS_CONFIG_INVALID", error))?;
|
||||
.map_err(|error| -> Box<dyn std::error::Error> { crate::vless_config_error(&error).into() })?;
|
||||
let runtime = XrayClientRuntime {
|
||||
listen_port: config.local_port,
|
||||
username: config.username.clone(),
|
||||
password: config.password.clone(),
|
||||
};
|
||||
let runtime_json = build_client_config_json(&parsed.config, &runtime)
|
||||
.map_err(|error| structured_error_with_detail("VLESS_CONFIG_INVALID", error))?;
|
||||
.map_err(|error| -> Box<dyn std::error::Error> { crate::vless_config_error(&error).into() })?;
|
||||
write_xray_runtime_config(&config.id, runtime_json.as_bytes())
|
||||
.map_err(|error| structured_error_with_detail("XRAY_START_FAILED", error))?;
|
||||
let runtime_path = crate::xray_worker_storage::xray_runtime_config_path(&config.id);
|
||||
|
||||
@@ -182,6 +182,39 @@ fn worker_is_tombstoned(id: &str) -> bool {
|
||||
xray_worker_tombstone_path(id).exists()
|
||||
}
|
||||
|
||||
/// How long a tombstone has to outlive its worker.
|
||||
///
|
||||
/// It only has to survive long enough to beat a write already in flight from
|
||||
/// the process that owned that id. A day is many orders of magnitude more than
|
||||
/// that, and bounds a directory that otherwise gains a file per worker forever.
|
||||
const TOMBSTONE_TTL: std::time::Duration = std::time::Duration::from_secs(24 * 60 * 60);
|
||||
|
||||
/// Drop tombstones old enough that nothing could still be racing them.
|
||||
fn prune_stale_tombstones() {
|
||||
let Ok(entries) = fs::read_dir(crate::proxy_storage::get_storage_dir()) else {
|
||||
return;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|e| e.to_str()) != Some("stopped") {
|
||||
continue;
|
||||
}
|
||||
let aged_out = path
|
||||
.metadata()
|
||||
.and_then(|meta| meta.modified())
|
||||
.map(|modified| {
|
||||
modified
|
||||
.elapsed()
|
||||
.map(|age| age > TOMBSTONE_TTL)
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.unwrap_or(false);
|
||||
if aged_out {
|
||||
let _ = fs::remove_file(&path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn create_xray_worker_log(id: &str) -> std::io::Result<std::fs::File> {
|
||||
ensure_private_storage_dir()?;
|
||||
if worker_is_tombstoned(id) {
|
||||
@@ -275,6 +308,8 @@ pub fn delete_xray_worker_config(id: &str) -> bool {
|
||||
}
|
||||
|
||||
pub fn list_xray_worker_configs() -> Vec<XrayWorkerConfig> {
|
||||
// Cheap, and this is the one call every sweep already makes.
|
||||
prune_stale_tombstones();
|
||||
let storage_dir = crate::proxy_storage::get_storage_dir();
|
||||
let Ok(entries) = fs::read_dir(storage_dir) else {
|
||||
return Vec::new();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Donut",
|
||||
"version": "0.28.2",
|
||||
"version": "0.29.0",
|
||||
"identifier": "com.donutbrowser",
|
||||
"build": {
|
||||
"beforeDevCommand": "pnpm copy-proxy-binary && pnpm dev",
|
||||
|
||||
+327
-44
@@ -13,11 +13,6 @@ import { CloneProfileDialog } from "@/components/clone-profile-dialog";
|
||||
import { CloseConfirmDialog } from "@/components/close-confirm-dialog";
|
||||
import { CommandPalette } from "@/components/command-palette";
|
||||
import { CommercialTrialModal } from "@/components/commercial-trial-modal";
|
||||
import {
|
||||
type ConsistencyResult,
|
||||
ConsistencyWarningDialog,
|
||||
isConsistencyWarningSuppressed,
|
||||
} from "@/components/consistency-warning-dialog";
|
||||
import { CookieBotPage, type CookieBotTab } from "@/components/cookie-bot-page";
|
||||
import { CookieCopyDialog } from "@/components/cookie-copy-dialog";
|
||||
import { CookieManagementDialog } from "@/components/cookie-management-dialog";
|
||||
@@ -33,6 +28,11 @@ import { ImportProfileDialog } from "@/components/import-profile-dialog";
|
||||
import { IntegrationsDialog } from "@/components/integrations-dialog";
|
||||
import { ONBOARDING_TOUR } from "@/components/onboarding-provider";
|
||||
import { PermissionDialog } from "@/components/permission-dialog";
|
||||
import {
|
||||
type GateDecision,
|
||||
type GateFindings,
|
||||
PreLaunchGateDialog,
|
||||
} from "@/components/pre-launch-gate-dialog";
|
||||
import { ProfilesDataTable } from "@/components/profile-data-table";
|
||||
import {
|
||||
type PasswordDialogMode,
|
||||
@@ -67,7 +67,7 @@ import { useUpdateNotifications } from "@/hooks/use-update-notifications";
|
||||
import { useVersionUpdater } from "@/hooks/use-version-updater";
|
||||
import { useVpnEvents } from "@/hooks/use-vpn-events";
|
||||
import { useWayfernTerms } from "@/hooks/use-wayfern-terms";
|
||||
import { translateBackendError } from "@/lib/backend-errors";
|
||||
import { parseBackendError, translateBackendError } from "@/lib/backend-errors";
|
||||
import { canUseCookieBot, getEntitlements } from "@/lib/entitlements";
|
||||
import { MOTION_EASE_OUT } from "@/lib/motion";
|
||||
import {
|
||||
@@ -88,7 +88,42 @@ import {
|
||||
showSyncProgressToast,
|
||||
showToast,
|
||||
} from "@/lib/toast-utils";
|
||||
import type { BrowserProfile, SyncSettings, WayfernConfig } from "@/types";
|
||||
import type {
|
||||
BrowserProfile,
|
||||
ConsistencyResult,
|
||||
PreLaunchChecks,
|
||||
SyncSettings,
|
||||
WayfernConfig,
|
||||
} from "@/types";
|
||||
|
||||
type GateRequest = {
|
||||
profile: BrowserProfile;
|
||||
findings: GateFindings;
|
||||
};
|
||||
|
||||
type LaunchResult = {
|
||||
status: "launched" | "cancelled" | "blocked";
|
||||
};
|
||||
|
||||
/**
|
||||
* Rebuild the mismatch detail the gate dialog renders from a
|
||||
* FINGERPRINT_EXIT_MISMATCH error's params. Every param is a string, because
|
||||
* backend error params always are.
|
||||
*/
|
||||
function consistencyFromErrorParams(
|
||||
params?: Record<string, string>,
|
||||
): ConsistencyResult {
|
||||
return {
|
||||
consistent: false,
|
||||
checked: true,
|
||||
exit_ip: params?.exitIp || null,
|
||||
exit_country_code: params?.exitCountry || null,
|
||||
exit_timezone: params?.exitTimezone || null,
|
||||
fingerprint_timezone: params?.fingerprintTimezone || null,
|
||||
fingerprint_language: params?.fingerprintLanguage || null,
|
||||
mismatches: (params?.mismatches ?? "").split(",").filter(Boolean),
|
||||
};
|
||||
}
|
||||
|
||||
type BrowserTypeString = "wayfern";
|
||||
|
||||
@@ -252,7 +287,7 @@ export default function Home() {
|
||||
const { user: cloudUser } = useCloudAuth();
|
||||
const crossOsUnlocked = getEntitlements(cloudUser).crossOsFingerprints;
|
||||
// Bulk run/stop is a paid (browser automation) feature, matching the
|
||||
// /v1/profiles/batch/run API gate. Free/starter users see the bulk Run/Stop
|
||||
// /v1/profiles/batch/run API gate. Free/solo users see the bulk Run/Stop
|
||||
// actions disabled with a Pro badge.
|
||||
const automationUnlocked = getEntitlements(cloudUser).browserAutomation;
|
||||
// The rail needs to show a live run from every page, so the shell subscribes
|
||||
@@ -279,7 +314,12 @@ export default function Home() {
|
||||
}
|
||||
}, [cloudUser]);
|
||||
|
||||
const syncUnlocked = crossOsUnlocked || selfHostedSyncConfigured;
|
||||
// Cloud sync follows `cloudBackup`, NOT `crossOsFingerprints`. They agreed on
|
||||
// every plan until Solo, which buys 20 cloud backups and deliberately has no
|
||||
// fingerprint editing — so deriving sync from the fingerprint capability put a
|
||||
// Pro badge on the one feature a Solo customer is paying for.
|
||||
const cloudBackupUnlocked = getEntitlements(cloudUser).cloudBackup;
|
||||
const syncUnlocked = cloudBackupUnlocked || selfHostedSyncConfigured;
|
||||
|
||||
const [currentPage, setCurrentPage] = useState<AppPage>("profiles");
|
||||
const [accountDialogOpen, setAccountDialogOpen] = useState(false);
|
||||
@@ -365,10 +405,30 @@ export default function Home() {
|
||||
useState<BrowserProfile | null>(null);
|
||||
const [commandPaletteOpen, setCommandPaletteOpen] = useState(false);
|
||||
const [aboutDialogOpen, setAboutDialogOpen] = useState(false);
|
||||
const [consistencyWarning, setConsistencyWarning] = useState<{
|
||||
profile: BrowserProfile;
|
||||
result: ConsistencyResult;
|
||||
// Pre-launch gate. Requests queue instead of overwriting a single resolver:
|
||||
// a bulk run enqueues one per profile, and every waiter must settle or the
|
||||
// Promise.allSettled below it never resolves and the bulk spinner sticks.
|
||||
const gateQueueRef = useRef<
|
||||
Array<{ req: GateRequest; resolve: (decision: GateDecision) => void }>
|
||||
>([]);
|
||||
const [gateState, setGateState] = useState<{
|
||||
req: GateRequest;
|
||||
remaining: number;
|
||||
} | null>(null);
|
||||
// Set when the user ticks "apply to the remaining profiles" during a bulk
|
||||
// run, so the rest are answered without prompting again. Scoped to one bulk
|
||||
// run and to the severity it was given for.
|
||||
const blanketGateDecisionRef = useRef<{
|
||||
decision: GateDecision;
|
||||
/// Only auto-answers gates no more severe than the one the user saw. A
|
||||
/// choice made on an extension warning must never silently bypass a hard
|
||||
/// block on a later profile.
|
||||
coversBlocking: boolean;
|
||||
/// Identifies the bulk run, so a single launch started while a bulk run is
|
||||
/// in flight still gets its own dialog.
|
||||
runId: number;
|
||||
} | null>(null);
|
||||
const bulkRunIdRef = useRef(0);
|
||||
// Owned by page.tsx so the command palette can request opening the profile
|
||||
// info dialog. ProfilesDataTable consumes it through controlled props.
|
||||
const [profileInfoDialog, setProfileInfoDialog] =
|
||||
@@ -933,8 +993,137 @@ export default function Home() {
|
||||
[selectedGroupId, t],
|
||||
);
|
||||
|
||||
// Show the queue's head, and how many are waiting behind it.
|
||||
// The backend gate downgrades to advisory rather than blocking when it
|
||||
// cannot trust its own measurement (a confirmed VPN extension can reroute
|
||||
// traffic away from the proxy it just probed), and for unattended launches.
|
||||
// Without a listener that finding was emitted into the void.
|
||||
useEffect(() => {
|
||||
const unlisten = listen<ConsistencyResult>(
|
||||
"fingerprint-consistency-warning",
|
||||
(event) => {
|
||||
const { exit_timezone, fingerprint_timezone } = event.payload;
|
||||
showErrorToast(t("backendErrors.fingerprintExitMismatch"), {
|
||||
// The cause differs by path (an unverifiable measurement vs an
|
||||
// unattended launch), so state the measurement rather than guess.
|
||||
description:
|
||||
exit_timezone && fingerprint_timezone
|
||||
? t("consistencyWarning.timezoneDetail", {
|
||||
exit: exit_timezone,
|
||||
fingerprint: fingerprint_timezone,
|
||||
})
|
||||
: undefined,
|
||||
id: `fingerprint-mismatch-${exit_timezone ?? "unknown"}`,
|
||||
});
|
||||
},
|
||||
);
|
||||
return () => {
|
||||
void unlisten.then((fn) => {
|
||||
fn();
|
||||
});
|
||||
};
|
||||
}, [t]);
|
||||
|
||||
const syncGateUi = useCallback(() => {
|
||||
const queue = gateQueueRef.current;
|
||||
setGateState(
|
||||
queue.length > 0
|
||||
? { req: queue[0].req, remaining: queue.length - 1 }
|
||||
: null,
|
||||
);
|
||||
}, []);
|
||||
|
||||
const requestGateDecision = useCallback(
|
||||
(req: GateRequest, runId?: number): Promise<GateDecision> => {
|
||||
const blanket = blanketGateDecisionRef.current;
|
||||
const isBlocking = req.findings.fingerprint !== null;
|
||||
if (
|
||||
blanket &&
|
||||
blanket.runId === runId &&
|
||||
(blanket.coversBlocking || !isBlocking)
|
||||
) {
|
||||
// A blanket answer covers only whether to launch. The acknowledgements
|
||||
// it carried were about the first profile's specific mismatch and
|
||||
// extensions, and must not be persisted against profiles the user
|
||||
// never saw.
|
||||
return Promise.resolve({
|
||||
...blanket.decision,
|
||||
ackFingerprint: false,
|
||||
ackExtensionKeys: [],
|
||||
});
|
||||
}
|
||||
return new Promise<GateDecision>((resolve) => {
|
||||
gateQueueRef.current.push({ req, resolve });
|
||||
syncGateUi();
|
||||
});
|
||||
},
|
||||
[syncGateUi],
|
||||
);
|
||||
|
||||
const settleGate = useCallback(
|
||||
(decision: GateDecision) => {
|
||||
const entry = gateQueueRef.current.shift();
|
||||
entry?.resolve(decision);
|
||||
if (decision.applyToRemaining) {
|
||||
const coversBlocking = entry?.req.findings.fingerprint !== null;
|
||||
blanketGateDecisionRef.current = {
|
||||
decision,
|
||||
coversBlocking,
|
||||
runId: bulkRunIdRef.current,
|
||||
};
|
||||
// Drain the queue rather than leaving promises pending forever — but
|
||||
// only those the blanket actually covers. A hard block still deserves
|
||||
// its own dialog even after the user blanket-approved a warning.
|
||||
const remaining = gateQueueRef.current.splice(0);
|
||||
const kept = remaining.filter(
|
||||
(queued) =>
|
||||
!coversBlocking && queued.req.findings.fingerprint !== null,
|
||||
);
|
||||
for (const queued of remaining) {
|
||||
if (kept.includes(queued)) {
|
||||
continue;
|
||||
}
|
||||
queued.resolve({
|
||||
...decision,
|
||||
ackFingerprint: false,
|
||||
ackExtensionKeys: [],
|
||||
});
|
||||
}
|
||||
gateQueueRef.current = kept;
|
||||
}
|
||||
syncGateUi();
|
||||
},
|
||||
[syncGateUi],
|
||||
);
|
||||
|
||||
const persistGateAcks = useCallback(
|
||||
async (profileId: string, decision: GateDecision) => {
|
||||
// Only on proceed. Cancel is the autofocused default action, so a stray
|
||||
// Enter would otherwise permanently disarm the gate for this profile.
|
||||
if (!decision.proceed) {
|
||||
return;
|
||||
}
|
||||
if (!decision.ackFingerprint && decision.ackExtensionKeys.length === 0) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await invoke("ack_launch_gate", {
|
||||
profileId,
|
||||
ackFingerprint: decision.ackFingerprint,
|
||||
ackExtensionKeys: decision.ackExtensionKeys,
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn("Failed to persist launch gate acknowledgement:", err);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const launchProfile = useCallback(
|
||||
async (profile: BrowserProfile) => {
|
||||
async (
|
||||
profile: BrowserProfile,
|
||||
opts?: { bulkRunId?: number },
|
||||
): Promise<LaunchResult> => {
|
||||
console.log("Starting launch for profile:", profile.name);
|
||||
|
||||
// Password-protected: must be unlocked before launch
|
||||
@@ -947,7 +1136,7 @@ export default function Home() {
|
||||
pendingLaunchAfterUnlockRef.current = profile;
|
||||
setPasswordDialogMode("unlock");
|
||||
setPasswordDialogProfile(profile);
|
||||
return;
|
||||
return { status: "cancelled" };
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to check profile lock state:", err);
|
||||
@@ -966,7 +1155,7 @@ export default function Home() {
|
||||
setWindowResizeWarningOpen(true);
|
||||
});
|
||||
if (!proceed) {
|
||||
return;
|
||||
return { status: "cancelled" };
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -974,30 +1163,106 @@ export default function Home() {
|
||||
}
|
||||
}
|
||||
|
||||
// Tier 1: purely local checks — an extension scan and a cached exit
|
||||
// verdict. No network, no worker started, so a profile whose exit is
|
||||
// already known blocks before the launch touches anything.
|
||||
let consentToken: string | null = null;
|
||||
try {
|
||||
// One-shot migration of the old per-profile "don't warn again" flag,
|
||||
// so a user who already dismissed this profile isn't hard-blocked by
|
||||
// the new gate. Granted against the profile's current exit, which is
|
||||
// the mismatch they were looking at when they dismissed it.
|
||||
const legacySkipKey = `consistency-warn-skip-${profile.id}`;
|
||||
if (localStorage.getItem(legacySkipKey) === "1") {
|
||||
await invoke("ack_launch_gate", {
|
||||
profileId: profile.id,
|
||||
ackFingerprint: true,
|
||||
ackExtensionKeys: [],
|
||||
}).catch((err: unknown) => {
|
||||
console.warn("Failed to migrate consistency skip flag:", err);
|
||||
});
|
||||
localStorage.removeItem(legacySkipKey);
|
||||
}
|
||||
|
||||
const checks = await invoke<PreLaunchChecks>(
|
||||
"get_profile_pre_launch_checks",
|
||||
{ profileId: profile.id },
|
||||
);
|
||||
const blocked =
|
||||
checks.consistency.checked && !checks.consistency.consistent;
|
||||
if (blocked || checks.vpn_extensions.length > 0) {
|
||||
const decision = await requestGateDecision(
|
||||
{
|
||||
profile,
|
||||
findings: {
|
||||
vpnExtensions: checks.vpn_extensions,
|
||||
scanState: checks.scan_state,
|
||||
fingerprint: blocked ? checks.consistency : null,
|
||||
measurementUnreliable: checks.exit_measurement_unreliable,
|
||||
probePending: checks.exit_probe_pending,
|
||||
},
|
||||
},
|
||||
opts?.bulkRunId,
|
||||
);
|
||||
await persistGateAcks(profile.id, decision);
|
||||
if (!decision.proceed) {
|
||||
return { status: "cancelled" };
|
||||
}
|
||||
consentToken = checks.consent_token;
|
||||
}
|
||||
} catch (err) {
|
||||
// Same posture as the password and window-resize gates: a check that
|
||||
// cannot run must not make profiles unlaunchable.
|
||||
console.warn("Pre-launch checks failed, launching anyway:", err);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await invoke<BrowserProfile>("launch_browser_profile", {
|
||||
profile,
|
||||
consentToken,
|
||||
});
|
||||
console.log("Successfully launched profile:", result.name);
|
||||
|
||||
// Non-blocking: after a successful launch, check that the proxy exit
|
||||
// node's timezone/country agrees with the fingerprint. A mismatch is a
|
||||
// strong anti-bot tell even though the real device never leaks.
|
||||
if (profile.proxy_id && !isConsistencyWarningSuppressed(profile.id)) {
|
||||
void invoke<ConsistencyResult>(
|
||||
"check_profile_fingerprint_consistency",
|
||||
{ profileId: profile.id },
|
||||
)
|
||||
.then((res) => {
|
||||
if (res.checked && !res.consistent) {
|
||||
setConsistencyWarning({ profile, result: res });
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
console.warn("Consistency check failed:", e);
|
||||
});
|
||||
}
|
||||
return { status: "launched" };
|
||||
} catch (err: unknown) {
|
||||
// Tier 2: the enforcing gate measured the exit mid-launch and stopped
|
||||
// before spawning the browser. Offer the same decision, then retry
|
||||
// exactly once with the token it minted — bounded, so a gate loop is
|
||||
// structurally impossible.
|
||||
const parsed = parseBackendError(err);
|
||||
if (parsed?.code === "FINGERPRINT_EXIT_MISMATCH") {
|
||||
const decision = await requestGateDecision(
|
||||
{
|
||||
profile,
|
||||
findings: {
|
||||
vpnExtensions: [],
|
||||
scanState: "scanned",
|
||||
fingerprint: consistencyFromErrorParams(parsed.params),
|
||||
measurementUnreliable: false,
|
||||
probePending: false,
|
||||
},
|
||||
},
|
||||
opts?.bulkRunId,
|
||||
);
|
||||
await persistGateAcks(profile.id, decision);
|
||||
if (!decision.proceed) {
|
||||
return { status: "cancelled" };
|
||||
}
|
||||
try {
|
||||
await invoke<BrowserProfile>("launch_browser_profile", {
|
||||
profile,
|
||||
consentToken: parsed.params?.token ?? null,
|
||||
});
|
||||
return { status: "launched" };
|
||||
} catch (retryErr: unknown) {
|
||||
showErrorToast(
|
||||
t("errors.launchBrowserFailed", {
|
||||
error: translateBackendError(t, retryErr),
|
||||
}),
|
||||
);
|
||||
return { status: "blocked" };
|
||||
}
|
||||
}
|
||||
|
||||
console.error("Failed to launch browser:", err);
|
||||
const errorMessage = translateBackendError(t, err);
|
||||
showErrorToast(
|
||||
@@ -1006,7 +1271,7 @@ export default function Home() {
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
[t],
|
||||
[persistGateAcks, requestGateDecision, t],
|
||||
);
|
||||
|
||||
const handleCloneProfile = useCallback((profile: BrowserProfile) => {
|
||||
@@ -1203,15 +1468,34 @@ export default function Home() {
|
||||
const executeBulkRun = useCallback(
|
||||
async (targets: BrowserProfile[]) => {
|
||||
setIsBulkActing(true);
|
||||
blanketGateDecisionRef.current = null;
|
||||
bulkRunIdRef.current += 1;
|
||||
const runId = bulkRunIdRef.current;
|
||||
try {
|
||||
await Promise.allSettled(targets.map((p) => launchProfile(p)));
|
||||
const results = await Promise.allSettled(
|
||||
targets.map((p) => launchProfile(p, { bulkRunId: runId })),
|
||||
);
|
||||
const stopped = results.filter(
|
||||
(r) => r.status === "fulfilled" && r.value.status !== "launched",
|
||||
).length;
|
||||
if (stopped > 0) {
|
||||
// Previously a declined launch resolved to undefined, so allSettled
|
||||
// reported success and the user was told nothing.
|
||||
showErrorToast(
|
||||
t("prelaunchGate.cancelledSummary", {
|
||||
cancelled: stopped,
|
||||
total: targets.length,
|
||||
}),
|
||||
);
|
||||
}
|
||||
setSelectedProfiles([]);
|
||||
} finally {
|
||||
blanketGateDecisionRef.current = null;
|
||||
setIsBulkActing(false);
|
||||
setPendingBulkAction(null);
|
||||
}
|
||||
},
|
||||
[launchProfile],
|
||||
[launchProfile, t],
|
||||
);
|
||||
|
||||
const executeBulkStop = useCallback(
|
||||
@@ -1898,14 +2182,13 @@ export default function Home() {
|
||||
}}
|
||||
/>
|
||||
|
||||
<ConsistencyWarningDialog
|
||||
isOpen={consistencyWarning !== null}
|
||||
onClose={() => {
|
||||
setConsistencyWarning(null);
|
||||
}}
|
||||
profileName={consistencyWarning?.profile.name ?? ""}
|
||||
profileId={consistencyWarning?.profile.id ?? ""}
|
||||
result={consistencyWarning?.result ?? null}
|
||||
<PreLaunchGateDialog
|
||||
isOpen={gateState !== null}
|
||||
profileName={gateState?.req.profile.name ?? ""}
|
||||
profileId={gateState?.req.profile.id ?? ""}
|
||||
findings={gateState?.req.findings ?? null}
|
||||
remainingCount={gateState?.remaining ?? 0}
|
||||
onResult={settleGate}
|
||||
/>
|
||||
|
||||
{pendingUrls.map((pendingUrl) => (
|
||||
|
||||
@@ -1,176 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LuTriangleAlert } from "react-icons/lu";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { translateBackendError } from "@/lib/backend-errors";
|
||||
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
|
||||
import { RippleButton } from "./ui/ripple";
|
||||
|
||||
export interface ConsistencyResult {
|
||||
consistent: boolean;
|
||||
checked: boolean;
|
||||
exit_ip: string | null;
|
||||
exit_country_code: string | null;
|
||||
exit_timezone: string | null;
|
||||
fingerprint_timezone: string | null;
|
||||
fingerprint_language: string | null;
|
||||
mismatches: string[];
|
||||
}
|
||||
|
||||
const GLOBAL_DISABLE_KEY = "consistency-warn-disabled";
|
||||
const perProfileKey = (id: string) => `consistency-warn-skip-${id}`;
|
||||
|
||||
export function isConsistencyWarningSuppressed(profileId: string): boolean {
|
||||
try {
|
||||
return (
|
||||
localStorage.getItem(GLOBAL_DISABLE_KEY) === "1" ||
|
||||
localStorage.getItem(perProfileKey(profileId)) === "1"
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
interface ConsistencyWarningDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
profileName: string;
|
||||
profileId: string;
|
||||
result: ConsistencyResult | null;
|
||||
}
|
||||
|
||||
export function ConsistencyWarningDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
profileName,
|
||||
profileId,
|
||||
result,
|
||||
}: ConsistencyWarningDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [dontWarnAgain, setDontWarnAgain] = useState(false);
|
||||
const [isMatching, setIsMatching] = useState(false);
|
||||
|
||||
const handleClose = () => {
|
||||
if (dontWarnAgain) {
|
||||
try {
|
||||
localStorage.setItem(perProfileKey(profileId), "1");
|
||||
} catch {
|
||||
// localStorage unavailable — nothing to persist
|
||||
}
|
||||
}
|
||||
setDontWarnAgain(false);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const mismatches = result?.mismatches ?? [];
|
||||
const exitIp = result?.exit_ip ?? null;
|
||||
|
||||
const handleMatch = async () => {
|
||||
if (!exitIp) {
|
||||
return;
|
||||
}
|
||||
setIsMatching(true);
|
||||
try {
|
||||
await invoke("match_profile_fingerprint_to_exit", {
|
||||
profileId,
|
||||
exitIp,
|
||||
});
|
||||
showSuccessToast(t("consistencyWarning.matchSuccess"));
|
||||
handleClose();
|
||||
} catch (e) {
|
||||
showErrorToast(translateBackendError(t, e));
|
||||
} finally {
|
||||
setIsMatching(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<LuTriangleAlert className="size-5 text-warning-text" />
|
||||
{t("consistencyWarning.title")}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3 text-sm">
|
||||
<p className="text-muted-foreground">
|
||||
{t("consistencyWarning.intro", { name: profileName })}
|
||||
</p>
|
||||
|
||||
<div className="space-y-2 rounded-md border border-warning/40 bg-warning/10 p-3">
|
||||
{mismatches.includes("timezone") && (
|
||||
<div>
|
||||
<p className="font-medium">
|
||||
{t("consistencyWarning.timezoneTitle")}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("consistencyWarning.timezoneDetail", {
|
||||
exit: result?.exit_timezone ?? "?",
|
||||
fingerprint: result?.fingerprint_timezone ?? "?",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{mismatches.includes("language") && (
|
||||
<div>
|
||||
<p className="font-medium">
|
||||
{t("consistencyWarning.languageTitle")}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("consistencyWarning.languageDetail", {
|
||||
country: result?.exit_country_code ?? "?",
|
||||
fingerprint: result?.fingerprint_language ?? "?",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("consistencyWarning.explainer")}
|
||||
</p>
|
||||
|
||||
<label
|
||||
htmlFor="consistency-dont-warn"
|
||||
className="flex cursor-pointer items-center gap-2 text-xs"
|
||||
>
|
||||
<Checkbox
|
||||
id="consistency-dont-warn"
|
||||
checked={dontWarnAgain}
|
||||
onCheckedChange={(v) => setDontWarnAgain(v === true)}
|
||||
/>
|
||||
{t("consistencyWarning.dontWarnAgain")}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<RippleButton
|
||||
variant="outline"
|
||||
onClick={handleClose}
|
||||
disabled={isMatching}
|
||||
>
|
||||
{t("common.buttons.close")}
|
||||
</RippleButton>
|
||||
{exitIp && (
|
||||
<RippleButton onClick={handleMatch} disabled={isMatching}>
|
||||
{isMatching
|
||||
? t("consistencyWarning.matching")
|
||||
: t("consistencyWarning.matchToProxy")}
|
||||
</RippleButton>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -11,9 +11,11 @@ import {
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { parseBackendError, translateBackendError } from "@/lib/backend-errors";
|
||||
import type {
|
||||
CookieBotRun,
|
||||
CookieBotSchedule,
|
||||
CookieBotSlot,
|
||||
RemoteHoursQuota,
|
||||
} from "@/lib/cookie-bot";
|
||||
import { MOTION_EASE_OUT } from "@/lib/motion";
|
||||
@@ -66,6 +68,84 @@ export function nightsPerWeek(mask: number): number {
|
||||
return count;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Slots */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Every time-of-day an enrolment fires, from whichever shape the server sent.
|
||||
*
|
||||
* ALWAYS at least one slot. A server that predates multi-slot scheduling sends
|
||||
* only the mirrored `run_at_minute` / `days_mask` pair, and a renderer that read
|
||||
* `slots` directly would show an enrolment as firing at no time at all. Reading
|
||||
* the wire through here is what keeps that fallback in one place.
|
||||
*/
|
||||
export function scheduleSlots(schedule: {
|
||||
slots?: CookieBotSlot[];
|
||||
run_at_minute: number;
|
||||
days_mask: number;
|
||||
}): CookieBotSlot[] {
|
||||
const slots = schedule.slots ?? [];
|
||||
if (slots.length > 0) return slots;
|
||||
return [
|
||||
{ days_mask: schedule.days_mask, run_at_minute: schedule.run_at_minute },
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* How many times a week a whole calendar fires.
|
||||
*
|
||||
* Counted across slots, not read off the first one: an enrolment with three
|
||||
* slots costs three times the hours, and the budget estimate beside it is the
|
||||
* only place a user sees that before committing.
|
||||
*
|
||||
* DISTINCT (weekday, minute) pairs rather than a sum of night counts, because
|
||||
* two slots landing on the same weekday at the same minute are the same
|
||||
* instant and the server dispatches them as ONE run — `upcomingSlotsMulti`
|
||||
* collapses coincident fires. Summing quoted a Mon+Tue and a Tue+Wed slot at
|
||||
* 02:00 as four nights when it is three, and that inflated figure is what the
|
||||
* over-budget warning is compared against.
|
||||
*/
|
||||
export function weeklyRuns(
|
||||
slots: { days_mask: number; run_at_minute: number }[],
|
||||
): number {
|
||||
const fires = new Set<number>();
|
||||
for (const slot of slots) {
|
||||
for (let bit = 0; bit < 7; bit += 1) {
|
||||
if ((slot.days_mask & (1 << bit)) !== 0) {
|
||||
fires.add(bit * 1440 + slot.run_at_minute);
|
||||
}
|
||||
}
|
||||
}
|
||||
return fires.size;
|
||||
}
|
||||
|
||||
/**
|
||||
* Monday-first weekday names, from the viewer's own locale.
|
||||
*
|
||||
* Derived rather than translated into ten locale files: `narrow` already gives
|
||||
* each language its own single-letter convention, and a hand-written table
|
||||
* would be ten more places for Monday-first to be got wrong. The reference week
|
||||
* is formatted in UTC so a user east of Greenwich does not see it shift by a
|
||||
* day.
|
||||
*/
|
||||
export function weekdayNames(): { narrow: string; long: string }[] {
|
||||
// 2024-01-01 was a Monday, which is bit 0 of the server's mask.
|
||||
const monday = Date.UTC(2024, 0, 1);
|
||||
const narrow = new Intl.DateTimeFormat(undefined, {
|
||||
weekday: "narrow",
|
||||
timeZone: "UTC",
|
||||
});
|
||||
const long = new Intl.DateTimeFormat(undefined, {
|
||||
weekday: "long",
|
||||
timeZone: "UTC",
|
||||
});
|
||||
return Array.from({ length: 7 }, (_, index) => {
|
||||
const day = new Date(monday + index * 24 * 60 * 60 * 1000);
|
||||
return { narrow: narrow.format(day), long: long.format(day) };
|
||||
});
|
||||
}
|
||||
|
||||
/** A human cadence label. Unknown masks fall back to the night count. */
|
||||
export function describeCadence(t: TFunction, mask: number): string {
|
||||
const id = cadenceForMask(mask);
|
||||
@@ -237,6 +317,24 @@ export function preflight(profile: BrowserProfile): PreflightResult {
|
||||
return ELIGIBLE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this profile could be launched on a remote host.
|
||||
*
|
||||
* A strict subset of {@link preflight}: a remote session needs the profile to
|
||||
* exist in cloud storage in a form a host can read, and nothing more. The bot's
|
||||
* extra requirement — an exit node — exists because a night of unattended
|
||||
* traffic from a datacenter address is worse for the profile than not warming
|
||||
* it, and that reasoning does not apply to a session the user is driving.
|
||||
*
|
||||
* Mirrors `remote_launch_profile_rules` in `api_server.rs`, which is
|
||||
* authoritative; this only avoids offering an action that would be refused.
|
||||
*/
|
||||
export function canLaunchRemotely(profile: BrowserProfile): boolean {
|
||||
const syncMode = profile.sync_mode ?? "Disabled";
|
||||
if (syncMode === "Disabled" || syncMode === "Encrypted") return false;
|
||||
return resolvedOs(profile) !== null;
|
||||
}
|
||||
|
||||
export function preflightReason(t: TFunction, result: PreflightResult): string {
|
||||
switch (result.code) {
|
||||
case "syncOff":
|
||||
@@ -391,6 +489,36 @@ export function outcomeLabel(
|
||||
return key ? t(key) : t("cookieBot.outcome.unknown", { code });
|
||||
}
|
||||
|
||||
/**
|
||||
* The three refusals only the saved-list routes can raise.
|
||||
*
|
||||
* They are absent from the shared `backendErrors` table, so
|
||||
* `translateBackendError` renders them through its unknown-code fallback: a
|
||||
* user who reuses a name would be told "Something went wrong:
|
||||
* COOKIE_BOT_TEMPLATE_NAME_TAKEN" instead of that the name is taken, in the one
|
||||
* dialog where the fix is a single keystroke. Everything else — a signed-out
|
||||
* desktop, an unreachable cloud — still goes through the shared translator.
|
||||
*/
|
||||
const TEMPLATE_ERROR_KEYS: Record<string, string> = {
|
||||
COOKIE_BOT_TEMPLATE_NAME_TAKEN: "cookieBot.enrol.templateNameTaken",
|
||||
COOKIE_BOT_INVALID_TEMPLATE_NAME: "cookieBot.enrol.templateNameInvalid",
|
||||
COOKIE_BOT_TEMPLATE_NOT_FOUND: "cookieBot.enrol.templateMissing",
|
||||
};
|
||||
|
||||
export function templateErrorMessage(t: TFunction, error: unknown): string {
|
||||
const parsed = parseBackendError(error);
|
||||
const key = parsed ? TEMPLATE_ERROR_KEYS[parsed.code] : undefined;
|
||||
if (!key) return translateBackendError(t, error);
|
||||
const max = parsed?.params?.max;
|
||||
// The server does not always send a limit. Interpolating an empty string
|
||||
// rendered "a name of characters or fewer", so fall back to wording that
|
||||
// does not need the number.
|
||||
if (key === "cookieBot.enrol.templateNameInvalid" && !max) {
|
||||
return t("cookieBot.enrol.templateNameInvalidNoMax");
|
||||
}
|
||||
return t(key, { max });
|
||||
}
|
||||
|
||||
/**
|
||||
* The session state machine, named honestly. `provisioning -> ready -> live ->
|
||||
* closed`, with `error` reachable from any of the first three, is what the
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { GoPlus } from "react-icons/go";
|
||||
import { LuChevronLeft, LuChevronRight, LuSearch, LuX } from "react-icons/lu";
|
||||
import { useWindowDecorations } from "@/hooks/use-window-decorations";
|
||||
import { getCurrentOS } from "@/lib/browser-utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { GroupWithCount } from "@/types";
|
||||
@@ -59,8 +60,14 @@ const HomeHeader = ({
|
||||
}, []);
|
||||
|
||||
const isMacOS = platform === "macos";
|
||||
const isLinux = platform === "linux";
|
||||
const showProfileToolbar = !pageTitle;
|
||||
|
||||
// Same hook the controls use, so the reserved space can never disagree with
|
||||
// what is actually drawn.
|
||||
const decorations = useWindowDecorations();
|
||||
const linuxLayout = decorations.clientSide ? decorations.layout : null;
|
||||
|
||||
// Press-and-hold drag: any pixel of the sys-bar becomes a drag handle after
|
||||
// HOLD_MS, but quick clicks still reach buttons/inputs underneath.
|
||||
const holdTimeoutRef = useRef<number | null>(null);
|
||||
@@ -179,14 +186,29 @@ const HomeHeader = ({
|
||||
onPointerCancel={handlePointerEnd}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
className={cn(
|
||||
"flex h-11 items-center gap-2 border-b border-border bg-card pl-3 select-none",
|
||||
"flex h-11 items-center gap-2 border-b border-border bg-card select-none",
|
||||
// Windows: WindowDragArea renders three 44px native-style controls
|
||||
// (minimize + maximize/restore + close) fixed at top-right with
|
||||
// z-50, total 132px wide. Reserve 144px on the right edge so the
|
||||
// "+ New" button and search input clear them with a few pixels of
|
||||
// breathing room and never sit underneath the controls.
|
||||
isWindows ? "pr-[144px]" : "pr-3",
|
||||
isWindows ? "pl-3 pr-[144px]" : null,
|
||||
// Linux reserves its space through the inline style below, because the
|
||||
// desktop chooses which side the controls sit on and how many there
|
||||
// are. Everything else keeps the plain symmetric padding.
|
||||
!isWindows && !isLinux ? "pl-3 pr-3" : null,
|
||||
)}
|
||||
style={
|
||||
isLinux
|
||||
? {
|
||||
// Each control is 44px wide; add the usual 12px gutter. Before
|
||||
// the layout resolves, fall back to the gutter alone rather than
|
||||
// to no padding, which would visibly shift the content.
|
||||
paddingLeft: (linuxLayout?.left.length ?? 0) * 44 + 12,
|
||||
paddingRight: (linuxLayout?.right.length ?? 0) * 44 + 12,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{isMacOS && (
|
||||
<div
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
"use client";
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LuTriangleAlert } from "react-icons/lu";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { translateBackendError } from "@/lib/backend-errors";
|
||||
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
|
||||
import type { ConsistencyResult, DetectedVpnExtension } from "@/types";
|
||||
import { RippleButton } from "./ui/ripple";
|
||||
|
||||
export interface GateFindings {
|
||||
/// Extensions that can reroute traffic. A warning: the user may proceed.
|
||||
vpnExtensions: DetectedVpnExtension[];
|
||||
scanState: string;
|
||||
/// A measured exit/fingerprint mismatch. A block: the browser has not started.
|
||||
fingerprint: ConsistencyResult | null;
|
||||
/// A confirmed proxy-permission extension makes any exit measurement suspect.
|
||||
measurementUnreliable: boolean;
|
||||
/// The exit has not been measured yet; the launch itself will still check.
|
||||
probePending: boolean;
|
||||
}
|
||||
|
||||
export interface GateDecision {
|
||||
proceed: boolean;
|
||||
ackFingerprint: boolean;
|
||||
ackExtensionKeys: string[];
|
||||
applyToRemaining: boolean;
|
||||
}
|
||||
|
||||
interface PreLaunchGateDialogProps {
|
||||
isOpen: boolean;
|
||||
profileName: string;
|
||||
profileId: string;
|
||||
findings: GateFindings | null;
|
||||
/// How many further profiles are queued behind this one; >0 offers to apply
|
||||
/// the same decision to all of them.
|
||||
remainingCount: number;
|
||||
/// The single exit route. Every path out of this dialog calls it exactly
|
||||
/// once, so a caller awaiting a decision can never be left hanging.
|
||||
onResult: (decision: GateDecision) => void;
|
||||
}
|
||||
|
||||
export function PreLaunchGateDialog({
|
||||
isOpen,
|
||||
profileName,
|
||||
profileId,
|
||||
findings,
|
||||
remainingCount,
|
||||
onResult,
|
||||
}: PreLaunchGateDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [ackFingerprint, setAckFingerprint] = useState(false);
|
||||
const [ackExtensions, setAckExtensions] = useState(false);
|
||||
const [applyToRemaining, setApplyToRemaining] = useState(false);
|
||||
const [isMatching, setIsMatching] = useState(false);
|
||||
// The dialog node is reused as the queue advances, so without this a double
|
||||
// click would decide for the next profile too.
|
||||
const [decided, setDecided] = useState(false);
|
||||
|
||||
// Keyed on profileId, not just isOpen: a queued gate promotes the next
|
||||
// profile without ever closing the dialog, so an isOpen-only reset would
|
||||
// carry the previous profile's ticked boxes — and persist an acknowledgement
|
||||
// against a profile the user never saw.
|
||||
useEffect(() => {
|
||||
setAckFingerprint(false);
|
||||
setAckExtensions(false);
|
||||
setIsMatching(false);
|
||||
setDecided(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setApplyToRemaining(false);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const fingerprint = findings?.fingerprint ?? null;
|
||||
const extensions = findings?.vpnExtensions ?? [];
|
||||
const mismatches = fingerprint?.mismatches ?? [];
|
||||
const exitIp = fingerprint?.exit_ip ?? null;
|
||||
const isBlocked = fingerprint !== null;
|
||||
|
||||
const decide = (proceed: boolean) => {
|
||||
if (decided) {
|
||||
return;
|
||||
}
|
||||
setDecided(true);
|
||||
onResult({
|
||||
proceed,
|
||||
ackFingerprint: ackFingerprint && isBlocked,
|
||||
ackExtensionKeys: ackExtensions ? extensions.map((e) => e.key) : [],
|
||||
applyToRemaining,
|
||||
});
|
||||
};
|
||||
|
||||
const handleMatchFingerprint = async () => {
|
||||
if (!exitIp) {
|
||||
return;
|
||||
}
|
||||
setIsMatching(true);
|
||||
try {
|
||||
await invoke("match_profile_fingerprint_to_exit", {
|
||||
profileId,
|
||||
exitIp,
|
||||
});
|
||||
showSuccessToast(t("consistencyWarning.matchSuccess"));
|
||||
// The fingerprint the block was measured against no longer exists, so
|
||||
// this launch is abandoned rather than forced through with a stale
|
||||
// consent token; the user relaunches against the corrected profile.
|
||||
decide(false);
|
||||
} catch (e) {
|
||||
showErrorToast(translateBackendError(t, e));
|
||||
} finally {
|
||||
setIsMatching(false);
|
||||
}
|
||||
};
|
||||
|
||||
const scanNotice = (() => {
|
||||
switch (findings?.scanState) {
|
||||
case "encrypted":
|
||||
return t("prelaunchGate.scanIncompleteEncrypted");
|
||||
case "ephemeral":
|
||||
return t("prelaunchGate.scanIncompleteEphemeral");
|
||||
case "partial":
|
||||
return t("prelaunchGate.scanIncompletePartial");
|
||||
case "missing":
|
||||
return t("prelaunchGate.scanIncompleteMissing");
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen}>
|
||||
<DialogContent className="sm:max-w-md" dismissible={false}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<LuTriangleAlert className="size-5 text-warning-text" />
|
||||
{isBlocked
|
||||
? t("prelaunchGate.titleBlocked")
|
||||
: t("prelaunchGate.titleWarning")}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3 text-sm">
|
||||
<p className="text-muted-foreground">
|
||||
{t("prelaunchGate.intro", { name: profileName })}
|
||||
</p>
|
||||
|
||||
{isBlocked && (
|
||||
<div className="space-y-2 rounded-md border border-destructive/50 bg-destructive/10 p-3">
|
||||
<p className="font-medium">
|
||||
{t("prelaunchGate.fingerprintHeading")}
|
||||
</p>
|
||||
{mismatches.includes("timezone") && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("consistencyWarning.timezoneDetail", {
|
||||
exit: fingerprint?.exit_timezone ?? "?",
|
||||
fingerprint: fingerprint?.fingerprint_timezone ?? "?",
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
{mismatches.includes("language") && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("consistencyWarning.languageDetail", {
|
||||
country: fingerprint?.exit_country_code ?? "?",
|
||||
fingerprint: fingerprint?.fingerprint_language ?? "?",
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("consistencyWarning.explainer")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{extensions.length > 0 && (
|
||||
<div className="space-y-2 rounded-md border border-warning/50 bg-warning/10 p-3">
|
||||
<p className="font-medium">
|
||||
{t("prelaunchGate.vpnExtensionHeading")}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("prelaunchGate.vpnExtensionIntro")}
|
||||
</p>
|
||||
<ul className="space-y-1">
|
||||
{extensions.map((ext) => (
|
||||
<li key={ext.key} className="text-xs">
|
||||
<span className="font-medium">{ext.name}</span>
|
||||
<span className="text-muted-foreground">
|
||||
{t("prelaunchGate.vpnExtensionEntry", {
|
||||
version: ext.version ?? "",
|
||||
capability:
|
||||
ext.confidence === "confirmed"
|
||||
? t("prelaunchGate.vpnExtensionConfirmed")
|
||||
: t("prelaunchGate.vpnExtensionLikely"),
|
||||
source:
|
||||
ext.source === "donut"
|
||||
? t("prelaunchGate.sourceDonut")
|
||||
: t("prelaunchGate.sourceBrowser"),
|
||||
})}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("prelaunchGate.vpnExtensionExplainer")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{findings?.measurementUnreliable && isBlocked && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("prelaunchGate.measurementUnreliable")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{findings?.probePending && !isBlocked && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("prelaunchGate.probePending")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{scanNotice && (
|
||||
<p className="text-xs text-muted-foreground">{scanNotice}</p>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
{isBlocked && (
|
||||
<div className="flex items-center gap-x-2">
|
||||
<Checkbox
|
||||
id="gate-ack-fingerprint"
|
||||
checked={ackFingerprint}
|
||||
onCheckedChange={(v) => setAckFingerprint(v === true)}
|
||||
/>
|
||||
<Label htmlFor="gate-ack-fingerprint" className="text-xs">
|
||||
{t("prelaunchGate.dontBlockAgain")}
|
||||
</Label>
|
||||
</div>
|
||||
)}
|
||||
{extensions.length > 0 && (
|
||||
<div className="flex items-center gap-x-2">
|
||||
<Checkbox
|
||||
id="gate-ack-extensions"
|
||||
checked={ackExtensions}
|
||||
onCheckedChange={(v) => setAckExtensions(v === true)}
|
||||
/>
|
||||
<Label htmlFor="gate-ack-extensions" className="text-xs">
|
||||
{t("prelaunchGate.dontWarnExtensions")}
|
||||
</Label>
|
||||
</div>
|
||||
)}
|
||||
{remainingCount > 0 && (
|
||||
<div className="flex items-center gap-x-2">
|
||||
<Checkbox
|
||||
id="gate-apply-remaining"
|
||||
checked={applyToRemaining}
|
||||
onCheckedChange={(v) => setApplyToRemaining(v === true)}
|
||||
/>
|
||||
<Label htmlFor="gate-apply-remaining" className="text-xs">
|
||||
{t("prelaunchGate.applyToRemaining")}
|
||||
</Label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="flex-row justify-between sm:justify-between">
|
||||
{/* Cancel is the default action: the browser has not started, and
|
||||
not starting it is the safe outcome. */}
|
||||
<RippleButton
|
||||
variant="outline"
|
||||
onClick={() => decide(false)}
|
||||
disabled={isMatching || decided}
|
||||
autoFocus
|
||||
>
|
||||
{t("common.buttons.cancel")}
|
||||
</RippleButton>
|
||||
<div className="flex gap-2">
|
||||
{isBlocked && exitIp && (
|
||||
<RippleButton
|
||||
variant="outline"
|
||||
onClick={() => void handleMatchFingerprint()}
|
||||
disabled={isMatching || decided}
|
||||
>
|
||||
{isMatching
|
||||
? t("consistencyWarning.matching")
|
||||
: t("consistencyWarning.matchToProxy")}
|
||||
</RippleButton>
|
||||
)}
|
||||
<RippleButton
|
||||
variant={isBlocked ? "destructive" : "default"}
|
||||
onClick={() => decide(true)}
|
||||
disabled={isMatching || decided}
|
||||
>
|
||||
{t("prelaunchGate.launchAnyway")}
|
||||
</RippleButton>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -101,6 +101,7 @@ import { useBrowserState } from "@/hooks/use-browser-state";
|
||||
import { useCloudAuth } from "@/hooks/use-cloud-auth";
|
||||
import { cookieBotScopeFor, useCookieBot } from "@/hooks/use-cookie-bot";
|
||||
import { useProxyEvents } from "@/hooks/use-proxy-events";
|
||||
import { useRemoteHandoff } from "@/hooks/use-remote-handoff";
|
||||
import { useScrollFade } from "@/hooks/use-scroll-fade";
|
||||
import { useTableSorting } from "@/hooks/use-table-sorting";
|
||||
import { useTeamLocks } from "@/hooks/use-team-locks";
|
||||
@@ -121,6 +122,7 @@ import {
|
||||
import { DNS_BLOCKLIST_LEVELS } from "@/lib/dns-blocklist-levels";
|
||||
import { canUseCookieBot } from "@/lib/entitlements";
|
||||
import { formatRelativeTime } from "@/lib/flag-utils";
|
||||
import type { RemoteHandoffState } from "@/lib/remote-sessions";
|
||||
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type {
|
||||
@@ -240,7 +242,7 @@ interface TableMeta {
|
||||
setLaunchingProfiles: React.Dispatch<React.SetStateAction<Set<string>>>;
|
||||
setStoppingProfiles: React.Dispatch<React.SetStateAction<Set<string>>>;
|
||||
onKillProfile: (profile: BrowserProfile) => void | Promise<void>;
|
||||
onLaunchProfile: (profile: BrowserProfile) => void | Promise<void>;
|
||||
onLaunchProfile: (profile: BrowserProfile) => void | Promise<unknown>;
|
||||
|
||||
// Overflow actions
|
||||
onAssignProfilesToGroup?: (profileIds: string[]) => void;
|
||||
@@ -273,6 +275,15 @@ interface TableMeta {
|
||||
isProfileLockedByAnother: (profileId: string) => boolean;
|
||||
getProfileLockEmail: (profileId: string) => string | undefined;
|
||||
|
||||
// Remote execution.
|
||||
//
|
||||
// `getRemoteHandoff` is the authoritative answer to "can this be opened
|
||||
// here", read from the same store the backend gate reads. The team-lock cache
|
||||
// above cannot serve it: it refreshes on a 30-second poll and says nothing at
|
||||
// all about a session that has finished but whose work has not been pulled
|
||||
// back yet.
|
||||
getRemoteHandoff: (profileId: string) => RemoteHandoffState | null;
|
||||
|
||||
// Synchronizer
|
||||
getProfileSyncInfo: (profileId: string) =>
|
||||
| {
|
||||
@@ -1383,7 +1394,7 @@ BotCell.displayName = "BotCell";
|
||||
|
||||
interface ProfilesDataTableProps {
|
||||
profiles: BrowserProfile[];
|
||||
onLaunchProfile: (profile: BrowserProfile) => void | Promise<void>;
|
||||
onLaunchProfile: (profile: BrowserProfile) => void | Promise<unknown>;
|
||||
onKillProfile: (profile: BrowserProfile) => void | Promise<void>;
|
||||
onCloneProfile: (profile: BrowserProfile) => void | Promise<void>;
|
||||
onDeleteProfile: (profile: BrowserProfile) => void | Promise<void>;
|
||||
@@ -1585,6 +1596,10 @@ export function ProfilesDataTable({
|
||||
const { vpnConfigs } = useVpnEvents();
|
||||
const { user } = useCloudAuth();
|
||||
const { isProfileLocked, getLockInfo } = useTeamLocks(user?.id);
|
||||
// Which profiles cannot be opened on this computer, and why. Event-driven and
|
||||
// read from the backend's own gate, so the button state and the refusal the
|
||||
// backend would give can never disagree.
|
||||
const { handoffFor } = useRemoteHandoff();
|
||||
|
||||
// Cookie Bot. Enrolments and live runs both live server-side, so the table
|
||||
// reads them from the shared store rather than from BrowserProfile.
|
||||
@@ -2445,6 +2460,9 @@ export function ProfilesDataTable({
|
||||
getProfileLockEmail: (profileId: string) =>
|
||||
getLockInfo(profileId)?.lockedByEmail,
|
||||
|
||||
// Remote execution
|
||||
getRemoteHandoff: handoffFor,
|
||||
|
||||
// Synchronizer
|
||||
getProfileSyncInfo: getProfileSyncInfo ?? (() => undefined),
|
||||
onLaunchWithSync:
|
||||
@@ -2524,6 +2542,7 @@ export function ProfilesDataTable({
|
||||
handleCreateCountryProxy,
|
||||
isProfileLocked,
|
||||
getLockInfo,
|
||||
handoffFor,
|
||||
getProfileSyncInfo,
|
||||
onLaunchWithSync,
|
||||
cookieBotUnlocked,
|
||||
@@ -2725,20 +2744,37 @@ export function ProfilesDataTable({
|
||||
cell: ({ row, table }) => {
|
||||
const meta = table.options.meta as TableMeta;
|
||||
const profile = row.original;
|
||||
const handoff = meta.getRemoteHandoff(profile.id);
|
||||
// A profile open on the fleet IS running, and the button has to say
|
||||
// so: it is the control that stops it, and stopping now reaches the
|
||||
// remote browser rather than looking for a local process that was
|
||||
// never there.
|
||||
const isRunningRemotely = handoff === "running";
|
||||
const isPendingRemotePull = handoff === "pending_sync";
|
||||
const isRunning =
|
||||
meta.isClient && meta.runningProfiles.has(profile.id);
|
||||
(meta.isClient && meta.runningProfiles.has(profile.id)) ||
|
||||
isRunningRemotely;
|
||||
const isLaunching = meta.launchingProfiles.has(profile.id);
|
||||
const isStopping = meta.stoppingProfiles.has(profile.id);
|
||||
const isLockedByAnother = meta.isProfileLockedByAnother(profile.id);
|
||||
const isSyncing = meta.syncStatuses[profile.id]?.status === "syncing";
|
||||
const canLaunch =
|
||||
meta.browserState.canLaunchProfile(profile) &&
|
||||
!isLockedByAnother &&
|
||||
!isSyncing;
|
||||
// A remote session holds the profile lock under its own holder id, so
|
||||
// `isLockedByAnother` is true for the user's OWN fleet session. That
|
||||
// must not disable the control that stops it.
|
||||
const canLaunch = isRunningRemotely
|
||||
? true
|
||||
: meta.browserState.canLaunchProfile(profile) &&
|
||||
!isPendingRemotePull &&
|
||||
!isLockedByAnother &&
|
||||
!isSyncing;
|
||||
const lockEmail = meta.getProfileLockEmail(profile.id);
|
||||
const tooltipContent = isLockedByAnother
|
||||
? meta.t("sync.team.cannotLaunchLocked", { email: lockEmail })
|
||||
: meta.browserState.getLaunchTooltipContent(profile);
|
||||
const tooltipContent = isRunningRemotely
|
||||
? meta.t("profiles.remote.runningTooltip")
|
||||
: isPendingRemotePull
|
||||
? meta.t("profiles.remote.pendingSyncTooltip")
|
||||
: isLockedByAnother
|
||||
? meta.t("sync.team.cannotLaunchLocked", { email: lockEmail })
|
||||
: meta.browserState.getLaunchTooltipContent(profile);
|
||||
|
||||
const handleProfileStop = async (profile: BrowserProfile) => {
|
||||
meta.setStoppingProfiles((prev: Set<string>) =>
|
||||
|
||||
@@ -28,7 +28,9 @@ import {
|
||||
import { useBrowserState } from "@/hooks/use-browser-state";
|
||||
import { useProfileEvents } from "@/hooks/use-profile-events";
|
||||
import { useProxyEvents } from "@/hooks/use-proxy-events";
|
||||
import { translateBackendError } from "@/lib/backend-errors";
|
||||
import { getBrowserDisplayName, getBrowserIcon } from "@/lib/browser-utils";
|
||||
import { showErrorToast } from "@/lib/toast-utils";
|
||||
import type { BrowserProfile } from "@/types";
|
||||
import { CopyToClipboard } from "./ui/copy-to-clipboard";
|
||||
import { RippleButton } from "./ui/ripple";
|
||||
@@ -108,10 +110,15 @@ export function ProfileSelectorDialog({
|
||||
await invoke("open_url_with_profile", {
|
||||
profileId: selected.id,
|
||||
url,
|
||||
consentToken: null,
|
||||
});
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error("Failed to open URL with profile:", error);
|
||||
// This path reaches the browser without going through page.tsx's gate,
|
||||
// so a launch the gate blocks surfaces here. Without a toast the deep
|
||||
// link would simply appear to do nothing.
|
||||
showErrorToast(translateBackendError(t, error));
|
||||
} finally {
|
||||
setIsLaunching(false);
|
||||
if (selected) {
|
||||
@@ -122,7 +129,7 @@ export function ProfileSelectorDialog({
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [selectedProfile, url, onClose, profiles]);
|
||||
}, [selectedProfile, url, onClose, profiles, t]);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
setSelectedProfile(null);
|
||||
|
||||
@@ -89,6 +89,12 @@ export function ProxyFormDialog({
|
||||
const { t } = useTranslation();
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [form, setForm] = useState<ProxyFormData>(DEFAULT_FORM);
|
||||
// The local parse only covers scheme/host/port. Whether Donut can actually
|
||||
// use the server — REALITY, XTLS Vision, plain TCP — is decided by the Rust
|
||||
// parser, so ask it (below) and show the specific reason while the user is
|
||||
// still editing rather than after they save. Declared here because
|
||||
// `handleSubmit` guards on it.
|
||||
const [vlessUnsupported, setVlessUnsupported] = useState<string | null>(null);
|
||||
|
||||
const resetForm = useCallback(() => {
|
||||
setForm(DEFAULT_FORM);
|
||||
@@ -134,6 +140,11 @@ export function ProxyFormDialog({
|
||||
return;
|
||||
}
|
||||
|
||||
if (isVless && vlessUnsupported) {
|
||||
toast.error(vlessUnsupported);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isVless && (!form.host.trim() || !form.port)) {
|
||||
toast.error(t("proxies.form.hostPortRequired"));
|
||||
return;
|
||||
@@ -183,7 +194,7 @@ export function ProxyFormDialog({
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}, [editingProxy, form, onClose, t]);
|
||||
}, [editingProxy, form, onClose, t, vlessUnsupported]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
if (!isSubmitting) {
|
||||
@@ -193,12 +204,37 @@ export function ProxyFormDialog({
|
||||
|
||||
const isVless = form.proxy_type === "vless";
|
||||
const vlessEndpoint = isVless ? parseVlessEndpoint(form.vless_uri) : null;
|
||||
|
||||
const trimmedVlessUri = form.vless_uri.trim();
|
||||
useEffect(() => {
|
||||
if (!isVless || trimmedVlessUri.length === 0) {
|
||||
setVlessUnsupported(null);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
const timer = window.setTimeout(() => {
|
||||
void invoke("validate_vless_uri", { uri: trimmedVlessUri })
|
||||
.then(() => {
|
||||
if (!cancelled) setVlessUnsupported(null);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (!cancelled) setVlessUnsupported(translateBackendError(t, error));
|
||||
});
|
||||
}, 300);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearTimeout(timer);
|
||||
};
|
||||
}, [isVless, trimmedVlessUri, t]);
|
||||
|
||||
const hasInvalidVlessUri =
|
||||
isVless && form.vless_uri.trim().length > 0 && !vlessEndpoint;
|
||||
isVless &&
|
||||
trimmedVlessUri.length > 0 &&
|
||||
(!vlessEndpoint || vlessUnsupported !== null);
|
||||
const isFormValid =
|
||||
form.name.trim() &&
|
||||
(isVless
|
||||
? vlessEndpoint !== null
|
||||
? vlessEndpoint !== null && vlessUnsupported === null
|
||||
: form.host.trim() &&
|
||||
form.port > 0 &&
|
||||
form.port <= 65535 &&
|
||||
@@ -286,7 +322,7 @@ export function ProxyFormDialog({
|
||||
role={hasInvalidVlessUri ? "alert" : undefined}
|
||||
>
|
||||
{hasInvalidVlessUri
|
||||
? t("proxies.form.vlessUriInvalid")
|
||||
? (vlessUnsupported ?? t("proxies.form.vlessUriInvalid"))
|
||||
: t("proxies.form.vlessUriHint")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -73,6 +73,8 @@ interface AppSettings {
|
||||
api_token?: string;
|
||||
disable_auto_updates?: boolean;
|
||||
keep_decrypted_profiles_in_ram?: boolean;
|
||||
fingerprint_gate_disabled?: boolean;
|
||||
vpn_extension_warning_disabled?: boolean;
|
||||
}
|
||||
|
||||
interface CustomThemeState {
|
||||
@@ -127,15 +129,6 @@ export function SettingsDialog({
|
||||
const [isSettingDefault, setIsSettingDefault] = useState(false);
|
||||
const [isClearingCache, setIsClearingCache] = useState(false);
|
||||
const [isClearingTraffic, setIsClearingTraffic] = useState(false);
|
||||
const [consistencyWarningEnabled, setConsistencyWarningEnabled] = useState(
|
||||
() => {
|
||||
try {
|
||||
return localStorage.getItem("consistency-warn-disabled") !== "1";
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
},
|
||||
);
|
||||
const [permissions, setPermissions] = useState<PermissionInfo[]>([]);
|
||||
const [isLoadingPermissions, setIsLoadingPermissions] = useState(false);
|
||||
const [requestingPermission, setRequestingPermission] =
|
||||
@@ -267,9 +260,28 @@ export function SettingsDialog({
|
||||
? normalizeThemeColors(appSettings.custom_theme)
|
||||
: tokyoNightTheme.colors,
|
||||
};
|
||||
setSettings(merged);
|
||||
setOriginalSettings(merged);
|
||||
originalSettingsRef.current = merged;
|
||||
// One-shot migration off the old localStorage flag. Without it, a user
|
||||
// who explicitly turned the warning off would start getting hard blocks
|
||||
// after updating — the single most likely support complaint here.
|
||||
let migrated = merged;
|
||||
try {
|
||||
if (
|
||||
localStorage.getItem("consistency-warn-disabled") === "1" &&
|
||||
!merged.fingerprint_gate_disabled
|
||||
) {
|
||||
migrated = { ...merged, fingerprint_gate_disabled: true };
|
||||
await invoke<AppSettings>("save_app_settings", {
|
||||
settings: migrated,
|
||||
});
|
||||
}
|
||||
localStorage.removeItem("consistency-warn-disabled");
|
||||
} catch (err) {
|
||||
console.warn("Failed to migrate consistency warning preference:", err);
|
||||
}
|
||||
|
||||
setSettings(migrated);
|
||||
setOriginalSettings(migrated);
|
||||
originalSettingsRef.current = migrated;
|
||||
hasLoadedSettingsRef.current = true;
|
||||
setHasLoadedSettings(true);
|
||||
|
||||
@@ -687,7 +699,11 @@ export function SettingsDialog({
|
||||
(settings.theme !== "custom" &&
|
||||
JSON.stringify(settings.custom_theme ?? {}) !==
|
||||
JSON.stringify(originalSettings.custom_theme ?? {})) ||
|
||||
settings.disable_auto_updates !== originalSettings.disable_auto_updates;
|
||||
settings.disable_auto_updates !== originalSettings.disable_auto_updates ||
|
||||
settings.fingerprint_gate_disabled !==
|
||||
originalSettings.fingerprint_gate_disabled ||
|
||||
settings.vpn_extension_warning_disabled !==
|
||||
originalSettings.vpn_extension_warning_disabled;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -1392,21 +1408,32 @@ export function SettingsDialog({
|
||||
</div>
|
||||
<AnimatedSwitch
|
||||
aria-label={t("settings.privacy.consistencyWarning")}
|
||||
checked={consistencyWarningEnabled}
|
||||
checked={!(settings.fingerprint_gate_disabled ?? false)}
|
||||
onCheckedChange={(v) => {
|
||||
setConsistencyWarningEnabled(v === true);
|
||||
try {
|
||||
if (v === true) {
|
||||
localStorage.removeItem("consistency-warn-disabled");
|
||||
} else {
|
||||
localStorage.setItem(
|
||||
"consistency-warn-disabled",
|
||||
"1",
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// localStorage unavailable
|
||||
}
|
||||
updateSetting("fingerprint_gate_disabled", v !== true);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start justify-between gap-x-3 rounded-lg border p-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="text-sm font-medium">
|
||||
{t("settings.privacy.vpnExtensionWarning")}
|
||||
</span>
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
{t("settings.privacy.vpnExtensionWarningDescription")}
|
||||
</span>
|
||||
</div>
|
||||
<AnimatedSwitch
|
||||
aria-label={t("settings.privacy.vpnExtensionWarning")}
|
||||
checked={
|
||||
!(settings.vpn_extension_warning_disabled ?? false)
|
||||
}
|
||||
onCheckedChange={(v) => {
|
||||
updateSetting(
|
||||
"vpn_extension_warning_disabled",
|
||||
v !== true,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
+165
-118
@@ -3,19 +3,23 @@
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useWindowDecorations } from "@/hooks/use-window-decorations";
|
||||
import { getCurrentOS, type OperatingSystem } from "@/lib/platform";
|
||||
import type { WindowControl } from "@/lib/window-decorations";
|
||||
import { WindowResizeHandles } from "./window-resize-handles";
|
||||
|
||||
export function WindowDragArea() {
|
||||
const { t } = useTranslation();
|
||||
const [platform, setPlatform] = useState<OperatingSystem | null>(null);
|
||||
const [isMaximized, setIsMaximized] = useState(false);
|
||||
const decorations = useWindowDecorations();
|
||||
|
||||
useEffect(() => {
|
||||
setPlatform(getCurrentOS());
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (platform !== "windows") return;
|
||||
if (platform !== "windows" && platform !== "linux") return;
|
||||
const win = getCurrentWindow();
|
||||
let cancelled = false;
|
||||
const sync = async () => {
|
||||
@@ -38,42 +42,6 @@ export function WindowDragArea() {
|
||||
};
|
||||
}, [platform]);
|
||||
|
||||
const handlePointerDown = (e: React.PointerEvent) => {
|
||||
if (e.button !== 0) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const startDrag = async () => {
|
||||
try {
|
||||
const window = getCurrentWindow();
|
||||
await window.startDragging();
|
||||
} catch (error) {
|
||||
console.error("Failed to start window dragging:", error);
|
||||
}
|
||||
};
|
||||
|
||||
void startDrag();
|
||||
};
|
||||
|
||||
// Linux: system decorations handle everything
|
||||
if (!platform || platform === "linux" || platform === "unknown") {
|
||||
return null;
|
||||
}
|
||||
|
||||
// macOS: nothing to render here. The transparent native titlebar (set via
|
||||
// `set_transparent_titlebar(true)` in src-tauri/src/lib.rs) lets the OS
|
||||
// handle dragging directly, and the sys-bar inside `home-header.tsx`
|
||||
// declares its own `data-tauri-drag-region` overlay for the WebView area.
|
||||
// The previous full-width fixed z-[999999] button was stealing every
|
||||
// click in the top 40px of the window.
|
||||
if (platform === "macos") {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Windows: minimize/maximize/close controls anchored at the top-right
|
||||
// corner of the sys-bar. The HomeHeader's own drag-region overlay handles window
|
||||
// dragging via Tauri 2, so we don't need a separate draggable spacer
|
||||
// covering the whole width.
|
||||
const handleMinimize = async () => {
|
||||
try {
|
||||
await getCurrentWindow().minimize();
|
||||
@@ -97,93 +65,172 @@ export function WindowDragArea() {
|
||||
console.error("Failed to close window:", error);
|
||||
}
|
||||
};
|
||||
void handlePointerDown; // kept for backwards-compat; not used on Windows now
|
||||
|
||||
const renderControl = (control: WindowControl) => {
|
||||
switch (control) {
|
||||
case "minimize":
|
||||
return (
|
||||
<button
|
||||
key="minimize"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void handleMinimize();
|
||||
}}
|
||||
className="flex h-full w-11 items-center justify-center text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground"
|
||||
aria-label={t("common.window.minimize")}
|
||||
>
|
||||
<svg
|
||||
width="10"
|
||||
height="1"
|
||||
viewBox="0 0 10 1"
|
||||
fill="currentColor"
|
||||
role="img"
|
||||
aria-label={t("common.window.minimize")}
|
||||
>
|
||||
<rect width="10" height="1" />
|
||||
</svg>
|
||||
</button>
|
||||
);
|
||||
case "maximize":
|
||||
return (
|
||||
<button
|
||||
key="maximize"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void handleToggleMaximize();
|
||||
}}
|
||||
className="flex h-full w-11 items-center justify-center text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground"
|
||||
aria-label={
|
||||
isMaximized
|
||||
? t("common.window.restore")
|
||||
: t("common.window.maximize")
|
||||
}
|
||||
>
|
||||
{isMaximized ? (
|
||||
<svg
|
||||
width="10"
|
||||
height="10"
|
||||
viewBox="0 0 10 10"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.2"
|
||||
role="img"
|
||||
aria-label={t("common.window.restore")}
|
||||
>
|
||||
<rect x="1" y="3" width="6" height="6" />
|
||||
<path d="M3 3 V1 H9 V7 H7" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg
|
||||
width="10"
|
||||
height="10"
|
||||
viewBox="0 0 10 10"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.2"
|
||||
role="img"
|
||||
aria-label={t("common.window.maximize")}
|
||||
>
|
||||
<rect x="1" y="1" width="8" height="8" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
case "close":
|
||||
return (
|
||||
<button
|
||||
key="close"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void handleClose();
|
||||
}}
|
||||
className="flex h-full w-11 items-center justify-center text-muted-foreground transition-colors hover:bg-destructive hover:text-destructive-foreground"
|
||||
aria-label={t("common.window.close")}
|
||||
>
|
||||
<svg
|
||||
width="10"
|
||||
height="10"
|
||||
viewBox="0 0 10 10"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.2"
|
||||
role="img"
|
||||
aria-label={t("common.window.close")}
|
||||
>
|
||||
<line x1="1" y1="1" x2="9" y2="9" />
|
||||
<line x1="9" y1="1" x2="1" y2="9" />
|
||||
</svg>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
if (!platform || platform === "unknown") {
|
||||
return null;
|
||||
}
|
||||
|
||||
// macOS: nothing to render here. The transparent native titlebar (set via
|
||||
// `set_transparent_titlebar(true)` in src-tauri/src/lib.rs) lets the OS
|
||||
// handle dragging directly, and the sys-bar inside `home-header.tsx`
|
||||
// declares its own `data-tauri-drag-region` overlay for the WebView area.
|
||||
// The previous full-width fixed z-[999999] button was stealing every
|
||||
// click in the top 40px of the window.
|
||||
if (platform === "macos") {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Linux: the window has no server-side decorations, so the app owns both the
|
||||
// controls and the resize edges. Which buttons appear and on which side is a
|
||||
// desktop-wide preference (GNOME's `button-layout`, KWin's decoration
|
||||
// settings), read through GTK so both environments are honored.
|
||||
if (platform === "linux") {
|
||||
// Not resolved yet, or the session keeps server-side decorations (KDE on
|
||||
// Wayland — see `use_client_side_decorations` in the backend). Either way
|
||||
// there is a real titlebar and nothing for the app to draw.
|
||||
if (!decorations.resolved || !decorations.clientSide) {
|
||||
return null;
|
||||
}
|
||||
const { layout } = decorations;
|
||||
return (
|
||||
<>
|
||||
{/* Dropping decorations also drops the compositor's drop shadow, and
|
||||
neither Tauri nor tao exposes a Linux shadow API. Without some edge
|
||||
the window is invisible against a similarly coloured desktop, so
|
||||
draw a hairline. Not rounded: that needs a transparent window, which
|
||||
would conflict with the WebView and the resize strips. */}
|
||||
{!isMaximized && (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none fixed inset-0 z-[99999] border border-border"
|
||||
/>
|
||||
)}
|
||||
<WindowResizeHandles isMaximized={isMaximized} />
|
||||
{layout.left.length > 0 && (
|
||||
<div className="fixed top-0 left-0 z-[100000] flex h-11 items-center select-none pointer-events-auto">
|
||||
{layout.left.map(renderControl)}
|
||||
</div>
|
||||
)}
|
||||
{layout.right.length > 0 && (
|
||||
<div className="fixed top-0 right-0 z-[100000] flex h-11 items-center select-none pointer-events-auto">
|
||||
{layout.right.map(renderControl)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Windows: minimize/maximize/close controls anchored at the top-right
|
||||
// corner of the sys-bar. The HomeHeader's own drag-region overlay handles
|
||||
// window dragging via Tauri 2, so we don't need a separate draggable spacer
|
||||
// covering the whole width.
|
||||
return (
|
||||
<div
|
||||
className="fixed top-0 right-0 z-50 flex h-11 items-center select-none"
|
||||
aria-hidden="false"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void handleMinimize();
|
||||
}}
|
||||
className="flex h-full w-11 items-center justify-center text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground"
|
||||
aria-label={t("common.window.minimize")}
|
||||
>
|
||||
<svg
|
||||
width="10"
|
||||
height="1"
|
||||
viewBox="0 0 10 1"
|
||||
fill="currentColor"
|
||||
role="img"
|
||||
aria-label={t("common.window.minimize")}
|
||||
>
|
||||
<rect width="10" height="1" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void handleToggleMaximize();
|
||||
}}
|
||||
className="flex h-full w-11 items-center justify-center text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground"
|
||||
aria-label={
|
||||
isMaximized ? t("common.window.restore") : t("common.window.maximize")
|
||||
}
|
||||
>
|
||||
{isMaximized ? (
|
||||
<svg
|
||||
width="10"
|
||||
height="10"
|
||||
viewBox="0 0 10 10"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.2"
|
||||
role="img"
|
||||
aria-label={t("common.window.restore")}
|
||||
>
|
||||
<rect x="1" y="3" width="6" height="6" />
|
||||
<path d="M3 3 V1 H9 V7 H7" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg
|
||||
width="10"
|
||||
height="10"
|
||||
viewBox="0 0 10 10"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.2"
|
||||
role="img"
|
||||
aria-label={t("common.window.maximize")}
|
||||
>
|
||||
<rect x="1" y="1" width="8" height="8" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void handleClose();
|
||||
}}
|
||||
className="flex h-full w-11 items-center justify-center text-muted-foreground transition-colors hover:bg-destructive hover:text-destructive-foreground"
|
||||
aria-label={t("common.buttons.close")}
|
||||
>
|
||||
<svg
|
||||
width="10"
|
||||
height="10"
|
||||
viewBox="0 0 10 10"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.2"
|
||||
role="img"
|
||||
aria-label={t("common.buttons.close")}
|
||||
>
|
||||
<line x1="1" y1="1" x2="9" y2="9" />
|
||||
<line x1="9" y1="1" x2="1" y2="9" />
|
||||
</svg>
|
||||
</button>
|
||||
{(["minimize", "maximize", "close"] as WindowControl[]).map(
|
||||
renderControl,
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
"use client";
|
||||
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
|
||||
/**
|
||||
* Mirrors the API's own `ResizeDirection`, which it declares but does not
|
||||
* export. Structurally identical, so a drift would fail the call below.
|
||||
*/
|
||||
type ResizeDirection =
|
||||
| "East"
|
||||
| "North"
|
||||
| "NorthEast"
|
||||
| "NorthWest"
|
||||
| "South"
|
||||
| "SouthEast"
|
||||
| "SouthWest"
|
||||
| "West";
|
||||
|
||||
/**
|
||||
* Mouse resize areas for a window with no server-side decorations.
|
||||
*
|
||||
* `gtk_window_set_decorated(false)` removes GTK's own invisible resize border
|
||||
* along with the frame, so without these the window can only be resized through
|
||||
* window-manager shortcuts (Super+right-drag and friends). Each handle hands the
|
||||
* pointer to the compositor via `begin_resize_drag`, which is the same call
|
||||
* GTK's client-side decorations make, so edge snapping and the resize cursor
|
||||
* come from the WM exactly as they do for a native window.
|
||||
*
|
||||
* Rendered only where the app owns the frame; on macOS the native titlebar is
|
||||
* still in place and the system draws its own resize edges.
|
||||
*/
|
||||
|
||||
/**
|
||||
* GTK's own grab area is far wider, but all of it sits *outside* the window in
|
||||
* the shadow margin. Ours is inside, so every pixel is taken from real content.
|
||||
*/
|
||||
/** Top edge only — it overlaps the 44px-tall window controls. */
|
||||
const TOP_EDGE = "6px";
|
||||
/** Sides and bottom overlap nothing, so they can be comfortably grabbable. */
|
||||
const EDGE = "8px";
|
||||
/** Corners need to win over the edges that overlap them. */
|
||||
const CORNER = "16px";
|
||||
|
||||
interface Handle {
|
||||
direction: ResizeDirection;
|
||||
style: React.CSSProperties;
|
||||
cursor: string;
|
||||
}
|
||||
|
||||
const HANDLES: Handle[] = [
|
||||
// Edges.
|
||||
{
|
||||
direction: "North",
|
||||
cursor: "ns-resize",
|
||||
style: { top: 0, left: CORNER, right: CORNER, height: TOP_EDGE },
|
||||
},
|
||||
{
|
||||
direction: "South",
|
||||
cursor: "ns-resize",
|
||||
style: { bottom: 0, left: CORNER, right: CORNER, height: EDGE },
|
||||
},
|
||||
{
|
||||
direction: "West",
|
||||
cursor: "ew-resize",
|
||||
style: { left: 0, top: CORNER, bottom: CORNER, width: EDGE },
|
||||
},
|
||||
{
|
||||
direction: "East",
|
||||
cursor: "ew-resize",
|
||||
style: { right: 0, top: CORNER, bottom: CORNER, width: EDGE },
|
||||
},
|
||||
// Corners, drawn after the edges so they sit on top of the overlap.
|
||||
{
|
||||
direction: "NorthWest",
|
||||
cursor: "nwse-resize",
|
||||
style: { top: 0, left: 0, width: CORNER, height: TOP_EDGE },
|
||||
},
|
||||
{
|
||||
direction: "NorthEast",
|
||||
cursor: "nesw-resize",
|
||||
style: { top: 0, right: 0, width: CORNER, height: TOP_EDGE },
|
||||
},
|
||||
{
|
||||
direction: "SouthWest",
|
||||
cursor: "nesw-resize",
|
||||
style: { bottom: 0, left: 0, width: CORNER, height: CORNER },
|
||||
},
|
||||
{
|
||||
direction: "SouthEast",
|
||||
cursor: "nwse-resize",
|
||||
style: { bottom: 0, right: 0, width: CORNER, height: CORNER },
|
||||
},
|
||||
];
|
||||
|
||||
export function WindowResizeHandles({ isMaximized }: { isMaximized: boolean }) {
|
||||
// A maximized window has no resizable edge, and leaving the strips live would
|
||||
// put invisible hit areas over real content. Tauri's own built-in undecorated
|
||||
// resizing disables itself while maximized for the same reason.
|
||||
if (isMaximized) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const startResize =
|
||||
(direction: ResizeDirection) => (e: React.PointerEvent) => {
|
||||
// Left button only: right-click belongs to the WM/window menu, and a
|
||||
// middle-click drag should not resize.
|
||||
if (e.button !== 0) {
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
void getCurrentWindow()
|
||||
.startResizeDragging(direction)
|
||||
.catch((error: unknown) => {
|
||||
console.error("Failed to start window resize:", error);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{HANDLES.map((handle) => (
|
||||
<div
|
||||
key={handle.direction}
|
||||
// Deliberately BELOW the window controls (z-100000) rather than
|
||||
// above: a real CSD window keeps its resize border outside the
|
||||
// buttons, but ours is inside the window, so layering it on top
|
||||
// would steal the corner of whichever control sits in that corner.
|
||||
// Edges still win over ordinary content, which is all they need.
|
||||
className="fixed z-[99998] pointer-events-auto"
|
||||
style={{ ...handle.style, cursor: handle.cursor }}
|
||||
onPointerDown={startResize(handle.direction)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
getRemoteHandoffStates,
|
||||
onRemoteHandoffChanged,
|
||||
type RemoteHandoffState,
|
||||
} from "@/lib/remote-sessions";
|
||||
|
||||
/**
|
||||
* Which profiles cannot be opened on this computer right now.
|
||||
*
|
||||
* Reads the same store the backend launch gate reads, so the button this
|
||||
* disables and the refusal the backend would produce can never disagree. That
|
||||
* matters more than it sounds: the previous signal was the profile-lock cache,
|
||||
* which refreshes on a 30-second server poll and only refetches on this
|
||||
* device's own lock events. A profile running on the fleet therefore looked
|
||||
* launchable for up to half a minute, and a profile whose finished session had
|
||||
* not been pulled back looked launchable indefinitely.
|
||||
*
|
||||
* Updates arrive as an event rather than a poll because every transition that
|
||||
* can change this already emits one.
|
||||
*/
|
||||
export function useRemoteHandoff() {
|
||||
const [states, setStates] = useState<Record<string, RemoteHandoffState>>({});
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
setStates(await getRemoteHandoffStates());
|
||||
} catch (error) {
|
||||
// Not signed in, or the app is still starting. The backend gate still
|
||||
// applies; the button is simply not pre-disabled.
|
||||
console.warn("Could not read remote handoff state:", error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
const unlisten = onRemoteHandoffChanged(setStates);
|
||||
return () => {
|
||||
void unlisten.then((off) => {
|
||||
off();
|
||||
});
|
||||
};
|
||||
}, [refresh]);
|
||||
|
||||
const handoffFor = useCallback(
|
||||
(profileId: string): RemoteHandoffState | null => states[profileId] ?? null,
|
||||
[states],
|
||||
);
|
||||
|
||||
return { handoffStates: states, handoffFor, refreshHandoff: refresh };
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
"use client";
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
type DecorationLayout,
|
||||
parseDecorationLayout,
|
||||
type WindowDecorationsInfo,
|
||||
} from "@/lib/window-decorations";
|
||||
|
||||
export interface WindowDecorationsState {
|
||||
/** True when the app owns the titlebar and must draw controls and edges. */
|
||||
clientSide: boolean;
|
||||
/** Which controls go on which side. Meaningless unless `clientSide`. */
|
||||
layout: DecorationLayout;
|
||||
/** False until the backend has answered. */
|
||||
resolved: boolean;
|
||||
}
|
||||
|
||||
const PENDING: WindowDecorationsState = {
|
||||
clientSide: false,
|
||||
layout: { left: [], right: [] },
|
||||
resolved: false,
|
||||
};
|
||||
|
||||
/**
|
||||
* The window's decoration state, shared by everything that has to agree about
|
||||
* it.
|
||||
*
|
||||
* The controls and the header padding that clears them are rendered by two
|
||||
* different components. Fetching this separately in each let them disagree for
|
||||
* a frame — or indefinitely, if one missed the change event — and the visible
|
||||
* result is window controls sitting on top of the search box. One subscription
|
||||
* per consumer, one shared derivation.
|
||||
*/
|
||||
export function useWindowDecorations(): WindowDecorationsState {
|
||||
const [state, setState] = useState<WindowDecorationsState>(PENDING);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const read = async () => {
|
||||
try {
|
||||
const value = await invoke<WindowDecorationsInfo>(
|
||||
"get_window_decoration_layout",
|
||||
);
|
||||
if (cancelled) return;
|
||||
setState({
|
||||
clientSide: value.client_side,
|
||||
layout: parseDecorationLayout(value.layout),
|
||||
resolved: true,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to read window decoration layout:", error);
|
||||
// Assume the platform still draws the titlebar: drawing a second one
|
||||
// over a real one is worse than drawing none.
|
||||
if (!cancelled) {
|
||||
setState({ ...PENDING, resolved: true });
|
||||
}
|
||||
}
|
||||
};
|
||||
void read();
|
||||
// The user can rearrange titlebar buttons while the app is running.
|
||||
const unlisten = listen("window-decoration-layout-changed", () => {
|
||||
void read();
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
void unlisten.then((fn) => {
|
||||
fn();
|
||||
});
|
||||
};
|
||||
}, []);
|
||||
|
||||
return state;
|
||||
}
|
||||
+102
-12
@@ -92,7 +92,8 @@
|
||||
"window": {
|
||||
"minimize": "Minimize",
|
||||
"maximize": "Maximize",
|
||||
"restore": "Restore"
|
||||
"restore": "Restore",
|
||||
"close": "Close window"
|
||||
},
|
||||
"commandPalette": {
|
||||
"title": "Command Palette",
|
||||
@@ -199,11 +200,13 @@
|
||||
"keepDecryptedProfilesInRam": "Keep Decrypted Profiles In RAM",
|
||||
"keepDecryptedProfilesInRamDescription": "Preserve the decrypted in-RAM copy of password-protected profiles between launches for faster startup. The on-disk copy stays encrypted regardless.",
|
||||
"privacy": {
|
||||
"consistencyWarning": "Fingerprint consistency warning",
|
||||
"consistencyWarningDescription": "Warn on launch when a profile's timezone or language doesn't match its proxy exit node.",
|
||||
"consistencyWarning": "Block on fingerprint mismatch",
|
||||
"consistencyWarningDescription": "Stop the browser from starting when a profile's timezone or language doesn't match its proxy exit node. You can still choose to launch.",
|
||||
"clearTraffic": "Clear all traffic history",
|
||||
"clearTrafficDescription": "Securely erase recorded traffic statistics for every profile.",
|
||||
"clearTrafficSuccess": "Traffic history cleared"
|
||||
"clearTrafficSuccess": "Traffic history cleared",
|
||||
"vpnExtensionWarning": "VPN extension warning",
|
||||
"vpnExtensionWarningDescription": "Warn before launching when a profile contains an extension that can reroute the browser's traffic."
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
@@ -345,6 +348,10 @@
|
||||
"nameDesc": "Name (Z–A)",
|
||||
"newest": "Newest first",
|
||||
"oldest": "Oldest first"
|
||||
},
|
||||
"remote": {
|
||||
"runningTooltip": "Running on a remote machine. Stop it to bring the profile back here.",
|
||||
"pendingSyncTooltip": "Downloading what the remote session changed. Available again when it finishes."
|
||||
}
|
||||
},
|
||||
"createProfile": {
|
||||
@@ -461,7 +468,7 @@
|
||||
"ssCipherRequired": "Cipher and password are required for Shadowsocks",
|
||||
"selectType": "Select proxy type",
|
||||
"saveFailed": "Failed to save proxy: {{error}}",
|
||||
"vlessType": "VLESS · Vision · REALITY",
|
||||
"vlessType": "VLESS",
|
||||
"vlessUri": "VLESS URI",
|
||||
"vlessUriPlaceholder": "vless://…",
|
||||
"vlessUriHint": "Requires XTLS Vision and REALITY.",
|
||||
@@ -1859,6 +1866,8 @@
|
||||
"remoteRateLimited": "Too many requests. Wait a moment and try again.",
|
||||
"remoteNoCapacity": "No remote host is free right now. Try again in a few minutes.",
|
||||
"remoteNotEntitled": "Your plan does not include remote execution.",
|
||||
"remoteInteractiveNotEntitled": "Your plan includes remote hours for the Cookie Bot only, not for hands-on remote sessions.",
|
||||
"remoteRequiresRemoteExitNode": "This profile's proxy only works on this computer (for example 127.0.0.1 or a home network address). A remote session runs on our hosts, so it needs a proxy with a public address.",
|
||||
"remoteSessionRefused": "The remote host refused this session.",
|
||||
"remoteSessionNotFound": "That remote session no longer exists.",
|
||||
"remoteSessionConflict": "This profile is already open somewhere else.",
|
||||
@@ -1879,8 +1888,33 @@
|
||||
"cookieBotUnknownPlatform": "This profile has no recorded operating system, so it cannot be matched to a host.",
|
||||
"cookieBotUnsupportedPlatform": "The cookie bot cannot run {{platform}} profiles. Only Windows and macOS profiles are supported.",
|
||||
"cookieBotRequiresExitNode": "Attach a proxy or VPN first. Without one the run would come from a datacenter address, which damages the profile's identity.",
|
||||
"cookieBotRequiresRemoteExitNode": "This profile's proxy only works on this computer (for example 127.0.0.1 or a home network address). Cookie Bot runs on our hosts, so it needs a proxy with a public address.",
|
||||
"unknownCode": "Something went wrong: {{code}}",
|
||||
"cookieBotTouchFingerprintUnsupported": "This profile claims a touch device, which the bot cannot drive. Use a desktop fingerprint."
|
||||
"cookieBotTouchFingerprintUnsupported": "This profile claims a touch device, which the bot cannot drive. Use a desktop fingerprint.",
|
||||
"profileRunningRemotely": "This profile is running on a remote machine. Stop the remote session first.",
|
||||
"profileRemoteSyncPending": "A remote session just finished. Waiting for its changes to download before this profile can open here.",
|
||||
"profileLockedByMember": "This profile is in use by {{email}}.",
|
||||
"profileLockedElsewhere": "This profile is in use on another device.",
|
||||
"profileLockUnavailable": "Could not check whether this profile is in use elsewhere. Check your connection and try again.",
|
||||
"fingerprintExitMismatch": "The proxy exit node doesn't match this profile's fingerprint.",
|
||||
"launchConsentExpired": "That confirmation is no longer valid. Try launching again.",
|
||||
"vpnWorkerStartFailed": "Couldn't start the VPN connection: {{detail}}",
|
||||
"exitProbeFailed": "Couldn't reach the proxy exit node to check its location.",
|
||||
"vlessUnsupported": {
|
||||
"security": "This VLESS server does not use REALITY. Donut supports VLESS with REALITY only.",
|
||||
"flow": "This VLESS server does not use XTLS Vision flow, which Donut requires.",
|
||||
"transport": "Donut supports VLESS over plain TCP only — this server uses a different transport (such as WebSocket or gRPC).",
|
||||
"encryption": "This VLESS server uses an encryption setting Donut does not support.",
|
||||
"headerType": "This VLESS server uses a header obfuscation Donut does not support.",
|
||||
"fingerprint": "This VLESS URI requests a TLS fingerprint Donut does not support.",
|
||||
"sni": "The VLESS URI is missing the SNI (sni) needed for REALITY.",
|
||||
"publicKey": "The VLESS URI is missing the REALITY public key (pbk).",
|
||||
"scheme": "That is not a VLESS link. It must start with vless://.",
|
||||
"parameter": "The VLESS URI contains an option Donut does not support.",
|
||||
"malformed": "The VLESS URI is invalid."
|
||||
},
|
||||
"camoufoxRemoved": "Camoufox is no longer supported. Recreate this profile with Wayfern.",
|
||||
"noE2ePasswordSet": "No end-to-end encryption password is set. Set one before syncing encrypted data."
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "Profiles",
|
||||
@@ -2126,14 +2160,9 @@
|
||||
"description": "Wipe cookies, history and cache when the browser closes. Extensions and bookmarks are kept."
|
||||
},
|
||||
"consistencyWarning": {
|
||||
"title": "Fingerprint mismatch",
|
||||
"intro": "Your proxy exit for \"{{name}}\" doesn't match this profile's fingerprint:",
|
||||
"timezoneTitle": "Timezone mismatch",
|
||||
"timezoneDetail": "Exit node is in {{exit}} but the fingerprint reports {{fingerprint}}.",
|
||||
"languageTitle": "Language mismatch",
|
||||
"languageDetail": "Exit country is {{country}} but the fingerprint language is {{fingerprint}}.",
|
||||
"explainer": "A timezone or language that disagrees with your exit IP is a strong anti-bot signal, even though your real device never leaks. Align the fingerprint with the proxy location to reduce hostile treatment.",
|
||||
"dontWarnAgain": "Don't warn again for this profile",
|
||||
"matchToProxy": "Match fingerprint to proxy",
|
||||
"matching": "Matching…",
|
||||
"matchSuccess": "Fingerprint updated to match the proxy. Relaunch the profile to apply."
|
||||
@@ -2331,13 +2360,48 @@
|
||||
"confirmBulkButton_other": "Continue with {{count}} profiles",
|
||||
"sitesRequired": "Add at least one site.",
|
||||
"addSitesFirst": "Add a site first",
|
||||
"presetsMissing": "Depth presets unavailable"
|
||||
"presetsMissing": "Depth presets unavailable",
|
||||
"sourceOwn": "My own list",
|
||||
"sourceCurated": "Curated",
|
||||
"sourceSaved": "Saved",
|
||||
"curatedEmpty": "No curated lists are available right now.",
|
||||
"curatedNote": "A curated list we keep up to date. Every profile draws its own sample from it, so no two profiles browse the same set — which is what stops the list itself becoming a signature. The addresses stay on our side.",
|
||||
"savedEmpty": "Nothing saved yet. Type a list under My own list, then save it from there.",
|
||||
"savedNote": "The sites are copied onto the schedule when you save it, so editing a list later leaves existing schedules alone.",
|
||||
"savedUnavailable": "Could not load your saved lists.",
|
||||
"saveAsList": "Save as a list",
|
||||
"listNamePlaceholder": "Name this list",
|
||||
"listSaved": "List saved",
|
||||
"listRenamed": "List renamed",
|
||||
"listDeleted": "List deleted",
|
||||
"listRename": "Rename",
|
||||
"listDeleteConfirm": "Delete?",
|
||||
"templateMissing": "That saved list no longer exists. Pick another one.",
|
||||
"templateNameTaken": "You already have a list with that name.",
|
||||
"templateNameInvalid": "Give the list a name of {{max}} characters or fewer.",
|
||||
"calendarLabel": "When it runs",
|
||||
"daysLabel": "Days of the week",
|
||||
"addSlot": "Add a time",
|
||||
"slotsFull": "At most {{max}} start times.",
|
||||
"removeSlot": "Remove this time",
|
||||
"pickListFirst": "Pick a list first",
|
||||
"finishCalendarFirst": "Finish the schedule first",
|
||||
"duplicateSlot": "Two rows have the same days and time",
|
||||
"listSites_one": "{{count}} site",
|
||||
"listSites_other": "{{count}} sites",
|
||||
"summarySlots_one": "Runs {{count}} time a week, up to {{minutes}} min each.",
|
||||
"summarySlots_other": "Runs {{count}} times a week, up to {{minutes}} min each.",
|
||||
"templateNameInvalidNoMax": "That name can't be used for a saved list."
|
||||
},
|
||||
"preset": {
|
||||
"light": "Light",
|
||||
"balanced": "Standard",
|
||||
"deep": "Deep"
|
||||
},
|
||||
"template": {
|
||||
"lowIntentPurchaser": "Low-intent purchaser",
|
||||
"lowIntentPurchaserHint": "Positions the profile as a price-sensitive buyer: comparison, coupon, cashback and resale sites, reaching retailers through aggregators rather than directly."
|
||||
},
|
||||
"preflight": {
|
||||
"ineligible_one": "{{count}} profile can't run remotely",
|
||||
"ineligible_other": "{{count}} profiles can't run remotely",
|
||||
@@ -2419,5 +2483,31 @@
|
||||
"cancelledByUser": "Stopped by hand",
|
||||
"unknown": "Unknown reason ({{code}})"
|
||||
}
|
||||
},
|
||||
"prelaunchGate": {
|
||||
"titleBlocked": "Launch blocked",
|
||||
"titleWarning": "Before you launch",
|
||||
"intro": "Review these issues with \"{{name}}\" before starting the browser.",
|
||||
"fingerprintHeading": "Proxy exit doesn't match the fingerprint",
|
||||
"vpnExtensionHeading": "VPN extension detected",
|
||||
"vpnExtensionIntro": "Extensions in this profile that can reroute the browser's traffic:",
|
||||
"vpnExtensionConfirmed": "Can change the proxy",
|
||||
"vpnExtensionLikely": "May change the proxy",
|
||||
"vpnExtensionExplainer": "If one of these routes your traffic elsewhere, the browser's real location will no longer match the timezone, language and geolocation this profile was created with, and Donut cannot detect that from the outside.",
|
||||
"sourceDonut": "Managed by Donut",
|
||||
"sourceBrowser": "Installed in the profile",
|
||||
"measurementUnreliable": "Because a VPN extension can override the proxy, the exit check may not describe the route the browser actually takes.",
|
||||
"scanIncompleteEncrypted": "This profile is encrypted, so only Donut-managed extensions could be checked.",
|
||||
"scanIncompleteEphemeral": "This profile has no data yet, so only Donut-managed extensions could be checked.",
|
||||
"scanIncompletePartial": "The extension scan was cut short, so some extensions may not be listed.",
|
||||
"probePending": "The proxy exit hasn't been measured yet. Donut will check it while starting and stop if it doesn't match.",
|
||||
"launchAnyway": "Launch anyway",
|
||||
"dontBlockAgain": "Don't block again for this exact mismatch",
|
||||
"dontWarnExtensions": "Don't warn again about these extensions",
|
||||
"applyToRemaining": "Apply this choice to the remaining profiles",
|
||||
"cancelledSummary": "{{cancelled}} of {{total}} launches cancelled",
|
||||
"cancelled": "Launch cancelled",
|
||||
"vpnExtensionEntry": " {{version}} — {{capability}}, {{source}}",
|
||||
"scanIncompleteMissing": "This profile has not been launched yet, so only Donut-managed extensions could be checked."
|
||||
}
|
||||
}
|
||||
|
||||
+104
-12
@@ -92,7 +92,8 @@
|
||||
"window": {
|
||||
"minimize": "Minimizar",
|
||||
"maximize": "Maximizar",
|
||||
"restore": "Restaurar"
|
||||
"restore": "Restaurar",
|
||||
"close": "Cerrar ventana"
|
||||
},
|
||||
"commandPalette": {
|
||||
"title": "Paleta de comandos",
|
||||
@@ -199,11 +200,13 @@
|
||||
"keepDecryptedProfilesInRam": "Mantener Perfiles Descifrados en RAM",
|
||||
"keepDecryptedProfilesInRamDescription": "Conservar la copia descifrada en RAM de los perfiles protegidos por contraseña entre lanzamientos para un inicio más rápido. La copia en disco permanece cifrada en cualquier caso.",
|
||||
"privacy": {
|
||||
"consistencyWarning": "Advertencia de consistencia de huella digital",
|
||||
"consistencyWarningDescription": "Advertir al iniciar cuando la zona horaria o el idioma de un perfil no coincidan con su nodo de salida del proxy.",
|
||||
"consistencyWarning": "Bloquear si la huella digital no coincide",
|
||||
"consistencyWarningDescription": "Impide que el navegador se inicie cuando la zona horaria o el idioma de un perfil no coinciden con su nodo de salida del proxy. Aun así podrás iniciarlo.",
|
||||
"clearTraffic": "Borrar todo el historial de tráfico",
|
||||
"clearTrafficDescription": "Elimina de forma segura las estadísticas de tráfico registradas de todos los perfiles.",
|
||||
"clearTrafficSuccess": "Historial de tráfico borrado"
|
||||
"clearTrafficSuccess": "Historial de tráfico borrado",
|
||||
"vpnExtensionWarning": "Aviso de extensión VPN",
|
||||
"vpnExtensionWarningDescription": "Avisar antes de iniciar cuando un perfil contenga una extensión capaz de redirigir el tráfico del navegador."
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
@@ -345,6 +348,10 @@
|
||||
"nameDesc": "Nombre (Z–A)",
|
||||
"newest": "Más recientes primero",
|
||||
"oldest": "Más antiguos primero"
|
||||
},
|
||||
"remote": {
|
||||
"runningTooltip": "Ejecutándose en una máquina remota. Deténlo para recuperar el perfil aquí.",
|
||||
"pendingSyncTooltip": "Descargando lo que cambió la sesión remota. Disponible de nuevo cuando termine."
|
||||
}
|
||||
},
|
||||
"createProfile": {
|
||||
@@ -462,7 +469,7 @@
|
||||
"ssCipherRequired": "Para Shadowsocks se requieren cifrado y contraseña",
|
||||
"selectType": "Selecciona el tipo de proxy",
|
||||
"saveFailed": "Error al guardar el proxy: {{error}}",
|
||||
"vlessType": "VLESS · Vision · REALITY",
|
||||
"vlessType": "VLESS",
|
||||
"vlessUri": "URI de VLESS",
|
||||
"vlessUriPlaceholder": "vless://…",
|
||||
"vlessUriHint": "Requiere XTLS Vision y REALITY.",
|
||||
@@ -1866,6 +1873,8 @@
|
||||
"remoteRateLimited": "Demasiadas solicitudes. Espera un momento e inténtalo de nuevo.",
|
||||
"remoteNoCapacity": "Ahora mismo no hay ninguna máquina remota libre. Inténtalo de nuevo en unos minutos.",
|
||||
"remoteNotEntitled": "Tu plan no incluye la ejecución remota.",
|
||||
"remoteInteractiveNotEntitled": "Tu plan incluye horas remotas solo para el Cookie Bot, no para sesiones remotas interactivas.",
|
||||
"remoteRequiresRemoteExitNode": "El proxy de este perfil solo funciona en este ordenador (por ejemplo 127.0.0.1 o una dirección de red local). Las sesiones remotas se ejecutan en nuestros servidores, así que necesitan un proxy con dirección pública.",
|
||||
"remoteSessionRefused": "La máquina remota rechazó esta sesión.",
|
||||
"remoteSessionNotFound": "Esa sesión remota ya no existe.",
|
||||
"remoteSessionConflict": "Este perfil ya está abierto en otro sitio.",
|
||||
@@ -1886,8 +1895,33 @@
|
||||
"cookieBotUnknownPlatform": "Este perfil no tiene un sistema operativo registrado, así que no se puede asignar a ninguna máquina.",
|
||||
"cookieBotUnsupportedPlatform": "Cookie Bot no puede ejecutar perfiles de {{platform}}. Solo se admiten perfiles de Windows y macOS.",
|
||||
"cookieBotRequiresExitNode": "Asigna primero un proxy o una VPN. Sin ninguno, la ejecución saldría desde una dirección de centro de datos, lo que daña la identidad del perfil.",
|
||||
"cookieBotRequiresRemoteExitNode": "El proxy de este perfil solo funciona en este ordenador (por ejemplo 127.0.0.1 o una dirección de red local). Cookie Bot se ejecuta en nuestros servidores, así que necesita un proxy con dirección pública.",
|
||||
"unknownCode": "Algo salió mal: {{code}}",
|
||||
"cookieBotTouchFingerprintUnsupported": "Este perfil declara un dispositivo táctil, que el bot no puede controlar. Usa una huella de escritorio."
|
||||
"cookieBotTouchFingerprintUnsupported": "Este perfil declara un dispositivo táctil, que el bot no puede controlar. Usa una huella de escritorio.",
|
||||
"profileRunningRemotely": "Este perfil se está ejecutando en una máquina remota. Detén primero la sesión remota.",
|
||||
"profileRemoteSyncPending": "Una sesión remota acaba de terminar. Esperando a que se descarguen sus cambios antes de abrir este perfil aquí.",
|
||||
"profileLockedByMember": "Este perfil está siendo usado por {{email}}.",
|
||||
"profileLockedElsewhere": "Este perfil está en uso en otro dispositivo.",
|
||||
"profileLockUnavailable": "No se pudo comprobar si este perfil está en uso en otro lugar. Revisa tu conexión e inténtalo de nuevo.",
|
||||
"fingerprintExitMismatch": "El nodo de salida del proxy no coincide con la huella digital de este perfil.",
|
||||
"launchConsentExpired": "Esa confirmación ya no es válida. Vuelve a iniciar.",
|
||||
"vpnWorkerStartFailed": "No se pudo iniciar la conexión VPN: {{detail}}",
|
||||
"exitProbeFailed": "No se pudo contactar con el nodo de salida del proxy para comprobar su ubicación.",
|
||||
"vlessUnsupported": {
|
||||
"security": "Este servidor VLESS no usa REALITY. Donut solo admite VLESS con REALITY.",
|
||||
"flow": "Este servidor VLESS no usa el flujo XTLS Vision, que Donut requiere.",
|
||||
"transport": "Donut solo admite VLESS sobre TCP simple: este servidor usa otro transporte (como WebSocket o gRPC).",
|
||||
"encryption": "Este servidor VLESS usa un cifrado que Donut no admite.",
|
||||
"headerType": "Este servidor VLESS usa una ofuscación de cabecera que Donut no admite.",
|
||||
"fingerprint": "Esta URI VLESS solicita una huella TLS que Donut no admite.",
|
||||
"sni": "A la URI VLESS le falta el SNI (sni) necesario para REALITY.",
|
||||
"publicKey": "A la URI VLESS le falta la clave pública de REALITY (pbk).",
|
||||
"scheme": "Eso no es un enlace VLESS. Debe empezar por vless://.",
|
||||
"parameter": "La URI VLESS contiene una opción que Donut no admite.",
|
||||
"malformed": "La URI VLESS no es válida."
|
||||
},
|
||||
"camoufoxRemoved": "Camoufox ya no es compatible. Vuelve a crear este perfil con Wayfern.",
|
||||
"noE2ePasswordSet": "No hay contraseña de cifrado de extremo a extremo. Establece una antes de sincronizar datos cifrados."
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "Perfiles",
|
||||
@@ -2133,14 +2167,9 @@
|
||||
"description": "Elimina cookies, historial y caché al cerrar el navegador. Las extensiones y los marcadores se conservan."
|
||||
},
|
||||
"consistencyWarning": {
|
||||
"title": "Discrepancia de huella digital",
|
||||
"intro": "La salida del proxy de \"{{name}}\" no coincide con la huella digital de este perfil:",
|
||||
"timezoneTitle": "Discrepancia de zona horaria",
|
||||
"timezoneDetail": "El nodo de salida está en {{exit}}, pero la huella digital indica {{fingerprint}}.",
|
||||
"languageTitle": "Discrepancia de idioma",
|
||||
"languageDetail": "El país de salida es {{country}}, pero el idioma de la huella digital es {{fingerprint}}.",
|
||||
"explainer": "Una zona horaria o un idioma que no coincide con tu IP de salida es una fuerte señal anti-bot, aunque tu dispositivo real nunca se filtre. Alinea la huella digital con la ubicación del proxy para reducir el trato hostil.",
|
||||
"dontWarnAgain": "No volver a advertir para este perfil",
|
||||
"matchToProxy": "Ajustar huella al proxy",
|
||||
"matching": "Ajustando…",
|
||||
"matchSuccess": "Huella actualizada para coincidir con el proxy. Reinicia el perfil para aplicar."
|
||||
@@ -2356,13 +2385,50 @@
|
||||
"confirmBulkButton_many": "Continuar con {{count}} perfiles",
|
||||
"sitesRequired": "Añade al menos un sitio.",
|
||||
"addSitesFirst": "Añade un sitio primero",
|
||||
"presetsMissing": "Ajustes de profundidad no disponibles"
|
||||
"presetsMissing": "Ajustes de profundidad no disponibles",
|
||||
"sourceOwn": "Mi propia lista",
|
||||
"sourceCurated": "Seleccionadas",
|
||||
"sourceSaved": "Guardadas",
|
||||
"curatedEmpty": "Ahora mismo no hay listas seleccionadas disponibles.",
|
||||
"curatedNote": "Una lista seleccionada que mantenemos al día. Cada perfil toma su propia muestra de ella, así que no hay dos perfiles que naveguen el mismo conjunto, y por eso la lista no llega a convertirse en una firma. Las direcciones se quedan de nuestro lado.",
|
||||
"savedEmpty": "Todavía no has guardado nada. Escribe una lista en Mi propia lista y guárdala desde allí.",
|
||||
"savedNote": "Los sitios se copian en la programación al guardarla, así que editar una lista más tarde no afecta a las programaciones existentes.",
|
||||
"savedUnavailable": "No se pudieron cargar tus listas guardadas.",
|
||||
"saveAsList": "Guardar como lista",
|
||||
"listNamePlaceholder": "Nombra esta lista",
|
||||
"listSaved": "Lista guardada",
|
||||
"listRenamed": "Lista renombrada",
|
||||
"listDeleted": "Lista eliminada",
|
||||
"listRename": "Renombrar",
|
||||
"listDeleteConfirm": "¿Eliminar?",
|
||||
"templateMissing": "Esa lista guardada ya no existe. Elige otra.",
|
||||
"templateNameTaken": "Ya tienes una lista con ese nombre.",
|
||||
"templateNameInvalid": "Ponle a la lista un nombre de {{max}} caracteres o menos.",
|
||||
"calendarLabel": "Cuándo se ejecuta",
|
||||
"daysLabel": "Días de la semana",
|
||||
"addSlot": "Añadir una hora",
|
||||
"slotsFull": "Como máximo {{max}} horas de inicio.",
|
||||
"removeSlot": "Quitar esta hora",
|
||||
"pickListFirst": "Elige una lista primero",
|
||||
"finishCalendarFirst": "Termina la programación primero",
|
||||
"duplicateSlot": "Dos filas tienen los mismos días y la misma hora",
|
||||
"listSites_one": "{{count}} sitio",
|
||||
"listSites_other": "{{count}} sitios",
|
||||
"listSites_many": "{{count}} sitios",
|
||||
"summarySlots_one": "Se ejecuta {{count}} vez a la semana, hasta {{minutes}} min cada vez.",
|
||||
"summarySlots_other": "Se ejecuta {{count}} veces a la semana, hasta {{minutes}} min cada vez.",
|
||||
"summarySlots_many": "Se ejecuta {{count}} veces a la semana, hasta {{minutes}} min cada vez.",
|
||||
"templateNameInvalidNoMax": "Ese nombre no se puede usar para una lista guardada."
|
||||
},
|
||||
"preset": {
|
||||
"light": "Ligera",
|
||||
"balanced": "Estándar",
|
||||
"deep": "Profunda"
|
||||
},
|
||||
"template": {
|
||||
"lowIntentPurchaser": "Comprador de baja intención",
|
||||
"lowIntentPurchaserHint": "Posiciona el perfil como un comprador sensible al precio: sitios de comparación, cupones, reembolsos y reventa, llegando a las tiendas a través de agregadores en lugar de directamente."
|
||||
},
|
||||
"preflight": {
|
||||
"ineligible_one": "{{count}} perfil no puede ejecutarse en remoto",
|
||||
"ineligible_other": "{{count}} perfiles no pueden ejecutarse en remoto",
|
||||
@@ -2446,5 +2512,31 @@
|
||||
"cancelledByUser": "Detenido a mano",
|
||||
"unknown": "Motivo desconocido ({{code}})"
|
||||
}
|
||||
},
|
||||
"prelaunchGate": {
|
||||
"titleBlocked": "Inicio bloqueado",
|
||||
"titleWarning": "Antes de iniciar",
|
||||
"intro": "Revisa estos problemas de \"{{name}}\" antes de iniciar el navegador.",
|
||||
"fingerprintHeading": "La salida del proxy no coincide con la huella digital",
|
||||
"vpnExtensionHeading": "Extensión VPN detectada",
|
||||
"vpnExtensionIntro": "Extensiones de este perfil que pueden redirigir el tráfico del navegador:",
|
||||
"vpnExtensionConfirmed": "Puede cambiar el proxy",
|
||||
"vpnExtensionLikely": "Podría cambiar el proxy",
|
||||
"vpnExtensionExplainer": "Si alguna de ellas redirige tu tráfico a otro lugar, la ubicación real del navegador dejará de coincidir con la zona horaria, el idioma y la geolocalización con los que se creó este perfil, y Donut no puede detectarlo desde fuera.",
|
||||
"sourceDonut": "Gestionada por Donut",
|
||||
"sourceBrowser": "Instalada en el perfil",
|
||||
"measurementUnreliable": "Como una extensión VPN puede anular el proxy, la comprobación de salida podría no reflejar la ruta que el navegador usa realmente.",
|
||||
"scanIncompleteEncrypted": "Este perfil está cifrado, así que solo se pudieron comprobar las extensiones gestionadas por Donut.",
|
||||
"scanIncompleteEphemeral": "Este perfil aún no tiene datos, así que solo se pudieron comprobar las extensiones gestionadas por Donut.",
|
||||
"scanIncompletePartial": "El análisis de extensiones se interrumpió, así que puede que falten algunas.",
|
||||
"probePending": "Todavía no se ha medido la salida del proxy. Donut la comprobará al iniciar y se detendrá si no coincide.",
|
||||
"launchAnyway": "Iniciar de todos modos",
|
||||
"dontBlockAgain": "No bloquear de nuevo por esta discrepancia exacta",
|
||||
"dontWarnExtensions": "No volver a avisar sobre estas extensiones",
|
||||
"applyToRemaining": "Aplicar esta decisión a los perfiles restantes",
|
||||
"cancelledSummary": "{{cancelled}} de {{total}} inicios cancelados",
|
||||
"cancelled": "Inicio cancelado",
|
||||
"vpnExtensionEntry": " {{version}} — {{capability}}, {{source}}",
|
||||
"scanIncompleteMissing": "Este perfil aún no se ha iniciado, así que solo se pudieron comprobar las extensiones gestionadas por Donut."
|
||||
}
|
||||
}
|
||||
|
||||
+104
-12
@@ -92,7 +92,8 @@
|
||||
"window": {
|
||||
"minimize": "Réduire",
|
||||
"maximize": "Agrandir",
|
||||
"restore": "Restaurer"
|
||||
"restore": "Restaurer",
|
||||
"close": "Fermer la fenêtre"
|
||||
},
|
||||
"commandPalette": {
|
||||
"title": "Palette de commandes",
|
||||
@@ -199,11 +200,13 @@
|
||||
"keepDecryptedProfilesInRam": "Conserver les profils déchiffrés en RAM",
|
||||
"keepDecryptedProfilesInRamDescription": "Conserver en RAM la copie déchiffrée des profils protégés par mot de passe entre les lancements pour un démarrage plus rapide. La copie sur disque reste chiffrée dans tous les cas.",
|
||||
"privacy": {
|
||||
"consistencyWarning": "Avertissement de cohérence d'empreinte",
|
||||
"consistencyWarningDescription": "Avertir au lancement lorsque le fuseau horaire ou la langue d'un profil ne correspond pas à son nœud de sortie proxy.",
|
||||
"consistencyWarning": "Bloquer en cas d'empreinte incohérente",
|
||||
"consistencyWarningDescription": "Empêche le navigateur de démarrer lorsque le fuseau horaire ou la langue d'un profil ne correspond pas à son nœud de sortie. Vous pourrez tout de même lancer.",
|
||||
"clearTraffic": "Effacer tout l'historique de trafic",
|
||||
"clearTrafficDescription": "Efface en toute sécurité les statistiques de trafic enregistrées pour chaque profil.",
|
||||
"clearTrafficSuccess": "Historique de trafic effacé"
|
||||
"clearTrafficSuccess": "Historique de trafic effacé",
|
||||
"vpnExtensionWarning": "Avertissement d'extension VPN",
|
||||
"vpnExtensionWarningDescription": "Avertir avant le lancement lorsqu'un profil contient une extension capable de rerouter le trafic du navigateur."
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
@@ -345,6 +348,10 @@
|
||||
"nameDesc": "Nom (Z–A)",
|
||||
"newest": "Plus récents d’abord",
|
||||
"oldest": "Plus anciens d’abord"
|
||||
},
|
||||
"remote": {
|
||||
"runningTooltip": "En cours d'exécution sur une machine distante. Arrêtez-la pour récupérer le profil ici.",
|
||||
"pendingSyncTooltip": "Téléchargement des modifications de la session distante. De nouveau disponible une fois terminé."
|
||||
}
|
||||
},
|
||||
"createProfile": {
|
||||
@@ -462,7 +469,7 @@
|
||||
"ssCipherRequired": "Le chiffrement et le mot de passe sont requis pour Shadowsocks",
|
||||
"selectType": "Sélectionnez le type de proxy",
|
||||
"saveFailed": "Échec de la sauvegarde du proxy : {{error}}",
|
||||
"vlessType": "VLESS · Vision · REALITY",
|
||||
"vlessType": "VLESS",
|
||||
"vlessUri": "URI VLESS",
|
||||
"vlessUriPlaceholder": "vless://…",
|
||||
"vlessUriHint": "Nécessite XTLS Vision et REALITY.",
|
||||
@@ -1866,6 +1873,8 @@
|
||||
"remoteRateLimited": "Trop de requêtes. Patientez un instant et réessayez.",
|
||||
"remoteNoCapacity": "Aucune machine distante n'est libre pour le moment. Réessayez dans quelques minutes.",
|
||||
"remoteNotEntitled": "Votre forfait n'inclut pas l'exécution à distance.",
|
||||
"remoteInteractiveNotEntitled": "Votre forfait inclut des heures distantes uniquement pour le Cookie Bot, pas pour les sessions distantes interactives.",
|
||||
"remoteRequiresRemoteExitNode": "Le proxy de ce profil ne fonctionne que sur cet ordinateur (par exemple 127.0.0.1 ou une adresse de réseau local). Les sessions distantes s'exécutent sur nos hôtes et ont donc besoin d'un proxy avec une adresse publique.",
|
||||
"remoteSessionRefused": "La machine distante a refusé cette session.",
|
||||
"remoteSessionNotFound": "Cette session distante n'existe plus.",
|
||||
"remoteSessionConflict": "Ce profil est déjà ouvert ailleurs.",
|
||||
@@ -1886,8 +1895,33 @@
|
||||
"cookieBotUnknownPlatform": "Ce profil n'a aucun système d'exploitation enregistré, il ne peut donc pas être associé à une machine.",
|
||||
"cookieBotUnsupportedPlatform": "Cookie Bot ne peut pas exécuter de profils {{platform}}. Seuls les profils Windows et macOS sont pris en charge.",
|
||||
"cookieBotRequiresExitNode": "Associez d'abord un proxy ou un VPN. Sans cela, l'exécution proviendrait d'une adresse de centre de données, ce qui abîme l'identité du profil.",
|
||||
"cookieBotRequiresRemoteExitNode": "Le proxy de ce profil ne fonctionne que sur cet ordinateur (par exemple 127.0.0.1 ou une adresse de réseau local). Cookie Bot s'exécute sur nos hôtes et a donc besoin d'un proxy avec une adresse publique.",
|
||||
"unknownCode": "Une erreur est survenue : {{code}}",
|
||||
"cookieBotTouchFingerprintUnsupported": "Ce profil déclare un appareil tactile, que le bot ne peut pas piloter. Utilisez une empreinte de bureau."
|
||||
"cookieBotTouchFingerprintUnsupported": "Ce profil déclare un appareil tactile, que le bot ne peut pas piloter. Utilisez une empreinte de bureau.",
|
||||
"profileRunningRemotely": "Ce profil s'exécute sur une machine distante. Arrêtez d'abord la session distante.",
|
||||
"profileRemoteSyncPending": "Une session distante vient de se terminer. Ses modifications doivent être téléchargées avant d'ouvrir ce profil ici.",
|
||||
"profileLockedByMember": "Ce profil est utilisé par {{email}}.",
|
||||
"profileLockedElsewhere": "Ce profil est utilisé sur un autre appareil.",
|
||||
"profileLockUnavailable": "Impossible de vérifier si ce profil est utilisé ailleurs. Vérifiez votre connexion et réessayez.",
|
||||
"fingerprintExitMismatch": "Le nœud de sortie du proxy ne correspond pas à l'empreinte de ce profil.",
|
||||
"launchConsentExpired": "Cette confirmation n'est plus valide. Relancez le profil.",
|
||||
"vpnWorkerStartFailed": "Impossible de démarrer la connexion VPN : {{detail}}",
|
||||
"exitProbeFailed": "Impossible de joindre le nœud de sortie du proxy pour vérifier sa localisation.",
|
||||
"vlessUnsupported": {
|
||||
"security": "Ce serveur VLESS n'utilise pas REALITY. Donut ne prend en charge que VLESS avec REALITY.",
|
||||
"flow": "Ce serveur VLESS n'utilise pas le flux XTLS Vision, requis par Donut.",
|
||||
"transport": "Donut ne prend en charge que VLESS sur TCP simple — ce serveur utilise un autre transport (WebSocket ou gRPC, par exemple).",
|
||||
"encryption": "Ce serveur VLESS utilise un chiffrement non pris en charge par Donut.",
|
||||
"headerType": "Ce serveur VLESS utilise une obfuscation d'en-tête non prise en charge par Donut.",
|
||||
"fingerprint": "Cette URI VLESS demande une empreinte TLS non prise en charge par Donut.",
|
||||
"sni": "Il manque le SNI (sni) nécessaire à REALITY dans l'URI VLESS.",
|
||||
"publicKey": "Il manque la clé publique REALITY (pbk) dans l'URI VLESS.",
|
||||
"scheme": "Ce n'est pas un lien VLESS. Il doit commencer par vless://.",
|
||||
"parameter": "L'URI VLESS contient une option non prise en charge par Donut.",
|
||||
"malformed": "L'URI VLESS n'est pas valide."
|
||||
},
|
||||
"camoufoxRemoved": "Camoufox n'est plus pris en charge. Recréez ce profil avec Wayfern.",
|
||||
"noE2ePasswordSet": "Aucun mot de passe de chiffrement de bout en bout n'est défini. Définissez-en un avant de synchroniser des données chiffrées."
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "Profils",
|
||||
@@ -2133,14 +2167,9 @@
|
||||
"description": "Supprime les cookies, l'historique et le cache à la fermeture du navigateur. Les extensions et les favoris sont conservés."
|
||||
},
|
||||
"consistencyWarning": {
|
||||
"title": "Incohérence d'empreinte",
|
||||
"intro": "La sortie du proxy de « {{name}} » ne correspond pas à l'empreinte de ce profil :",
|
||||
"timezoneTitle": "Incohérence de fuseau horaire",
|
||||
"timezoneDetail": "Le nœud de sortie est dans {{exit}}, mais l'empreinte indique {{fingerprint}}.",
|
||||
"languageTitle": "Incohérence de langue",
|
||||
"languageDetail": "Le pays de sortie est {{country}}, mais la langue de l'empreinte est {{fingerprint}}.",
|
||||
"explainer": "Un fuseau horaire ou une langue en désaccord avec votre IP de sortie est un signal anti-bot fort, même si votre appareil réel ne fuite jamais. Alignez l'empreinte sur l'emplacement du proxy pour réduire les traitements hostiles.",
|
||||
"dontWarnAgain": "Ne plus avertir pour ce profil",
|
||||
"matchToProxy": "Aligner l'empreinte sur le proxy",
|
||||
"matching": "Alignement…",
|
||||
"matchSuccess": "Empreinte mise à jour pour correspondre au proxy. Relancez le profil pour l'appliquer."
|
||||
@@ -2356,13 +2385,50 @@
|
||||
"confirmBulkButton_many": "Continuer avec {{count}} profils",
|
||||
"sitesRequired": "Ajoutez au moins un site.",
|
||||
"addSitesFirst": "Ajoutez d'abord un site",
|
||||
"presetsMissing": "Préréglages de profondeur indisponibles"
|
||||
"presetsMissing": "Préréglages de profondeur indisponibles",
|
||||
"sourceOwn": "Ma propre liste",
|
||||
"sourceCurated": "Sélection",
|
||||
"sourceSaved": "Enregistrées",
|
||||
"curatedEmpty": "Aucune liste de la sélection n'est disponible pour le moment.",
|
||||
"curatedNote": "Une liste que nous tenons à jour. Chaque profil en tire son propre échantillon : deux profils ne parcourent donc jamais le même ensemble, ce qui empêche la liste elle-même de devenir une signature. Les adresses restent de notre côté.",
|
||||
"savedEmpty": "Rien d'enregistré pour l'instant. Saisissez une liste dans Ma propre liste, puis enregistrez-la depuis là.",
|
||||
"savedNote": "Les sites sont copiés dans la planification au moment de l'enregistrement : modifier une liste plus tard ne touche pas aux planifications existantes.",
|
||||
"savedUnavailable": "Impossible de charger vos listes enregistrées.",
|
||||
"saveAsList": "Enregistrer comme liste",
|
||||
"listNamePlaceholder": "Nommez cette liste",
|
||||
"listSaved": "Liste enregistrée",
|
||||
"listRenamed": "Liste renommée",
|
||||
"listDeleted": "Liste supprimée",
|
||||
"listRename": "Renommer",
|
||||
"listDeleteConfirm": "Supprimer ?",
|
||||
"templateMissing": "Cette liste enregistrée n'existe plus. Choisissez-en une autre.",
|
||||
"templateNameTaken": "Vous avez déjà une liste portant ce nom.",
|
||||
"templateNameInvalid": "Donnez à la liste un nom de {{max}} caractères maximum.",
|
||||
"calendarLabel": "Quand il s'exécute",
|
||||
"daysLabel": "Jours de la semaine",
|
||||
"addSlot": "Ajouter une heure",
|
||||
"slotsFull": "{{max}} heures de départ au maximum.",
|
||||
"removeSlot": "Retirer cette heure",
|
||||
"pickListFirst": "Choisissez d'abord une liste",
|
||||
"finishCalendarFirst": "Terminez d'abord la planification",
|
||||
"duplicateSlot": "Deux lignes ont les mêmes jours et la même heure",
|
||||
"listSites_one": "{{count}} site",
|
||||
"listSites_other": "{{count}} sites",
|
||||
"listSites_many": "{{count}} sites",
|
||||
"summarySlots_one": "S'exécute {{count}} fois par semaine, jusqu'à {{minutes}} min à chaque fois.",
|
||||
"summarySlots_other": "S'exécute {{count}} fois par semaine, jusqu'à {{minutes}} min à chaque fois.",
|
||||
"summarySlots_many": "S'exécute {{count}} fois par semaine, jusqu'à {{minutes}} min à chaque fois.",
|
||||
"templateNameInvalidNoMax": "Ce nom ne peut pas être utilisé pour une liste enregistrée."
|
||||
},
|
||||
"preset": {
|
||||
"light": "Légère",
|
||||
"balanced": "Standard",
|
||||
"deep": "Approfondie"
|
||||
},
|
||||
"template": {
|
||||
"lowIntentPurchaser": "Acheteur à faible intention",
|
||||
"lowIntentPurchaserHint": "Positionne le profil comme un acheteur sensible au prix : comparateurs, coupons, cashback et revente, en arrivant chez les marchands via des agrégateurs plutôt qu'en direct."
|
||||
},
|
||||
"preflight": {
|
||||
"ineligible_one": "{{count}} profil ne peut pas s'exécuter à distance",
|
||||
"ineligible_other": "{{count}} profils ne peuvent pas s'exécuter à distance",
|
||||
@@ -2446,5 +2512,31 @@
|
||||
"cancelledByUser": "Arrêté à la main",
|
||||
"unknown": "Raison inconnue ({{code}})"
|
||||
}
|
||||
},
|
||||
"prelaunchGate": {
|
||||
"titleBlocked": "Lancement bloqué",
|
||||
"titleWarning": "Avant de lancer",
|
||||
"intro": "Examinez ces problèmes concernant « {{name}} » avant de démarrer le navigateur.",
|
||||
"fingerprintHeading": "La sortie du proxy ne correspond pas à l'empreinte",
|
||||
"vpnExtensionHeading": "Extension VPN détectée",
|
||||
"vpnExtensionIntro": "Extensions de ce profil pouvant rerouter le trafic du navigateur :",
|
||||
"vpnExtensionConfirmed": "Peut changer le proxy",
|
||||
"vpnExtensionLikely": "Pourrait changer le proxy",
|
||||
"vpnExtensionExplainer": "Si l'une d'elles redirige votre trafic ailleurs, la position réelle du navigateur ne correspondra plus au fuseau horaire, à la langue et à la géolocalisation avec lesquels ce profil a été créé, et Donut ne peut pas le détecter de l'extérieur.",
|
||||
"sourceDonut": "Gérée par Donut",
|
||||
"sourceBrowser": "Installée dans le profil",
|
||||
"measurementUnreliable": "Comme une extension VPN peut remplacer le proxy, la vérification de la sortie peut ne pas refléter la route réellement empruntée par le navigateur.",
|
||||
"scanIncompleteEncrypted": "Ce profil est chiffré : seules les extensions gérées par Donut ont pu être vérifiées.",
|
||||
"scanIncompleteEphemeral": "Ce profil n'a pas encore de données : seules les extensions gérées par Donut ont pu être vérifiées.",
|
||||
"scanIncompletePartial": "L'analyse des extensions a été interrompue, certaines peuvent manquer.",
|
||||
"probePending": "La sortie du proxy n'a pas encore été mesurée. Donut la vérifiera au démarrage et s'arrêtera si elle ne correspond pas.",
|
||||
"launchAnyway": "Lancer quand même",
|
||||
"dontBlockAgain": "Ne plus bloquer pour cette incohérence exacte",
|
||||
"dontWarnExtensions": "Ne plus m'avertir à propos de ces extensions",
|
||||
"applyToRemaining": "Appliquer ce choix aux profils restants",
|
||||
"cancelledSummary": "{{cancelled}} lancements sur {{total}} annulés",
|
||||
"cancelled": "Lancement annulé",
|
||||
"vpnExtensionEntry": " {{version}} — {{capability}}, {{source}}",
|
||||
"scanIncompleteMissing": "Ce profil n'a jamais été lancé : seules les extensions gérées par Donut ont pu être vérifiées."
|
||||
}
|
||||
}
|
||||
|
||||
+102
-12
@@ -92,7 +92,8 @@
|
||||
"window": {
|
||||
"minimize": "最小化",
|
||||
"maximize": "最大化",
|
||||
"restore": "元に戻す"
|
||||
"restore": "元に戻す",
|
||||
"close": "ウィンドウを閉じる"
|
||||
},
|
||||
"commandPalette": {
|
||||
"title": "コマンドパレット",
|
||||
@@ -199,11 +200,13 @@
|
||||
"keepDecryptedProfilesInRam": "復号済みプロファイルをRAMに保持",
|
||||
"keepDecryptedProfilesInRamDescription": "起動を高速化するため、パスワード保護されたプロファイルの復号済みコピーをRAMに保持します。ディスク上のコピーは常に暗号化されたままです。",
|
||||
"privacy": {
|
||||
"consistencyWarning": "フィンガープリント整合性の警告",
|
||||
"consistencyWarningDescription": "プロファイルのタイムゾーンや言語がプロキシ出口ノードと一致しない場合、起動時に警告します。",
|
||||
"consistencyWarning": "フィンガープリント不一致時に起動をブロック",
|
||||
"consistencyWarningDescription": "プロファイルのタイムゾーンや言語がプロキシの出口ノードと一致しない場合、ブラウザーの起動を停止します。それでも起動を選択できます。",
|
||||
"clearTraffic": "すべてのトラフィック履歴を消去",
|
||||
"clearTrafficDescription": "すべてのプロファイルの記録されたトラフィック統計を安全に消去します。",
|
||||
"clearTrafficSuccess": "トラフィック履歴を消去しました"
|
||||
"clearTrafficSuccess": "トラフィック履歴を消去しました",
|
||||
"vpnExtensionWarning": "VPN拡張機能の警告",
|
||||
"vpnExtensionWarningDescription": "ブラウザーの通信を経路変更できる拡張機能がプロファイルに含まれる場合、起動前に警告します。"
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
@@ -345,6 +348,10 @@
|
||||
"nameDesc": "名前 (Z→A)",
|
||||
"newest": "新しい順",
|
||||
"oldest": "古い順"
|
||||
},
|
||||
"remote": {
|
||||
"runningTooltip": "リモートマシンで実行中です。停止するとプロファイルがここに戻ります。",
|
||||
"pendingSyncTooltip": "リモートセッションの変更をダウンロード中です。完了すると再び使用できます。"
|
||||
}
|
||||
},
|
||||
"createProfile": {
|
||||
@@ -461,7 +468,7 @@
|
||||
"ssCipherRequired": "Shadowsocks には暗号とパスワードが必要です",
|
||||
"selectType": "プロキシの種類を選択",
|
||||
"saveFailed": "プロキシの保存に失敗しました: {{error}}",
|
||||
"vlessType": "VLESS · Vision · REALITY",
|
||||
"vlessType": "VLESS",
|
||||
"vlessUri": "VLESS URI",
|
||||
"vlessUriPlaceholder": "vless://…",
|
||||
"vlessUriHint": "XTLS Vision と REALITY が必要です。",
|
||||
@@ -1859,6 +1866,8 @@
|
||||
"remoteRateLimited": "リクエストが多すぎます。少し待ってからもう一度お試しください。",
|
||||
"remoteNoCapacity": "現在空いているリモートマシンがありません。数分後にもう一度お試しください。",
|
||||
"remoteNotEntitled": "ご利用のプランにはリモート実行が含まれていません。",
|
||||
"remoteInteractiveNotEntitled": "ご利用のプランのリモート時間は Cookie Bot 専用で、手動のリモートセッションには使えません。",
|
||||
"remoteRequiresRemoteExitNode": "このプロファイルのプロキシはこのコンピューター上でのみ有効です(127.0.0.1 やローカルネットワークのアドレスなど)。リモートセッションは当社のホスト上で実行されるため、公開アドレスを持つプロキシが必要です。",
|
||||
"remoteSessionRefused": "リモートマシンがこのセッションを拒否しました。",
|
||||
"remoteSessionNotFound": "そのリモートセッションはすでに存在しません。",
|
||||
"remoteSessionConflict": "このプロファイルはすでに別の場所で開かれています。",
|
||||
@@ -1879,8 +1888,33 @@
|
||||
"cookieBotUnknownPlatform": "このプロファイルには OS が記録されていないため、マシンを割り当てられません。",
|
||||
"cookieBotUnsupportedPlatform": "Cookie Bot は {{platform}} のプロファイルを実行できません。対応しているのは Windows と macOS のプロファイルのみです。",
|
||||
"cookieBotRequiresExitNode": "先にプロキシまたは VPN を設定してください。設定しないと通信がデータセンターのアドレスから出て、プロファイルの信頼性を損ないます。",
|
||||
"cookieBotRequiresRemoteExitNode": "このプロファイルのプロキシはこのコンピューター上でのみ有効です(127.0.0.1 やローカルネットワークのアドレスなど)。Cookie Bot は当社のホスト上で実行されるため、公開アドレスを持つプロキシが必要です。",
|
||||
"unknownCode": "エラーが発生しました: {{code}}",
|
||||
"cookieBotTouchFingerprintUnsupported": "このプロファイルはタッチ端末を名乗っており、ボットは操作できません。デスクトップのフィンガープリントをお使いください。"
|
||||
"cookieBotTouchFingerprintUnsupported": "このプロファイルはタッチ端末を名乗っており、ボットは操作できません。デスクトップのフィンガープリントをお使いください。",
|
||||
"profileRunningRemotely": "このプロファイルはリモートマシンで実行中です。先にリモートセッションを停止してください。",
|
||||
"profileRemoteSyncPending": "リモートセッションが終了しました。この profile をここで開く前に、変更のダウンロードを待っています。",
|
||||
"profileLockedByMember": "このプロファイルは {{email}} が使用中です。",
|
||||
"profileLockedElsewhere": "このプロファイルは別のデバイスで使用中です。",
|
||||
"profileLockUnavailable": "このプロファイルが他で使用中か確認できませんでした。接続を確認して再試行してください。",
|
||||
"fingerprintExitMismatch": "プロキシの出口ノードがこのプロファイルのフィンガープリントと一致しません。",
|
||||
"launchConsentExpired": "この確認は無効になりました。もう一度起動してください。",
|
||||
"vpnWorkerStartFailed": "VPN接続を開始できませんでした: {{detail}}",
|
||||
"exitProbeFailed": "プロキシの出口ノードに接続できず、所在地を確認できませんでした。",
|
||||
"vlessUnsupported": {
|
||||
"security": "このVLESSサーバーはREALITYを使用していません。DonutはREALITY付きのVLESSのみに対応しています。",
|
||||
"flow": "このVLESSサーバーは、Donutが必要とするXTLS Visionフローを使用していません。",
|
||||
"transport": "Donutは素のTCP上のVLESSのみに対応しています。このサーバーは別のトランスポート(WebSocketやgRPCなど)を使用しています。",
|
||||
"encryption": "このVLESSサーバーは、Donutが対応していない暗号化設定を使用しています。",
|
||||
"headerType": "このVLESSサーバーは、Donutが対応していないヘッダー難読化を使用しています。",
|
||||
"fingerprint": "このVLESS URIは、Donutが対応していないTLSフィンガープリントを要求しています。",
|
||||
"sni": "VLESS URIに、REALITYに必要なSNI(sni)がありません。",
|
||||
"publicKey": "VLESS URIに、REALITYの公開鍵(pbk)がありません。",
|
||||
"scheme": "これはVLESSリンクではありません。vless:// で始まる必要があります。",
|
||||
"parameter": "VLESS URIに、Donutが対応していないオプションが含まれています。",
|
||||
"malformed": "VLESS URIが無効です。"
|
||||
},
|
||||
"camoufoxRemoved": "Camoufoxはサポートされなくなりました。Wayfernでこのプロファイルを作り直してください。",
|
||||
"noE2ePasswordSet": "エンドツーエンド暗号化のパスワードが設定されていません。暗号化データを同期する前に設定してください。"
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "プロファイル",
|
||||
@@ -2126,14 +2160,9 @@
|
||||
"description": "ブラウザを閉じるときに Cookie、履歴、キャッシュを消去します。拡張機能とブックマークは保持されます。"
|
||||
},
|
||||
"consistencyWarning": {
|
||||
"title": "フィンガープリントの不一致",
|
||||
"intro": "「{{name}}」のプロキシ出口がこのプロファイルのフィンガープリントと一致していません:",
|
||||
"timezoneTitle": "タイムゾーンの不一致",
|
||||
"timezoneDetail": "出口ノードは {{exit}} にありますが、フィンガープリントは {{fingerprint}} を示しています。",
|
||||
"languageTitle": "言語の不一致",
|
||||
"languageDetail": "出口の国は {{country}} ですが、フィンガープリントの言語は {{fingerprint}} です。",
|
||||
"explainer": "出口 IP と食い違うタイムゾーンや言語は、実際のデバイス情報が漏れていなくても強力なアンチボットシグナルになります。フィンガープリントをプロキシの場所に合わせて、警戒される扱いを減らしましょう。",
|
||||
"dontWarnAgain": "このプロファイルでは今後警告しない",
|
||||
"matchToProxy": "フィンガープリントをプロキシに合わせる",
|
||||
"matching": "調整中…",
|
||||
"matchSuccess": "フィンガープリントをプロキシに合わせて更新しました。反映するにはプロファイルを再起動してください。"
|
||||
@@ -2331,13 +2360,48 @@
|
||||
"confirmBulkButton_other": "{{count}} 件のプロファイルで続行",
|
||||
"sitesRequired": "サイトを 1 件以上追加してください。",
|
||||
"addSitesFirst": "先にサイトを追加してください",
|
||||
"presetsMissing": "深さのプリセットを利用できません"
|
||||
"presetsMissing": "深さのプリセットを利用できません",
|
||||
"sourceOwn": "自分のリスト",
|
||||
"sourceCurated": "厳選リスト",
|
||||
"sourceSaved": "保存済み",
|
||||
"curatedEmpty": "現在利用できる厳選リストはありません。",
|
||||
"curatedNote": "こちらで最新に保っている厳選リストです。プロファイルごとに異なるサンプルを抽出するため、同じ組み合わせを閲覧するプロファイルは 2 つとありません。これがリスト自体を特徴にさせない仕組みです。アドレスはサーバー側に留まります。",
|
||||
"savedEmpty": "まだ保存されていません。「自分のリスト」でリストを入力し、そこから保存してください。",
|
||||
"savedNote": "サイトは保存時にスケジュールへコピーされます。後からリストを編集しても既存のスケジュールは変わりません。",
|
||||
"savedUnavailable": "保存済みのリストを読み込めませんでした。",
|
||||
"saveAsList": "リストとして保存",
|
||||
"listNamePlaceholder": "リスト名を入力",
|
||||
"listSaved": "リストを保存しました",
|
||||
"listRenamed": "リスト名を変更しました",
|
||||
"listDeleted": "リストを削除しました",
|
||||
"listRename": "名前を変更",
|
||||
"listDeleteConfirm": "削除しますか?",
|
||||
"templateMissing": "その保存済みリストは存在しません。別のものを選んでください。",
|
||||
"templateNameTaken": "同じ名前のリストがすでにあります。",
|
||||
"templateNameInvalid": "リスト名は {{max}} 文字以内にしてください。",
|
||||
"calendarLabel": "実行するタイミング",
|
||||
"daysLabel": "曜日",
|
||||
"addSlot": "時刻を追加",
|
||||
"slotsFull": "開始時刻は最大 {{max}} 件です。",
|
||||
"removeSlot": "この時刻を削除",
|
||||
"pickListFirst": "先にリストを選択",
|
||||
"finishCalendarFirst": "先にスケジュールを完成させてください",
|
||||
"duplicateSlot": "2 つの行の曜日と時刻が同じです",
|
||||
"listSites_one": "{{count}} サイト",
|
||||
"listSites_other": "{{count}} サイト",
|
||||
"summarySlots_one": "週 {{count}} 回、1 回あたり最大 {{minutes}} 分実行します。",
|
||||
"summarySlots_other": "週 {{count}} 回、1 回あたり最大 {{minutes}} 分実行します。",
|
||||
"templateNameInvalidNoMax": "その名前は保存済みリストには使用できません。"
|
||||
},
|
||||
"preset": {
|
||||
"light": "軽め",
|
||||
"balanced": "標準",
|
||||
"deep": "深め"
|
||||
},
|
||||
"template": {
|
||||
"lowIntentPurchaser": "低購買意欲の買い物客",
|
||||
"lowIntentPurchaserHint": "価格に敏感な買い手としてプロファイルを位置づけます。比較・クーポン・キャッシュバック・リセールのサイトを巡り、直接ではなく集約サイト経由で小売サイトに到達します。"
|
||||
},
|
||||
"preflight": {
|
||||
"ineligible_one": "{{count}} 件のプロファイルはリモートで実行できません",
|
||||
"ineligible_other": "{{count}} 件のプロファイルはリモートで実行できません",
|
||||
@@ -2419,5 +2483,31 @@
|
||||
"cancelledByUser": "手動で停止しました",
|
||||
"unknown": "不明な理由 ({{code}})"
|
||||
}
|
||||
},
|
||||
"prelaunchGate": {
|
||||
"titleBlocked": "起動をブロックしました",
|
||||
"titleWarning": "起動する前に",
|
||||
"intro": "ブラウザーを起動する前に、「{{name}}」に関する次の問題を確認してください。",
|
||||
"fingerprintHeading": "プロキシの出口がフィンガープリントと一致しません",
|
||||
"vpnExtensionHeading": "VPN拡張機能を検出しました",
|
||||
"vpnExtensionIntro": "このプロファイル内で、ブラウザーの通信を経路変更できる拡張機能:",
|
||||
"vpnExtensionConfirmed": "プロキシを変更できます",
|
||||
"vpnExtensionLikely": "プロキシを変更する可能性があります",
|
||||
"vpnExtensionExplainer": "いずれかが通信を別の経路に変えると、ブラウザーの実際の所在地は、このプロファイルの作成時に設定されたタイムゾーン・言語・位置情報と一致しなくなります。Donutは外部からそれを検出できません。",
|
||||
"sourceDonut": "Donutが管理",
|
||||
"sourceBrowser": "プロファイルにインストール済み",
|
||||
"measurementUnreliable": "VPN拡張機能はプロキシを上書きできるため、出口の確認結果がブラウザーの実際の経路を表していない可能性があります。",
|
||||
"scanIncompleteEncrypted": "このプロファイルは暗号化されているため、Donutが管理する拡張機能のみ確認できました。",
|
||||
"scanIncompleteEphemeral": "このプロファイルにはまだデータがないため、Donutが管理する拡張機能のみ確認できました。",
|
||||
"scanIncompletePartial": "拡張機能のスキャンが途中で終了したため、一部が表示されていない可能性があります。",
|
||||
"probePending": "プロキシの出口はまだ測定されていません。Donutは起動中に確認し、一致しない場合は停止します。",
|
||||
"launchAnyway": "このまま起動",
|
||||
"dontBlockAgain": "この不一致では今後ブロックしない",
|
||||
"dontWarnExtensions": "これらの拡張機能について今後警告しない",
|
||||
"applyToRemaining": "この選択を残りのプロファイルにも適用",
|
||||
"cancelledSummary": "{{total}}件中{{cancelled}}件の起動をキャンセルしました",
|
||||
"cancelled": "起動をキャンセルしました",
|
||||
"vpnExtensionEntry": " {{version}}({{capability}}、{{source}})",
|
||||
"scanIncompleteMissing": "このプロファイルはまだ起動されていないため、Donutが管理する拡張機能のみ確認できました。"
|
||||
}
|
||||
}
|
||||
|
||||
+102
-12
@@ -92,7 +92,8 @@
|
||||
"window": {
|
||||
"minimize": "최소화",
|
||||
"maximize": "최대화",
|
||||
"restore": "이전 크기로 복원"
|
||||
"restore": "이전 크기로 복원",
|
||||
"close": "창 닫기"
|
||||
},
|
||||
"commandPalette": {
|
||||
"title": "명령 팔레트",
|
||||
@@ -199,11 +200,13 @@
|
||||
"keepDecryptedProfilesInRam": "복호화된 프로필을 RAM에 유지",
|
||||
"keepDecryptedProfilesInRamDescription": "비밀번호로 보호된 프로필의 복호화된 RAM 사본을 실행 사이에 유지하여 시작 속도를 높입니다. 디스크의 사본은 그대로 암호화된 상태로 유지됩니다.",
|
||||
"privacy": {
|
||||
"consistencyWarning": "핑거프린트 일관성 경고",
|
||||
"consistencyWarningDescription": "프로필의 시간대나 언어가 프록시 출구 노드와 일치하지 않으면 실행 시 경고합니다.",
|
||||
"consistencyWarning": "핑거프린트 불일치 시 차단",
|
||||
"consistencyWarningDescription": "프로필의 시간대나 언어가 프록시 출구 노드와 일치하지 않으면 브라우저 시작을 중단합니다. 그래도 실행을 선택할 수 있습니다.",
|
||||
"clearTraffic": "모든 트래픽 기록 지우기",
|
||||
"clearTrafficDescription": "모든 프로필의 기록된 트래픽 통계를 안전하게 지웁니다.",
|
||||
"clearTrafficSuccess": "트래픽 기록이 지워졌습니다"
|
||||
"clearTrafficSuccess": "트래픽 기록이 지워졌습니다",
|
||||
"vpnExtensionWarning": "VPN 확장 프로그램 경고",
|
||||
"vpnExtensionWarningDescription": "브라우저 트래픽의 경로를 바꿀 수 있는 확장 프로그램이 프로필에 있으면 실행 전에 경고합니다."
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
@@ -345,6 +348,10 @@
|
||||
"nameDesc": "이름 (Z→A)",
|
||||
"newest": "최신순",
|
||||
"oldest": "오래된순"
|
||||
},
|
||||
"remote": {
|
||||
"runningTooltip": "원격 머신에서 실행 중입니다. 중지하면 프로필이 여기로 돌아옵니다.",
|
||||
"pendingSyncTooltip": "원격 세션이 변경한 내용을 내려받는 중입니다. 완료되면 다시 사용할 수 있습니다."
|
||||
}
|
||||
},
|
||||
"createProfile": {
|
||||
@@ -461,7 +468,7 @@
|
||||
"ssCipherRequired": "Shadowsocks에는 암호화와 비밀번호가 필요합니다",
|
||||
"selectType": "프록시 유형 선택",
|
||||
"saveFailed": "프록시 저장 실패: {{error}}",
|
||||
"vlessType": "VLESS · Vision · REALITY",
|
||||
"vlessType": "VLESS",
|
||||
"vlessUri": "VLESS URI",
|
||||
"vlessUriPlaceholder": "vless://…",
|
||||
"vlessUriHint": "XTLS Vision 및 REALITY가 필요합니다.",
|
||||
@@ -1859,6 +1866,8 @@
|
||||
"remoteRateLimited": "요청이 너무 많습니다. 잠시 기다렸다가 다시 시도하세요.",
|
||||
"remoteNoCapacity": "지금은 사용 가능한 원격 머신이 없습니다. 몇 분 후에 다시 시도하세요.",
|
||||
"remoteNotEntitled": "현재 요금제에는 원격 실행이 포함되어 있지 않습니다.",
|
||||
"remoteInteractiveNotEntitled": "현재 플랜의 원격 시간은 Cookie Bot 전용이며, 직접 조작하는 원격 세션에는 사용할 수 없습니다.",
|
||||
"remoteRequiresRemoteExitNode": "이 프로필의 프록시는 이 컴퓨터에서만 작동합니다(예: 127.0.0.1 또는 사설망 주소). 원격 세션은 당사 호스트에서 실행되므로 공용 주소를 가진 프록시가 필요합니다.",
|
||||
"remoteSessionRefused": "원격 머신이 이 세션을 거부했습니다.",
|
||||
"remoteSessionNotFound": "해당 원격 세션은 더 이상 존재하지 않습니다.",
|
||||
"remoteSessionConflict": "이 프로필은 이미 다른 곳에서 열려 있습니다.",
|
||||
@@ -1879,8 +1888,33 @@
|
||||
"cookieBotUnknownPlatform": "이 프로필에는 기록된 운영체제가 없어 머신을 배정할 수 없습니다.",
|
||||
"cookieBotUnsupportedPlatform": "Cookie Bot은 {{platform}} 프로필을 실행할 수 없습니다. Windows와 macOS 프로필만 지원합니다.",
|
||||
"cookieBotRequiresExitNode": "먼저 프록시나 VPN을 연결하세요. 없으면 실행 트래픽이 데이터센터 주소에서 나가 프로필 신뢰도를 해칩니다.",
|
||||
"cookieBotRequiresRemoteExitNode": "이 프로필의 프록시는 이 컴퓨터에서만 작동합니다(예: 127.0.0.1 또는 사설망 주소). Cookie Bot은 당사 호스트에서 실행되므로 공용 주소를 가진 프록시가 필요합니다.",
|
||||
"unknownCode": "문제가 발생했습니다: {{code}}",
|
||||
"cookieBotTouchFingerprintUnsupported": "이 프로필은 터치 기기를 표방하며, 봇이 조작할 수 없습니다. 데스크톱 지문을 사용하세요."
|
||||
"cookieBotTouchFingerprintUnsupported": "이 프로필은 터치 기기를 표방하며, 봇이 조작할 수 없습니다. 데스크톱 지문을 사용하세요.",
|
||||
"profileRunningRemotely": "이 프로필은 원격 머신에서 실행 중입니다. 먼저 원격 세션을 중지하세요.",
|
||||
"profileRemoteSyncPending": "원격 세션이 방금 끝났습니다. 이 프로필을 여기서 열기 전에 변경 사항을 내려받는 중입니다.",
|
||||
"profileLockedByMember": "이 프로필은 {{email}} 님이 사용 중입니다.",
|
||||
"profileLockedElsewhere": "이 프로필은 다른 기기에서 사용 중입니다.",
|
||||
"profileLockUnavailable": "이 프로필이 다른 곳에서 사용 중인지 확인할 수 없습니다. 연결을 확인한 뒤 다시 시도하세요.",
|
||||
"fingerprintExitMismatch": "프록시 출구 노드가 이 프로필의 핑거프린트와 일치하지 않습니다.",
|
||||
"launchConsentExpired": "해당 확인이 더 이상 유효하지 않습니다. 다시 실행해 보세요.",
|
||||
"vpnWorkerStartFailed": "VPN 연결을 시작하지 못했습니다: {{detail}}",
|
||||
"exitProbeFailed": "프록시 출구 노드에 연결할 수 없어 위치를 확인하지 못했습니다.",
|
||||
"vlessUnsupported": {
|
||||
"security": "이 VLESS 서버는 REALITY를 사용하지 않습니다. Donut은 REALITY를 사용하는 VLESS만 지원합니다.",
|
||||
"flow": "이 VLESS 서버는 Donut이 요구하는 XTLS Vision 플로우를 사용하지 않습니다.",
|
||||
"transport": "Donut은 일반 TCP 기반 VLESS만 지원합니다. 이 서버는 다른 전송 방식(WebSocket, gRPC 등)을 사용합니다.",
|
||||
"encryption": "이 VLESS 서버는 Donut이 지원하지 않는 암호화 설정을 사용합니다.",
|
||||
"headerType": "이 VLESS 서버는 Donut이 지원하지 않는 헤더 난독화를 사용합니다.",
|
||||
"fingerprint": "이 VLESS URI는 Donut이 지원하지 않는 TLS 지문을 요청합니다.",
|
||||
"sni": "VLESS URI에 REALITY에 필요한 SNI(sni)가 없습니다.",
|
||||
"publicKey": "VLESS URI에 REALITY 공개 키(pbk)가 없습니다.",
|
||||
"scheme": "VLESS 링크가 아닙니다. vless:// 로 시작해야 합니다.",
|
||||
"parameter": "VLESS URI에 Donut이 지원하지 않는 옵션이 있습니다.",
|
||||
"malformed": "VLESS URI가 올바르지 않습니다."
|
||||
},
|
||||
"camoufoxRemoved": "Camoufox는 더 이상 지원되지 않습니다. Wayfern으로 이 프로필을 다시 만드세요.",
|
||||
"noE2ePasswordSet": "종단 간 암호화 비밀번호가 설정되지 않았습니다. 암호화된 데이터를 동기화하기 전에 설정하세요."
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "프로필",
|
||||
@@ -2126,14 +2160,9 @@
|
||||
"description": "브라우저를 닫을 때 쿠키, 방문 기록, 캐시를 지웁니다. 확장 프로그램과 북마크는 유지됩니다."
|
||||
},
|
||||
"consistencyWarning": {
|
||||
"title": "핑거프린트 불일치",
|
||||
"intro": "\"{{name}}\"의 프록시 출구가 이 프로필의 핑거프린트와 일치하지 않습니다:",
|
||||
"timezoneTitle": "시간대 불일치",
|
||||
"timezoneDetail": "출구 노드는 {{exit}}에 있지만 핑거프린트는 {{fingerprint}}로 보고합니다.",
|
||||
"languageTitle": "언어 불일치",
|
||||
"languageDetail": "출구 국가는 {{country}}이지만 핑거프린트 언어는 {{fingerprint}}입니다.",
|
||||
"explainer": "출구 IP와 어긋나는 시간대나 언어는 실제 기기 정보가 유출되지 않더라도 강력한 안티봇 신호가 됩니다. 핑거프린트를 프록시 위치에 맞춰 의심받는 상황을 줄이세요.",
|
||||
"dontWarnAgain": "이 프로필에 대해 다시 경고하지 않음",
|
||||
"matchToProxy": "지문을 프록시에 맞추기",
|
||||
"matching": "맞추는 중…",
|
||||
"matchSuccess": "지문이 프록시에 맞게 업데이트되었습니다. 적용하려면 프로필을 다시 실행하세요."
|
||||
@@ -2331,13 +2360,48 @@
|
||||
"confirmBulkButton_other": "프로필 {{count}}개로 계속",
|
||||
"sitesRequired": "사이트를 하나 이상 추가하세요.",
|
||||
"addSitesFirst": "사이트를 먼저 추가하세요",
|
||||
"presetsMissing": "깊이 프리셋을 사용할 수 없습니다"
|
||||
"presetsMissing": "깊이 프리셋을 사용할 수 없습니다",
|
||||
"sourceOwn": "내 목록",
|
||||
"sourceCurated": "큐레이션",
|
||||
"sourceSaved": "저장됨",
|
||||
"curatedEmpty": "지금은 사용할 수 있는 큐레이션 목록이 없습니다.",
|
||||
"curatedNote": "저희가 최신 상태로 관리하는 큐레이션 목록입니다. 프로필마다 서로 다른 표본을 뽑기 때문에 같은 조합을 방문하는 프로필은 없습니다. 그래서 목록 자체가 특징이 되지 않습니다. 주소는 서버에만 남습니다.",
|
||||
"savedEmpty": "아직 저장한 것이 없습니다. 내 목록에서 목록을 입력한 뒤 거기서 저장하세요.",
|
||||
"savedNote": "사이트는 일정을 저장할 때 복사됩니다. 나중에 목록을 수정해도 기존 일정은 그대로 유지됩니다.",
|
||||
"savedUnavailable": "저장된 목록을 불러오지 못했습니다.",
|
||||
"saveAsList": "목록으로 저장",
|
||||
"listNamePlaceholder": "목록 이름",
|
||||
"listSaved": "목록을 저장했습니다",
|
||||
"listRenamed": "목록 이름을 변경했습니다",
|
||||
"listDeleted": "목록을 삭제했습니다",
|
||||
"listRename": "이름 변경",
|
||||
"listDeleteConfirm": "삭제할까요?",
|
||||
"templateMissing": "그 저장된 목록은 더 이상 없습니다. 다른 목록을 선택하세요.",
|
||||
"templateNameTaken": "같은 이름의 목록이 이미 있습니다.",
|
||||
"templateNameInvalid": "목록 이름은 {{max}}자 이하로 지어 주세요.",
|
||||
"calendarLabel": "실행 시점",
|
||||
"daysLabel": "요일",
|
||||
"addSlot": "시간 추가",
|
||||
"slotsFull": "시작 시간은 최대 {{max}}개입니다.",
|
||||
"removeSlot": "이 시간 제거",
|
||||
"pickListFirst": "먼저 목록을 선택하세요",
|
||||
"finishCalendarFirst": "먼저 일정을 완성하세요",
|
||||
"duplicateSlot": "두 행의 요일과 시간이 같습니다",
|
||||
"listSites_one": "사이트 {{count}}개",
|
||||
"listSites_other": "사이트 {{count}}개",
|
||||
"summarySlots_one": "주 {{count}}회, 회당 최대 {{minutes}}분 실행합니다.",
|
||||
"summarySlots_other": "주 {{count}}회, 회당 최대 {{minutes}}분 실행합니다.",
|
||||
"templateNameInvalidNoMax": "저장된 목록에 그 이름은 사용할 수 없습니다."
|
||||
},
|
||||
"preset": {
|
||||
"light": "가볍게",
|
||||
"balanced": "표준",
|
||||
"deep": "깊게"
|
||||
},
|
||||
"template": {
|
||||
"lowIntentPurchaser": "구매 의향이 낮은 쇼핑객",
|
||||
"lowIntentPurchaserHint": "가격에 민감한 구매자로 프로필을 자리매김합니다. 비교·쿠폰·캐시백·중고 거래 사이트를 이용하고, 판매점에는 직접이 아니라 집계 사이트를 거쳐 도달합니다."
|
||||
},
|
||||
"preflight": {
|
||||
"ineligible_one": "프로필 {{count}}개는 원격으로 실행할 수 없습니다",
|
||||
"ineligible_other": "프로필 {{count}}개는 원격으로 실행할 수 없습니다",
|
||||
@@ -2419,5 +2483,31 @@
|
||||
"cancelledByUser": "직접 중지했습니다",
|
||||
"unknown": "알 수 없는 이유 ({{code}})"
|
||||
}
|
||||
},
|
||||
"prelaunchGate": {
|
||||
"titleBlocked": "실행이 차단됨",
|
||||
"titleWarning": "실행하기 전에",
|
||||
"intro": "브라우저를 시작하기 전에 \"{{name}}\"의 다음 문제를 확인하세요.",
|
||||
"fingerprintHeading": "프록시 출구가 핑거프린트와 일치하지 않음",
|
||||
"vpnExtensionHeading": "VPN 확장 프로그램 감지됨",
|
||||
"vpnExtensionIntro": "이 프로필에서 브라우저 트래픽의 경로를 바꿀 수 있는 확장 프로그램:",
|
||||
"vpnExtensionConfirmed": "프록시를 변경할 수 있음",
|
||||
"vpnExtensionLikely": "프록시를 변경할 수 있음(추정)",
|
||||
"vpnExtensionExplainer": "이 중 하나가 트래픽을 다른 곳으로 보내면 브라우저의 실제 위치가 이 프로필을 만들 때 사용한 시간대, 언어, 지리 정보와 더 이상 일치하지 않으며, Donut은 외부에서 이를 감지할 수 없습니다.",
|
||||
"sourceDonut": "Donut이 관리",
|
||||
"sourceBrowser": "프로필에 설치됨",
|
||||
"measurementUnreliable": "VPN 확장 프로그램이 프록시를 덮어쓸 수 있으므로, 출구 확인 결과가 브라우저의 실제 경로와 다를 수 있습니다.",
|
||||
"scanIncompleteEncrypted": "이 프로필은 암호화되어 있어 Donut이 관리하는 확장 프로그램만 확인할 수 있었습니다.",
|
||||
"scanIncompleteEphemeral": "이 프로필에는 아직 데이터가 없어 Donut이 관리하는 확장 프로그램만 확인할 수 있었습니다.",
|
||||
"scanIncompletePartial": "확장 프로그램 검사가 중단되어 일부가 표시되지 않을 수 있습니다.",
|
||||
"probePending": "프록시 출구를 아직 측정하지 않았습니다. Donut이 시작 중에 확인하고 일치하지 않으면 중단합니다.",
|
||||
"launchAnyway": "그래도 실행",
|
||||
"dontBlockAgain": "이 불일치에 대해 다시 차단하지 않기",
|
||||
"dontWarnExtensions": "이 확장 프로그램에 대해 다시 경고하지 않기",
|
||||
"applyToRemaining": "이 선택을 나머지 프로필에 적용",
|
||||
"cancelledSummary": "{{total}}개 중 {{cancelled}}개의 실행이 취소됨",
|
||||
"cancelled": "실행이 취소됨",
|
||||
"vpnExtensionEntry": " {{version}} — {{capability}}, {{source}}",
|
||||
"scanIncompleteMissing": "이 프로필은 아직 실행된 적이 없어 Donut이 관리하는 확장 프로그램만 확인할 수 있었습니다."
|
||||
}
|
||||
}
|
||||
|
||||
+104
-12
@@ -92,7 +92,8 @@
|
||||
"window": {
|
||||
"minimize": "Minimizar",
|
||||
"maximize": "Maximizar",
|
||||
"restore": "Restaurar"
|
||||
"restore": "Restaurar",
|
||||
"close": "Fechar janela"
|
||||
},
|
||||
"commandPalette": {
|
||||
"title": "Paleta de comandos",
|
||||
@@ -199,11 +200,13 @@
|
||||
"keepDecryptedProfilesInRam": "Manter Perfis Descriptografados na RAM",
|
||||
"keepDecryptedProfilesInRamDescription": "Preserva a cópia descriptografada na RAM dos perfis protegidos por senha entre execuções para um início mais rápido. A cópia em disco permanece criptografada em qualquer caso.",
|
||||
"privacy": {
|
||||
"consistencyWarning": "Aviso de consistência de impressão digital",
|
||||
"consistencyWarningDescription": "Avisar ao iniciar quando o fuso horário ou o idioma de um perfil não corresponder ao seu nó de saída do proxy.",
|
||||
"consistencyWarning": "Bloquear quando a impressão digital divergir",
|
||||
"consistencyWarningDescription": "Impede que o navegador inicie quando o fuso horário ou o idioma de um perfil não corresponde ao seu nó de saída do proxy. Você ainda pode optar por iniciar.",
|
||||
"clearTraffic": "Limpar todo o histórico de tráfego",
|
||||
"clearTrafficDescription": "Apaga com segurança as estatísticas de tráfego registradas de todos os perfis.",
|
||||
"clearTrafficSuccess": "Histórico de tráfego limpo"
|
||||
"clearTrafficSuccess": "Histórico de tráfego limpo",
|
||||
"vpnExtensionWarning": "Aviso de extensão VPN",
|
||||
"vpnExtensionWarningDescription": "Avisar antes de iniciar quando um perfil contiver uma extensão capaz de redirecionar o tráfego do navegador."
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
@@ -345,6 +348,10 @@
|
||||
"nameDesc": "Nome (Z–A)",
|
||||
"newest": "Mais recentes primeiro",
|
||||
"oldest": "Mais antigos primeiro"
|
||||
},
|
||||
"remote": {
|
||||
"runningTooltip": "Em execução numa máquina remota. Pare-a para trazer o perfil de volta para aqui.",
|
||||
"pendingSyncTooltip": "A transferir o que a sessão remota alterou. Disponível novamente quando terminar."
|
||||
}
|
||||
},
|
||||
"createProfile": {
|
||||
@@ -462,7 +469,7 @@
|
||||
"ssCipherRequired": "Cifra e senha são obrigatórias para Shadowsocks",
|
||||
"selectType": "Selecione o tipo de proxy",
|
||||
"saveFailed": "Falha ao salvar o proxy: {{error}}",
|
||||
"vlessType": "VLESS · Vision · REALITY",
|
||||
"vlessType": "VLESS",
|
||||
"vlessUri": "URI VLESS",
|
||||
"vlessUriPlaceholder": "vless://…",
|
||||
"vlessUriHint": "Requer XTLS Vision e REALITY.",
|
||||
@@ -1866,6 +1873,8 @@
|
||||
"remoteRateLimited": "Solicitações demais. Aguarde um momento e tente novamente.",
|
||||
"remoteNoCapacity": "Nenhuma máquina remota está livre agora. Tente novamente em alguns minutos.",
|
||||
"remoteNotEntitled": "Seu plano não inclui execução remota.",
|
||||
"remoteInteractiveNotEntitled": "Seu plano inclui horas remotas apenas para o Cookie Bot, não para sessões remotas interativas.",
|
||||
"remoteRequiresRemoteExitNode": "O proxy deste perfil só funciona neste computador (por exemplo 127.0.0.1 ou um endereço de rede local). As sessões remotas são executadas nos nossos servidores, por isso precisam de um proxy com endereço público.",
|
||||
"remoteSessionRefused": "A máquina remota recusou esta sessão.",
|
||||
"remoteSessionNotFound": "Essa sessão remota não existe mais.",
|
||||
"remoteSessionConflict": "Este perfil já está aberto em outro lugar.",
|
||||
@@ -1886,8 +1895,33 @@
|
||||
"cookieBotUnknownPlatform": "Este perfil não tem sistema operacional registrado, então não é possível associá-lo a uma máquina.",
|
||||
"cookieBotUnsupportedPlatform": "O Cookie Bot não pode executar perfis de {{platform}}. Somente perfis Windows e macOS são suportados.",
|
||||
"cookieBotRequiresExitNode": "Anexe primeiro um proxy ou VPN. Sem isso, a execução sairia de um endereço de data center, o que prejudica a identidade do perfil.",
|
||||
"cookieBotRequiresRemoteExitNode": "O proxy deste perfil só funciona neste computador (por exemplo 127.0.0.1 ou um endereço de rede local). O Cookie Bot é executado nos nossos servidores, por isso precisa de um proxy com endereço público.",
|
||||
"unknownCode": "Algo deu errado: {{code}}",
|
||||
"cookieBotTouchFingerprintUnsupported": "Este perfil declara um dispositivo de toque, que o bot não consegue controlar. Use uma impressão digital de computador."
|
||||
"cookieBotTouchFingerprintUnsupported": "Este perfil declara um dispositivo de toque, que o bot não consegue controlar. Use uma impressão digital de computador.",
|
||||
"profileRunningRemotely": "Este perfil está em execução numa máquina remota. Pare primeiro a sessão remota.",
|
||||
"profileRemoteSyncPending": "Uma sessão remota acabou de terminar. A aguardar a transferência das alterações antes de abrir este perfil aqui.",
|
||||
"profileLockedByMember": "Este perfil está a ser utilizado por {{email}}.",
|
||||
"profileLockedElsewhere": "Este perfil está a ser utilizado noutro dispositivo.",
|
||||
"profileLockUnavailable": "Não foi possível verificar se este perfil está a ser utilizado noutro local. Verifique a ligação e tente novamente.",
|
||||
"fingerprintExitMismatch": "O nó de saída do proxy não corresponde à impressão digital deste perfil.",
|
||||
"launchConsentExpired": "Essa confirmação não é mais válida. Tente iniciar novamente.",
|
||||
"vpnWorkerStartFailed": "Não foi possível iniciar a conexão VPN: {{detail}}",
|
||||
"exitProbeFailed": "Não foi possível alcançar o nó de saída do proxy para verificar sua localização.",
|
||||
"vlessUnsupported": {
|
||||
"security": "Este servidor VLESS não usa REALITY. O Donut só oferece suporte a VLESS com REALITY.",
|
||||
"flow": "Este servidor VLESS não usa o fluxo XTLS Vision, exigido pelo Donut.",
|
||||
"transport": "O Donut só oferece suporte a VLESS sobre TCP simples — este servidor usa outro transporte (como WebSocket ou gRPC).",
|
||||
"encryption": "Este servidor VLESS usa uma criptografia sem suporte no Donut.",
|
||||
"headerType": "Este servidor VLESS usa uma ofuscação de cabeçalho sem suporte no Donut.",
|
||||
"fingerprint": "Esta URI VLESS solicita uma impressão digital TLS sem suporte no Donut.",
|
||||
"sni": "Falta na URI VLESS o SNI (sni) necessário para o REALITY.",
|
||||
"publicKey": "Falta na URI VLESS a chave pública do REALITY (pbk).",
|
||||
"scheme": "Isso não é um link VLESS. Ele precisa começar com vless://.",
|
||||
"parameter": "A URI VLESS contém uma opção sem suporte no Donut.",
|
||||
"malformed": "A URI VLESS é inválida."
|
||||
},
|
||||
"camoufoxRemoved": "O Camoufox não é mais compatível. Recrie este perfil com o Wayfern.",
|
||||
"noE2ePasswordSet": "Nenhuma senha de criptografia de ponta a ponta foi definida. Defina uma antes de sincronizar dados criptografados."
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "Perfis",
|
||||
@@ -2133,14 +2167,9 @@
|
||||
"description": "Apaga cookies, histórico e cache quando o navegador é fechado. Extensões e favoritos são mantidos."
|
||||
},
|
||||
"consistencyWarning": {
|
||||
"title": "Divergência de impressão digital",
|
||||
"intro": "A saída do proxy de \"{{name}}\" não corresponde à impressão digital deste perfil:",
|
||||
"timezoneTitle": "Divergência de fuso horário",
|
||||
"timezoneDetail": "O nó de saída está em {{exit}}, mas a impressão digital indica {{fingerprint}}.",
|
||||
"languageTitle": "Divergência de idioma",
|
||||
"languageDetail": "O país de saída é {{country}}, mas o idioma da impressão digital é {{fingerprint}}.",
|
||||
"explainer": "Um fuso horário ou idioma que não combina com seu IP de saída é um forte sinal anti-bot, mesmo que seu dispositivo real nunca vaze. Alinhe a impressão digital com a localização do proxy para reduzir tratamentos hostis.",
|
||||
"dontWarnAgain": "Não avisar novamente para este perfil",
|
||||
"matchToProxy": "Ajustar impressão ao proxy",
|
||||
"matching": "Ajustando…",
|
||||
"matchSuccess": "Impressão digital atualizada para corresponder ao proxy. Reinicie o perfil para aplicar."
|
||||
@@ -2356,13 +2385,50 @@
|
||||
"confirmBulkButton_many": "Continuar com {{count}} perfis",
|
||||
"sitesRequired": "Adicione pelo menos um site.",
|
||||
"addSitesFirst": "Adicione um site primeiro",
|
||||
"presetsMissing": "Predefinições de profundidade indisponíveis"
|
||||
"presetsMissing": "Predefinições de profundidade indisponíveis",
|
||||
"sourceOwn": "Minha própria lista",
|
||||
"sourceCurated": "Selecionadas",
|
||||
"sourceSaved": "Salvas",
|
||||
"curatedEmpty": "Nenhuma lista selecionada está disponível no momento.",
|
||||
"curatedNote": "Uma lista selecionada que mantemos atualizada. Cada perfil retira sua própria amostra dela, então não há dois perfis navegando pelo mesmo conjunto — é isso que impede a lista de virar uma assinatura. Os endereços ficam do nosso lado.",
|
||||
"savedEmpty": "Nada salvo ainda. Digite uma lista em Minha própria lista e salve por lá.",
|
||||
"savedNote": "Os sites são copiados para a programação quando você a salva, então editar uma lista depois não altera as programações existentes.",
|
||||
"savedUnavailable": "Não foi possível carregar suas listas salvas.",
|
||||
"saveAsList": "Salvar como lista",
|
||||
"listNamePlaceholder": "Dê um nome a esta lista",
|
||||
"listSaved": "Lista salva",
|
||||
"listRenamed": "Lista renomeada",
|
||||
"listDeleted": "Lista excluída",
|
||||
"listRename": "Renomear",
|
||||
"listDeleteConfirm": "Excluir?",
|
||||
"templateMissing": "Essa lista salva não existe mais. Escolha outra.",
|
||||
"templateNameTaken": "Você já tem uma lista com esse nome.",
|
||||
"templateNameInvalid": "Dê à lista um nome de até {{max}} caracteres.",
|
||||
"calendarLabel": "Quando executa",
|
||||
"daysLabel": "Dias da semana",
|
||||
"addSlot": "Adicionar um horário",
|
||||
"slotsFull": "No máximo {{max}} horários de início.",
|
||||
"removeSlot": "Remover este horário",
|
||||
"pickListFirst": "Escolha uma lista primeiro",
|
||||
"finishCalendarFirst": "Termine a programação primeiro",
|
||||
"duplicateSlot": "Duas linhas têm os mesmos dias e o mesmo horário",
|
||||
"listSites_one": "{{count}} site",
|
||||
"listSites_other": "{{count}} sites",
|
||||
"listSites_many": "{{count}} sites",
|
||||
"summarySlots_one": "Executa {{count}} vez por semana, até {{minutes}} min por vez.",
|
||||
"summarySlots_other": "Executa {{count}} vezes por semana, até {{minutes}} min por vez.",
|
||||
"summarySlots_many": "Executa {{count}} vezes por semana, até {{minutes}} min por vez.",
|
||||
"templateNameInvalidNoMax": "Esse nome não pode ser usado para uma lista salva."
|
||||
},
|
||||
"preset": {
|
||||
"light": "Leve",
|
||||
"balanced": "Padrão",
|
||||
"deep": "Profunda"
|
||||
},
|
||||
"template": {
|
||||
"lowIntentPurchaser": "Comprador de baixa intenção",
|
||||
"lowIntentPurchaserHint": "Posiciona o perfil como um comprador sensível a preço: sites de comparação, cupons, cashback e revenda, chegando às lojas por agregadores em vez de diretamente."
|
||||
},
|
||||
"preflight": {
|
||||
"ineligible_one": "{{count}} perfil não pode ser executado remotamente",
|
||||
"ineligible_other": "{{count}} perfis não podem ser executados remotamente",
|
||||
@@ -2446,5 +2512,31 @@
|
||||
"cancelledByUser": "Parado à mão",
|
||||
"unknown": "Motivo desconhecido ({{code}})"
|
||||
}
|
||||
},
|
||||
"prelaunchGate": {
|
||||
"titleBlocked": "Inicialização bloqueada",
|
||||
"titleWarning": "Antes de iniciar",
|
||||
"intro": "Revise estes problemas de \"{{name}}\" antes de iniciar o navegador.",
|
||||
"fingerprintHeading": "A saída do proxy não corresponde à impressão digital",
|
||||
"vpnExtensionHeading": "Extensão VPN detectada",
|
||||
"vpnExtensionIntro": "Extensões neste perfil que podem redirecionar o tráfego do navegador:",
|
||||
"vpnExtensionConfirmed": "Pode alterar o proxy",
|
||||
"vpnExtensionLikely": "Talvez altere o proxy",
|
||||
"vpnExtensionExplainer": "Se alguma delas redirecionar seu tráfego, a localização real do navegador deixará de corresponder ao fuso horário, ao idioma e à geolocalização com que este perfil foi criado, e o Donut não consegue detectar isso de fora.",
|
||||
"sourceDonut": "Gerenciada pelo Donut",
|
||||
"sourceBrowser": "Instalada no perfil",
|
||||
"measurementUnreliable": "Como uma extensão VPN pode substituir o proxy, a verificação de saída pode não refletir a rota que o navegador realmente usa.",
|
||||
"scanIncompleteEncrypted": "Este perfil está criptografado, portanto só foi possível verificar as extensões gerenciadas pelo Donut.",
|
||||
"scanIncompleteEphemeral": "Este perfil ainda não tem dados, portanto só foi possível verificar as extensões gerenciadas pelo Donut.",
|
||||
"scanIncompletePartial": "A verificação de extensões foi interrompida, então algumas podem não estar listadas.",
|
||||
"probePending": "A saída do proxy ainda não foi medida. O Donut vai verificá-la durante a inicialização e parar se não corresponder.",
|
||||
"launchAnyway": "Iniciar mesmo assim",
|
||||
"dontBlockAgain": "Não bloquear novamente para esta divergência exata",
|
||||
"dontWarnExtensions": "Não avisar novamente sobre estas extensões",
|
||||
"applyToRemaining": "Aplicar esta escolha aos perfis restantes",
|
||||
"cancelledSummary": "{{cancelled}} de {{total}} inicializações canceladas",
|
||||
"cancelled": "Inicialização cancelada",
|
||||
"vpnExtensionEntry": " {{version}} — {{capability}}, {{source}}",
|
||||
"scanIncompleteMissing": "Este perfil ainda não foi iniciado, portanto só foi possível verificar as extensões gerenciadas pelo Donut."
|
||||
}
|
||||
}
|
||||
|
||||
+106
-12
@@ -92,7 +92,8 @@
|
||||
"window": {
|
||||
"minimize": "Свернуть",
|
||||
"maximize": "Развернуть",
|
||||
"restore": "Восстановить"
|
||||
"restore": "Восстановить",
|
||||
"close": "Закрыть окно"
|
||||
},
|
||||
"commandPalette": {
|
||||
"title": "Палитра команд",
|
||||
@@ -199,11 +200,13 @@
|
||||
"keepDecryptedProfilesInRam": "Хранить расшифрованные профили в ОЗУ",
|
||||
"keepDecryptedProfilesInRamDescription": "Сохранять расшифрованную копию защищённых паролем профилей в ОЗУ между запусками для ускорения старта. Копия на диске в любом случае остаётся зашифрованной.",
|
||||
"privacy": {
|
||||
"consistencyWarning": "Предупреждение о согласованности отпечатка",
|
||||
"consistencyWarningDescription": "Предупреждать при запуске, если часовой пояс или язык профиля не совпадает с выходным узлом прокси.",
|
||||
"consistencyWarning": "Блокировать при несовпадении отпечатка",
|
||||
"consistencyWarningDescription": "Не запускать браузер, если часовой пояс или язык профиля не совпадают с выходным узлом прокси. Запустить всё равно можно вручную.",
|
||||
"clearTraffic": "Очистить всю историю трафика",
|
||||
"clearTrafficDescription": "Безопасно удаляет записанную статистику трафика для всех профилей.",
|
||||
"clearTrafficSuccess": "История трафика очищена"
|
||||
"clearTrafficSuccess": "История трафика очищена",
|
||||
"vpnExtensionWarning": "Предупреждение о VPN-расширении",
|
||||
"vpnExtensionWarningDescription": "Предупреждать перед запуском, если в профиле есть расширение, способное перенаправить трафик браузера."
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
@@ -345,6 +348,10 @@
|
||||
"nameDesc": "Имя (Я–А)",
|
||||
"newest": "Сначала новые",
|
||||
"oldest": "Сначала старые"
|
||||
},
|
||||
"remote": {
|
||||
"runningTooltip": "Выполняется на удалённой машине. Остановите, чтобы вернуть профиль сюда.",
|
||||
"pendingSyncTooltip": "Загружаются изменения удалённого сеанса. Профиль снова будет доступен по завершении."
|
||||
}
|
||||
},
|
||||
"createProfile": {
|
||||
@@ -463,7 +470,7 @@
|
||||
"ssCipherRequired": "Для Shadowsocks требуется шифр и пароль",
|
||||
"selectType": "Выберите тип прокси",
|
||||
"saveFailed": "Не удалось сохранить прокси: {{error}}",
|
||||
"vlessType": "VLESS · Vision · REALITY",
|
||||
"vlessType": "VLESS",
|
||||
"vlessUri": "URI VLESS",
|
||||
"vlessUriPlaceholder": "vless://…",
|
||||
"vlessUriHint": "Требуются XTLS Vision и REALITY.",
|
||||
@@ -1873,6 +1880,8 @@
|
||||
"remoteRateLimited": "Слишком много запросов. Подождите немного и попробуйте снова.",
|
||||
"remoteNoCapacity": "Сейчас нет свободных удалённых машин. Попробуйте через несколько минут.",
|
||||
"remoteNotEntitled": "Ваш тариф не включает удалённый запуск.",
|
||||
"remoteInteractiveNotEntitled": "В вашем тарифе удалённые часы доступны только для Cookie Bot, но не для интерактивных удалённых сессий.",
|
||||
"remoteRequiresRemoteExitNode": "Прокси этого профиля работает только на этом компьютере (например, 127.0.0.1 или адрес локальной сети). Удалённые сессии выполняются на наших хостах, поэтому нужен прокси с публичным адресом.",
|
||||
"remoteSessionRefused": "Удалённая машина отклонила эту сессию.",
|
||||
"remoteSessionNotFound": "Этой удалённой сессии больше не существует.",
|
||||
"remoteSessionConflict": "Этот профиль уже открыт в другом месте.",
|
||||
@@ -1893,8 +1902,33 @@
|
||||
"cookieBotUnknownPlatform": "Для этого профиля не записана операционная система, поэтому подобрать машину невозможно.",
|
||||
"cookieBotUnsupportedPlatform": "Cookie Bot не может запускать профили {{platform}}. Поддерживаются только профили Windows и macOS.",
|
||||
"cookieBotRequiresExitNode": "Сначала назначьте прокси или VPN. Без них трафик пойдёт с адреса дата-центра, а это вредит репутации профиля.",
|
||||
"cookieBotRequiresRemoteExitNode": "Прокси этого профиля работает только на этом компьютере (например, 127.0.0.1 или адрес локальной сети). Cookie Bot выполняется на наших хостах, поэтому нужен прокси с публичным адресом.",
|
||||
"unknownCode": "Что-то пошло не так: {{code}}",
|
||||
"cookieBotTouchFingerprintUnsupported": "Этот профиль выдаёт себя за сенсорное устройство, которым бот управлять не может. Используйте настольный отпечаток."
|
||||
"cookieBotTouchFingerprintUnsupported": "Этот профиль выдаёт себя за сенсорное устройство, которым бот управлять не может. Используйте настольный отпечаток.",
|
||||
"profileRunningRemotely": "Этот профиль запущен на удалённой машине. Сначала остановите удалённый сеанс.",
|
||||
"profileRemoteSyncPending": "Удалённый сеанс только что завершился. Дождитесь загрузки его изменений, прежде чем открывать профиль здесь.",
|
||||
"profileLockedByMember": "Этот профиль используется пользователем {{email}}.",
|
||||
"profileLockedElsewhere": "Этот профиль используется на другом устройстве.",
|
||||
"profileLockUnavailable": "Не удалось проверить, используется ли профиль где-то ещё. Проверьте подключение и попробуйте снова.",
|
||||
"fingerprintExitMismatch": "Выходной узел прокси не совпадает с отпечатком этого профиля.",
|
||||
"launchConsentExpired": "Это подтверждение больше не действует. Запустите профиль ещё раз.",
|
||||
"vpnWorkerStartFailed": "Не удалось запустить VPN-подключение: {{detail}}",
|
||||
"exitProbeFailed": "Не удалось связаться с выходным узлом прокси, чтобы определить его местоположение.",
|
||||
"vlessUnsupported": {
|
||||
"security": "Этот сервер VLESS не использует REALITY. Donut поддерживает только VLESS с REALITY.",
|
||||
"flow": "Этот сервер VLESS не использует поток XTLS Vision, который требуется Donut.",
|
||||
"transport": "Donut поддерживает VLESS только поверх обычного TCP — этот сервер использует другой транспорт (например, WebSocket или gRPC).",
|
||||
"encryption": "Этот сервер VLESS использует шифрование, которое Donut не поддерживает.",
|
||||
"headerType": "Этот сервер VLESS использует обфускацию заголовков, которую Donut не поддерживает.",
|
||||
"fingerprint": "Этот VLESS URI запрашивает отпечаток TLS, который Donut не поддерживает.",
|
||||
"sni": "В VLESS URI отсутствует SNI (sni), необходимый для REALITY.",
|
||||
"publicKey": "В VLESS URI отсутствует открытый ключ REALITY (pbk).",
|
||||
"scheme": "Это не ссылка VLESS. Она должна начинаться с vless://.",
|
||||
"parameter": "VLESS URI содержит параметр, который Donut не поддерживает.",
|
||||
"malformed": "VLESS URI недействителен."
|
||||
},
|
||||
"camoufoxRemoved": "Camoufox больше не поддерживается. Создайте этот профиль заново с Wayfern.",
|
||||
"noE2ePasswordSet": "Пароль сквозного шифрования не задан. Задайте его перед синхронизацией зашифрованных данных."
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "Профили",
|
||||
@@ -2140,14 +2174,9 @@
|
||||
"description": "Удаляет cookie, историю и кэш при закрытии браузера. Расширения и закладки сохраняются."
|
||||
},
|
||||
"consistencyWarning": {
|
||||
"title": "Несовпадение отпечатка",
|
||||
"intro": "Выходной узел прокси для «{{name}}» не соответствует отпечатку этого профиля:",
|
||||
"timezoneTitle": "Несовпадение часового пояса",
|
||||
"timezoneDetail": "Выходной узел находится в {{exit}}, но отпечаток сообщает {{fingerprint}}.",
|
||||
"languageTitle": "Несовпадение языка",
|
||||
"languageDetail": "Страна выхода — {{country}}, но язык отпечатка — {{fingerprint}}.",
|
||||
"explainer": "Часовой пояс или язык, не совпадающий с выходным IP, — сильный антибот-сигнал, даже если данные вашего реального устройства никогда не утекают. Приведите отпечаток в соответствие с расположением прокси, чтобы снизить враждебное отношение.",
|
||||
"dontWarnAgain": "Больше не предупреждать для этого профиля",
|
||||
"matchToProxy": "Подогнать отпечаток под прокси",
|
||||
"matching": "Подгонка…",
|
||||
"matchSuccess": "Отпечаток обновлён под прокси. Перезапустите профиль, чтобы применить."
|
||||
@@ -2381,13 +2410,52 @@
|
||||
"confirmBulkButton_many": "Продолжить с {{count}} профилями",
|
||||
"sitesRequired": "Добавьте хотя бы один сайт.",
|
||||
"addSitesFirst": "Сначала добавьте сайт",
|
||||
"presetsMissing": "Пресеты глубины недоступны"
|
||||
"presetsMissing": "Пресеты глубины недоступны",
|
||||
"sourceOwn": "Свой список",
|
||||
"sourceCurated": "Подборки",
|
||||
"sourceSaved": "Сохранённые",
|
||||
"curatedEmpty": "Сейчас нет доступных подборок.",
|
||||
"curatedNote": "Подборка, которую мы поддерживаем в актуальном состоянии. Каждый профиль берёт из неё свою выборку, поэтому два профиля не обходят один и тот же набор — именно это не даёт списку стать приметой. Адреса остаются на нашей стороне.",
|
||||
"savedEmpty": "Пока ничего не сохранено. Введите список в разделе «Свой список» и сохраните его оттуда.",
|
||||
"savedNote": "Сайты копируются в расписание при сохранении, поэтому правка списка позже не меняет уже созданные расписания.",
|
||||
"savedUnavailable": "Не удалось загрузить сохранённые списки.",
|
||||
"saveAsList": "Сохранить как список",
|
||||
"listNamePlaceholder": "Название списка",
|
||||
"listSaved": "Список сохранён",
|
||||
"listRenamed": "Список переименован",
|
||||
"listDeleted": "Список удалён",
|
||||
"listRename": "Переименовать",
|
||||
"listDeleteConfirm": "Удалить?",
|
||||
"templateMissing": "Этого сохранённого списка больше нет. Выберите другой.",
|
||||
"templateNameTaken": "Список с таким названием уже есть.",
|
||||
"templateNameInvalid": "Название списка — не длиннее {{max}} символов.",
|
||||
"calendarLabel": "Когда запускать",
|
||||
"daysLabel": "Дни недели",
|
||||
"addSlot": "Добавить время",
|
||||
"slotsFull": "Не больше {{max}} времён запуска.",
|
||||
"removeSlot": "Убрать это время",
|
||||
"pickListFirst": "Сначала выберите список",
|
||||
"finishCalendarFirst": "Сначала заполните расписание",
|
||||
"duplicateSlot": "Две строки повторяют одни и те же дни и время",
|
||||
"listSites_one": "{{count}} сайт",
|
||||
"listSites_few": "{{count}} сайта",
|
||||
"listSites_other": "{{count}} сайтов",
|
||||
"listSites_many": "{{count}} сайтов",
|
||||
"summarySlots_one": "Запускается {{count}} раз в неделю, не дольше {{minutes}} мин за раз.",
|
||||
"summarySlots_few": "Запускается {{count}} раза в неделю, не дольше {{minutes}} мин за раз.",
|
||||
"summarySlots_other": "Запускается {{count}} раз в неделю, не дольше {{minutes}} мин за раз.",
|
||||
"summarySlots_many": "Запускается {{count}} раз в неделю, не дольше {{minutes}} мин за раз.",
|
||||
"templateNameInvalidNoMax": "Это имя нельзя использовать для сохранённого списка."
|
||||
},
|
||||
"preset": {
|
||||
"light": "Лёгкая",
|
||||
"balanced": "Стандартная",
|
||||
"deep": "Глубокая"
|
||||
},
|
||||
"template": {
|
||||
"lowIntentPurchaser": "Покупатель с низким намерением",
|
||||
"lowIntentPurchaserHint": "Позиционирует профиль как чувствительного к цене покупателя: сравнение цен, купоны, кэшбэк и перепродажа, а к магазинам — через агрегаторы, а не напрямую."
|
||||
},
|
||||
"preflight": {
|
||||
"ineligible_one": "{{count}} профиль нельзя запустить удалённо",
|
||||
"ineligible_few": "{{count}} профиля нельзя запустить удалённо",
|
||||
@@ -2473,5 +2541,31 @@
|
||||
"cancelledByUser": "Остановлено вручную",
|
||||
"unknown": "Неизвестная причина ({{code}})"
|
||||
}
|
||||
},
|
||||
"prelaunchGate": {
|
||||
"titleBlocked": "Запуск заблокирован",
|
||||
"titleWarning": "Перед запуском",
|
||||
"intro": "Проверьте эти проблемы профиля «{{name}}» перед запуском браузера.",
|
||||
"fingerprintHeading": "Выходной узел прокси не совпадает с отпечатком",
|
||||
"vpnExtensionHeading": "Обнаружено VPN-расширение",
|
||||
"vpnExtensionIntro": "Расширения в этом профиле, способные перенаправить трафик браузера:",
|
||||
"vpnExtensionConfirmed": "Может изменить прокси",
|
||||
"vpnExtensionLikely": "Возможно, изменит прокси",
|
||||
"vpnExtensionExplainer": "Если одно из них направит трафик в другое место, реальное местоположение браузера перестанет совпадать с часовым поясом, языком и геолокацией, с которыми создавался профиль, а Donut не сможет это обнаружить извне.",
|
||||
"sourceDonut": "Управляется Donut",
|
||||
"sourceBrowser": "Установлено в профиле",
|
||||
"measurementUnreliable": "Поскольку VPN-расширение может переопределить прокси, проверка выходного узла может не отражать реальный маршрут браузера.",
|
||||
"scanIncompleteEncrypted": "Профиль зашифрован, поэтому удалось проверить только расширения, управляемые Donut.",
|
||||
"scanIncompleteEphemeral": "В профиле ещё нет данных, поэтому удалось проверить только расширения, управляемые Donut.",
|
||||
"scanIncompletePartial": "Проверка расширений была прервана, поэтому некоторые могут отсутствовать в списке.",
|
||||
"probePending": "Выходной узел прокси ещё не измерен. Donut проверит его при запуске и остановится, если он не совпадёт.",
|
||||
"launchAnyway": "Всё равно запустить",
|
||||
"dontBlockAgain": "Больше не блокировать при этом несовпадении",
|
||||
"dontWarnExtensions": "Больше не предупреждать об этих расширениях",
|
||||
"applyToRemaining": "Применить этот выбор к остальным профилям",
|
||||
"cancelledSummary": "Отменено запусков: {{cancelled}} из {{total}}",
|
||||
"cancelled": "Запуск отменён",
|
||||
"vpnExtensionEntry": " {{version}} — {{capability}}, {{source}}",
|
||||
"scanIncompleteMissing": "Профиль ещё ни разу не запускался, поэтому удалось проверить только расширения, управляемые Donut."
|
||||
}
|
||||
}
|
||||
|
||||
+102
-12
@@ -92,7 +92,8 @@
|
||||
"window": {
|
||||
"minimize": "Küçült",
|
||||
"maximize": "Büyüt",
|
||||
"restore": "Geri Yükle"
|
||||
"restore": "Geri Yükle",
|
||||
"close": "Pencereyi kapat"
|
||||
},
|
||||
"commandPalette": {
|
||||
"title": "Komut Paleti",
|
||||
@@ -199,11 +200,13 @@
|
||||
"keepDecryptedProfilesInRam": "Şifresi Çözülmüş Profilleri RAM'de Tut",
|
||||
"keepDecryptedProfilesInRamDescription": "Daha hızlı başlatma için parola korumalı profillerin şifresi çözülmüş RAM kopyasını başlatmalar arasında koruyun. Diskteki kopya her durumda şifreli kalır.",
|
||||
"privacy": {
|
||||
"consistencyWarning": "Parmak izi tutarlılık uyarısı",
|
||||
"consistencyWarningDescription": "Bir profilin saat dilimi veya dili proxy çıkış düğümüyle eşleşmediğinde başlatma sırasında uyar.",
|
||||
"consistencyWarning": "Parmak izi uyuşmazlığında engelle",
|
||||
"consistencyWarningDescription": "Bir profilin saat dilimi veya dili proxy çıkış düğümüyle eşleşmediğinde tarayıcının başlamasını durdurur. Yine de başlatmayı seçebilirsiniz.",
|
||||
"clearTraffic": "Tüm trafik geçmişini temizle",
|
||||
"clearTrafficDescription": "Tüm profillerin kayıtlı trafik istatistiklerini güvenli bir şekilde siler.",
|
||||
"clearTrafficSuccess": "Trafik geçmişi temizlendi"
|
||||
"clearTrafficSuccess": "Trafik geçmişi temizlendi",
|
||||
"vpnExtensionWarning": "VPN uzantısı uyarısı",
|
||||
"vpnExtensionWarningDescription": "Bir profil, tarayıcı trafiğini yeniden yönlendirebilecek bir uzantı içerdiğinde başlatmadan önce uyarır."
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
@@ -345,6 +348,10 @@
|
||||
"nameDesc": "Ad (Z–A)",
|
||||
"newest": "Önce en yeni",
|
||||
"oldest": "Önce en eski"
|
||||
},
|
||||
"remote": {
|
||||
"runningTooltip": "Uzak bir makinede çalışıyor. Profili buraya geri getirmek için durdurun.",
|
||||
"pendingSyncTooltip": "Uzak oturumun değiştirdikleri indiriliyor. Bittiğinde yeniden kullanılabilir olacak."
|
||||
}
|
||||
},
|
||||
"createProfile": {
|
||||
@@ -461,7 +468,7 @@
|
||||
"ssCipherRequired": "Shadowsocks için şifreleme algoritması ve parola zorunludur",
|
||||
"selectType": "Proxy türünü seçin",
|
||||
"saveFailed": "Proxy kaydedilemedi: {{error}}",
|
||||
"vlessType": "VLESS · Vision · REALITY",
|
||||
"vlessType": "VLESS",
|
||||
"vlessUri": "VLESS URI'si",
|
||||
"vlessUriPlaceholder": "vless://…",
|
||||
"vlessUriHint": "XTLS Vision ve REALITY gerektirir.",
|
||||
@@ -1859,6 +1866,8 @@
|
||||
"remoteRateLimited": "Çok fazla istek. Biraz bekleyip tekrar deneyin.",
|
||||
"remoteNoCapacity": "Şu anda boş uzak makine yok. Birkaç dakika sonra tekrar deneyin.",
|
||||
"remoteNotEntitled": "Planınız uzaktan çalıştırmayı içermiyor.",
|
||||
"remoteInteractiveNotEntitled": "Planınızdaki uzak saatler yalnızca Cookie Bot için geçerlidir, elle kullanılan uzak oturumlar için değil.",
|
||||
"remoteRequiresRemoteExitNode": "Bu profilin proxy'si yalnızca bu bilgisayarda çalışır (örneğin 127.0.0.1 veya yerel ağ adresi). Uzak oturumlar bizim sunucularımızda çalıştığı için genel bir adrese sahip bir proxy gerekir.",
|
||||
"remoteSessionRefused": "Uzak makine bu oturumu reddetti.",
|
||||
"remoteSessionNotFound": "Bu uzak oturum artık mevcut değil.",
|
||||
"remoteSessionConflict": "Bu profil başka bir yerde zaten açık.",
|
||||
@@ -1879,8 +1888,33 @@
|
||||
"cookieBotUnknownPlatform": "Bu profilde kayıtlı bir işletim sistemi yok, bu yüzden bir makineyle eşleştirilemiyor.",
|
||||
"cookieBotUnsupportedPlatform": "Cookie Bot, {{platform}} profillerini çalıştıramaz. Yalnızca Windows ve macOS profilleri desteklenir.",
|
||||
"cookieBotRequiresExitNode": "Önce bir proxy veya VPN ekleyin. Aksi hâlde çalışma bir veri merkezi adresinden çıkar ve bu, profilin kimliğine zarar verir.",
|
||||
"cookieBotRequiresRemoteExitNode": "Bu profilin proxy'si yalnızca bu bilgisayarda çalışır (örneğin 127.0.0.1 veya yerel ağ adresi). Cookie Bot bizim sunucularımızda çalıştığı için genel bir adrese sahip bir proxy gerekir.",
|
||||
"unknownCode": "Bir sorun oluştu: {{code}}",
|
||||
"cookieBotTouchFingerprintUnsupported": "Bu profil dokunmatik bir cihaz olduğunu bildiriyor ve bot bunu süremez. Masaüstü parmak izi kullanın."
|
||||
"cookieBotTouchFingerprintUnsupported": "Bu profil dokunmatik bir cihaz olduğunu bildiriyor ve bot bunu süremez. Masaüstü parmak izi kullanın.",
|
||||
"profileRunningRemotely": "Bu profil uzak bir makinede çalışıyor. Önce uzak oturumu durdurun.",
|
||||
"profileRemoteSyncPending": "Uzak oturum az önce bitti. Bu profili burada açmadan önce değişikliklerinin inmesi bekleniyor.",
|
||||
"profileLockedByMember": "Bu profil {{email}} tarafından kullanılıyor.",
|
||||
"profileLockedElsewhere": "Bu profil başka bir cihazda kullanılıyor.",
|
||||
"profileLockUnavailable": "Bu profilin başka bir yerde kullanılıp kullanılmadığı denetlenemedi. Bağlantınızı kontrol edip yeniden deneyin.",
|
||||
"fingerprintExitMismatch": "Proxy çıkış düğümü bu profilin parmak iziyle eşleşmiyor.",
|
||||
"launchConsentExpired": "Bu onay artık geçerli değil. Yeniden başlatmayı deneyin.",
|
||||
"vpnWorkerStartFailed": "VPN bağlantısı başlatılamadı: {{detail}}",
|
||||
"exitProbeFailed": "Konumunu denetlemek için proxy çıkış düğümüne ulaşılamadı.",
|
||||
"vlessUnsupported": {
|
||||
"security": "Bu VLESS sunucusu REALITY kullanmıyor. Donut yalnızca REALITY ile VLESS'i destekler.",
|
||||
"flow": "Bu VLESS sunucusu, Donut'ın gerektirdiği XTLS Vision akışını kullanmıyor.",
|
||||
"transport": "Donut yalnızca düz TCP üzerinden VLESS'i destekler — bu sunucu farklı bir taşıma (WebSocket veya gRPC gibi) kullanıyor.",
|
||||
"encryption": "Bu VLESS sunucusu, Donut'ın desteklemediği bir şifreleme kullanıyor.",
|
||||
"headerType": "Bu VLESS sunucusu, Donut'ın desteklemediği bir başlık gizlemesi kullanıyor.",
|
||||
"fingerprint": "Bu VLESS URI'si, Donut'ın desteklemediği bir TLS parmak izi istiyor.",
|
||||
"sni": "VLESS URI'sinde REALITY için gereken SNI (sni) eksik.",
|
||||
"publicKey": "VLESS URI'sinde REALITY genel anahtarı (pbk) eksik.",
|
||||
"scheme": "Bu bir VLESS bağlantısı değil. vless:// ile başlamalı.",
|
||||
"parameter": "VLESS URI'si, Donut'ın desteklemediği bir seçenek içeriyor.",
|
||||
"malformed": "VLESS URI'si geçersiz."
|
||||
},
|
||||
"camoufoxRemoved": "Camoufox artık desteklenmiyor. Bu profili Wayfern ile yeniden oluşturun.",
|
||||
"noE2ePasswordSet": "Uçtan uca şifreleme parolası ayarlanmamış. Şifreli veriyi eşitlemeden önce bir parola belirleyin."
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "Profiller",
|
||||
@@ -2126,14 +2160,9 @@
|
||||
"description": "Tarayıcı kapanırken çerezleri, geçmişi ve önbelleği siler. Uzantılar ve yer imleri korunur."
|
||||
},
|
||||
"consistencyWarning": {
|
||||
"title": "Parmak izi uyuşmazlığı",
|
||||
"intro": "\"{{name}}\" için proxy çıkışı bu profilin parmak iziyle eşleşmiyor:",
|
||||
"timezoneTitle": "Saat dilimi uyuşmazlığı",
|
||||
"timezoneDetail": "Çıkış düğümü {{exit}} konumunda, ancak parmak izi {{fingerprint}} bildiriyor.",
|
||||
"languageTitle": "Dil uyuşmazlığı",
|
||||
"languageDetail": "Çıkış ülkesi {{country}}, ancak parmak izi dili {{fingerprint}}.",
|
||||
"explainer": "Çıkış IP'nizle uyuşmayan bir saat dilimi veya dil, gerçek cihazınız hiç sızdırmasa bile güçlü bir anti-bot sinyalidir. Şüpheli muameleyi azaltmak için parmak izini proxy konumuyla hizalayın.",
|
||||
"dontWarnAgain": "Bu profil için bir daha uyarma",
|
||||
"matchToProxy": "Parmak izini proxy'ye eşle",
|
||||
"matching": "Eşleniyor…",
|
||||
"matchSuccess": "Parmak izi proxy'ye uyacak şekilde güncellendi. Uygulamak için profili yeniden başlatın."
|
||||
@@ -2331,13 +2360,48 @@
|
||||
"confirmBulkButton_other": "{{count}} profille devam et",
|
||||
"sitesRequired": "En az bir site ekleyin.",
|
||||
"addSitesFirst": "Önce bir site ekleyin",
|
||||
"presetsMissing": "Derinlik ön ayarları kullanılamıyor"
|
||||
"presetsMissing": "Derinlik ön ayarları kullanılamıyor",
|
||||
"sourceOwn": "Kendi listem",
|
||||
"sourceCurated": "Seçilmiş",
|
||||
"sourceSaved": "Kayıtlı",
|
||||
"curatedEmpty": "Şu anda kullanılabilir seçilmiş liste yok.",
|
||||
"curatedNote": "Güncel tuttuğumuz seçilmiş bir liste. Her profil kendi örneklemini alır, bu yüzden iki profil aynı kümeyi gezmez — listenin kendisinin bir imzaya dönüşmesini engelleyen şey budur. Adresler bizim tarafımızda kalır.",
|
||||
"savedEmpty": "Henüz kaydedilmiş bir şey yok. Kendi listem sekmesinde bir liste yazıp oradan kaydedin.",
|
||||
"savedNote": "Siteler programı kaydettiğinizde programa kopyalanır; listeyi sonradan düzenlemeniz mevcut programları etkilemez.",
|
||||
"savedUnavailable": "Kayıtlı listeleriniz yüklenemedi.",
|
||||
"saveAsList": "Liste olarak kaydet",
|
||||
"listNamePlaceholder": "Bu listeye bir ad verin",
|
||||
"listSaved": "Liste kaydedildi",
|
||||
"listRenamed": "Liste yeniden adlandırıldı",
|
||||
"listDeleted": "Liste silindi",
|
||||
"listRename": "Yeniden adlandır",
|
||||
"listDeleteConfirm": "Silinsin mi?",
|
||||
"templateMissing": "O kayıtlı liste artık yok. Başka birini seçin.",
|
||||
"templateNameTaken": "Bu ada sahip bir listeniz zaten var.",
|
||||
"templateNameInvalid": "Listeye en fazla {{max}} karakterlik bir ad verin.",
|
||||
"calendarLabel": "Ne zaman çalışır",
|
||||
"daysLabel": "Haftanın günleri",
|
||||
"addSlot": "Saat ekle",
|
||||
"slotsFull": "En fazla {{max}} başlangıç saati.",
|
||||
"removeSlot": "Bu saati kaldır",
|
||||
"pickListFirst": "Önce bir liste seçin",
|
||||
"finishCalendarFirst": "Önce programı tamamlayın",
|
||||
"duplicateSlot": "İki satırın günleri ve saati aynı",
|
||||
"listSites_one": "{{count}} site",
|
||||
"listSites_other": "{{count}} site",
|
||||
"summarySlots_one": "Haftada {{count}} kez, her seferinde en fazla {{minutes}} dk çalışır.",
|
||||
"summarySlots_other": "Haftada {{count}} kez, her seferinde en fazla {{minutes}} dk çalışır.",
|
||||
"templateNameInvalidNoMax": "Bu ad kayıtlı bir liste için kullanılamaz."
|
||||
},
|
||||
"preset": {
|
||||
"light": "Hafif",
|
||||
"balanced": "Standart",
|
||||
"deep": "Derin"
|
||||
},
|
||||
"template": {
|
||||
"lowIntentPurchaser": "Düşük niyetli alıcı",
|
||||
"lowIntentPurchaserHint": "Profili fiyata duyarlı bir alıcı olarak konumlar: karşılaştırma, kupon, nakit iade ve ikinci el siteleri; mağazalara doğrudan değil toplayıcılar üzerinden ulaşır."
|
||||
},
|
||||
"preflight": {
|
||||
"ineligible_one": "{{count}} profil uzaktan çalıştırılamıyor",
|
||||
"ineligible_other": "{{count}} profil uzaktan çalıştırılamıyor",
|
||||
@@ -2419,5 +2483,31 @@
|
||||
"cancelledByUser": "Elle durduruldu",
|
||||
"unknown": "Bilinmeyen neden ({{code}})"
|
||||
}
|
||||
},
|
||||
"prelaunchGate": {
|
||||
"titleBlocked": "Başlatma engellendi",
|
||||
"titleWarning": "Başlatmadan önce",
|
||||
"intro": "Tarayıcıyı başlatmadan önce \"{{name}}\" ile ilgili şu sorunları inceleyin.",
|
||||
"fingerprintHeading": "Proxy çıkışı parmak iziyle eşleşmiyor",
|
||||
"vpnExtensionHeading": "VPN uzantısı algılandı",
|
||||
"vpnExtensionIntro": "Bu profildeki, tarayıcı trafiğini yeniden yönlendirebilecek uzantılar:",
|
||||
"vpnExtensionConfirmed": "Proxy'yi değiştirebilir",
|
||||
"vpnExtensionLikely": "Proxy'yi değiştirebilir (olası)",
|
||||
"vpnExtensionExplainer": "Bunlardan biri trafiğinizi başka bir yere yönlendirirse, tarayıcının gerçek konumu artık bu profilin oluşturulduğu saat dilimi, dil ve coğrafi konumla eşleşmez ve Donut bunu dışarıdan algılayamaz.",
|
||||
"sourceDonut": "Donut tarafından yönetiliyor",
|
||||
"sourceBrowser": "Profile yüklenmiş",
|
||||
"measurementUnreliable": "Bir VPN uzantısı proxy'yi geçersiz kılabileceğinden, çıkış kontrolü tarayıcının gerçekte kullandığı rotayı yansıtmayabilir.",
|
||||
"scanIncompleteEncrypted": "Bu profil şifreli olduğundan yalnızca Donut tarafından yönetilen uzantılar denetlenebildi.",
|
||||
"scanIncompleteEphemeral": "Bu profilde henüz veri olmadığından yalnızca Donut tarafından yönetilen uzantılar denetlenebildi.",
|
||||
"scanIncompletePartial": "Uzantı taraması yarıda kesildi, bu nedenle bazıları listelenmemiş olabilir.",
|
||||
"probePending": "Proxy çıkışı henüz ölçülmedi. Donut başlatma sırasında kontrol edecek ve eşleşmezse duracak.",
|
||||
"launchAnyway": "Yine de başlat",
|
||||
"dontBlockAgain": "Bu tam uyuşmazlık için bir daha engelleme",
|
||||
"dontWarnExtensions": "Bu uzantılar için bir daha uyarma",
|
||||
"applyToRemaining": "Bu seçimi kalan profillere uygula",
|
||||
"cancelledSummary": "{{total}} başlatmadan {{cancelled}} tanesi iptal edildi",
|
||||
"cancelled": "Başlatma iptal edildi",
|
||||
"vpnExtensionEntry": " {{version}} — {{capability}}, {{source}}",
|
||||
"scanIncompleteMissing": "Bu profil henüz başlatılmadığından yalnızca Donut tarafından yönetilen uzantılar denetlenebildi."
|
||||
}
|
||||
}
|
||||
|
||||
+102
-12
@@ -92,7 +92,8 @@
|
||||
"window": {
|
||||
"minimize": "Thu nhỏ",
|
||||
"maximize": "Phóng to",
|
||||
"restore": "Khôi phục"
|
||||
"restore": "Khôi phục",
|
||||
"close": "Đóng cửa sổ"
|
||||
},
|
||||
"commandPalette": {
|
||||
"title": "Bảng lệnh",
|
||||
@@ -199,11 +200,13 @@
|
||||
"keepDecryptedProfilesInRam": "Giữ hồ sơ đã giải mã trong RAM",
|
||||
"keepDecryptedProfilesInRamDescription": "Giữ bản sao đã giải mã trong RAM của hồ sơ được bảo vệ bằng mật khẩu giữa các lần khởi chạy để khởi động nhanh hơn. Bản sao trên ổ đĩa vẫn được mã hóa.",
|
||||
"privacy": {
|
||||
"consistencyWarning": "Cảnh báo nhất quán vân tay",
|
||||
"consistencyWarningDescription": "Cảnh báo khi khởi chạy nếu múi giờ hoặc ngôn ngữ của hồ sơ không khớp với nút thoát proxy.",
|
||||
"consistencyWarning": "Chặn khi dấu vân tay không khớp",
|
||||
"consistencyWarningDescription": "Ngăn trình duyệt khởi động khi múi giờ hoặc ngôn ngữ của hồ sơ không khớp với nút thoát của proxy. Bạn vẫn có thể chọn khởi chạy.",
|
||||
"clearTraffic": "Xóa toàn bộ lịch sử lưu lượng",
|
||||
"clearTrafficDescription": "Xóa an toàn số liệu thống kê lưu lượng đã ghi của mọi hồ sơ.",
|
||||
"clearTrafficSuccess": "Đã xóa lịch sử lưu lượng"
|
||||
"clearTrafficSuccess": "Đã xóa lịch sử lưu lượng",
|
||||
"vpnExtensionWarning": "Cảnh báo tiện ích VPN",
|
||||
"vpnExtensionWarningDescription": "Cảnh báo trước khi khởi chạy khi hồ sơ có tiện ích có thể định tuyến lại lưu lượng của trình duyệt."
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
@@ -345,6 +348,10 @@
|
||||
"nameDesc": "Tên (Z–A)",
|
||||
"newest": "Mới nhất trước",
|
||||
"oldest": "Cũ nhất trước"
|
||||
},
|
||||
"remote": {
|
||||
"runningTooltip": "Đang chạy trên máy từ xa. Dừng lại để đưa hồ sơ về đây.",
|
||||
"pendingSyncTooltip": "Đang tải về những gì phiên từ xa đã thay đổi. Sẽ dùng lại được khi hoàn tất."
|
||||
}
|
||||
},
|
||||
"createProfile": {
|
||||
@@ -461,7 +468,7 @@
|
||||
"ssCipherRequired": "Cipher và mật khẩu là bắt buộc cho Shadowsocks",
|
||||
"selectType": "Chọn loại proxy",
|
||||
"saveFailed": "Lưu proxy thất bại: {{error}}",
|
||||
"vlessType": "VLESS · Vision · REALITY",
|
||||
"vlessType": "VLESS",
|
||||
"vlessUri": "URI VLESS",
|
||||
"vlessUriPlaceholder": "vless://…",
|
||||
"vlessUriHint": "Yêu cầu XTLS Vision và REALITY.",
|
||||
@@ -1859,6 +1866,8 @@
|
||||
"remoteRateLimited": "Quá nhiều yêu cầu. Hãy đợi một lát rồi thử lại.",
|
||||
"remoteNoCapacity": "Hiện không có máy từ xa nào rảnh. Hãy thử lại sau vài phút.",
|
||||
"remoteNotEntitled": "Gói của bạn không bao gồm chạy từ xa.",
|
||||
"remoteInteractiveNotEntitled": "Gói của bạn chỉ bao gồm giờ từ xa cho Cookie Bot, không dùng cho phiên từ xa thao tác trực tiếp.",
|
||||
"remoteRequiresRemoteExitNode": "Proxy của hồ sơ này chỉ hoạt động trên máy tính này (ví dụ 127.0.0.1 hoặc địa chỉ mạng nội bộ). Phiên từ xa chạy trên máy chủ của chúng tôi nên cần proxy có địa chỉ công khai.",
|
||||
"remoteSessionRefused": "Máy từ xa đã từ chối phiên này.",
|
||||
"remoteSessionNotFound": "Phiên từ xa đó không còn tồn tại.",
|
||||
"remoteSessionConflict": "Hồ sơ này đang được mở ở nơi khác.",
|
||||
@@ -1879,8 +1888,33 @@
|
||||
"cookieBotUnknownPlatform": "Hồ sơ này chưa ghi nhận hệ điều hành nên không thể ghép với máy nào.",
|
||||
"cookieBotUnsupportedPlatform": "Cookie Bot không chạy được hồ sơ {{platform}}. Chỉ hỗ trợ hồ sơ Windows và macOS.",
|
||||
"cookieBotRequiresExitNode": "Hãy gán proxy hoặc VPN trước. Nếu không, lần chạy sẽ đi ra từ địa chỉ trung tâm dữ liệu, gây hại cho danh tính hồ sơ.",
|
||||
"cookieBotRequiresRemoteExitNode": "Proxy của hồ sơ này chỉ hoạt động trên máy tính này (ví dụ 127.0.0.1 hoặc địa chỉ mạng nội bộ). Cookie Bot chạy trên máy chủ của chúng tôi nên cần proxy có địa chỉ công khai.",
|
||||
"unknownCode": "Đã xảy ra lỗi: {{code}}",
|
||||
"cookieBotTouchFingerprintUnsupported": "Hồ sơ này khai báo là thiết bị cảm ứng, bot không điều khiển được. Hãy dùng vân tay máy tính để bàn."
|
||||
"cookieBotTouchFingerprintUnsupported": "Hồ sơ này khai báo là thiết bị cảm ứng, bot không điều khiển được. Hãy dùng vân tay máy tính để bàn.",
|
||||
"profileRunningRemotely": "Hồ sơ này đang chạy trên máy từ xa. Hãy dừng phiên từ xa trước.",
|
||||
"profileRemoteSyncPending": "Một phiên từ xa vừa kết thúc. Đang chờ tải các thay đổi về trước khi mở hồ sơ này tại đây.",
|
||||
"profileLockedByMember": "Hồ sơ này đang được {{email}} sử dụng.",
|
||||
"profileLockedElsewhere": "Hồ sơ này đang được sử dụng trên thiết bị khác.",
|
||||
"profileLockUnavailable": "Không thể kiểm tra hồ sơ này có đang được dùng ở nơi khác hay không. Hãy kiểm tra kết nối và thử lại.",
|
||||
"fingerprintExitMismatch": "Nút thoát của proxy không khớp với dấu vân tay của hồ sơ này.",
|
||||
"launchConsentExpired": "Xác nhận đó không còn hiệu lực. Hãy thử khởi chạy lại.",
|
||||
"vpnWorkerStartFailed": "Không thể khởi động kết nối VPN: {{detail}}",
|
||||
"exitProbeFailed": "Không thể kết nối tới nút thoát của proxy để kiểm tra vị trí.",
|
||||
"vlessUnsupported": {
|
||||
"security": "Máy chủ VLESS này không dùng REALITY. Donut chỉ hỗ trợ VLESS kèm REALITY.",
|
||||
"flow": "Máy chủ VLESS này không dùng luồng XTLS Vision mà Donut yêu cầu.",
|
||||
"transport": "Donut chỉ hỗ trợ VLESS trên TCP thuần — máy chủ này dùng phương thức truyền khác (như WebSocket hoặc gRPC).",
|
||||
"encryption": "Máy chủ VLESS này dùng thiết lập mã hóa mà Donut không hỗ trợ.",
|
||||
"headerType": "Máy chủ VLESS này dùng cách che giấu tiêu đề mà Donut không hỗ trợ.",
|
||||
"fingerprint": "URI VLESS này yêu cầu một dấu vân tay TLS mà Donut không hỗ trợ.",
|
||||
"sni": "URI VLESS thiếu SNI (sni) cần cho REALITY.",
|
||||
"publicKey": "URI VLESS thiếu khóa công khai REALITY (pbk).",
|
||||
"scheme": "Đây không phải liên kết VLESS. Nó phải bắt đầu bằng vless://.",
|
||||
"parameter": "URI VLESS chứa một tùy chọn mà Donut không hỗ trợ.",
|
||||
"malformed": "URI VLESS không hợp lệ."
|
||||
},
|
||||
"camoufoxRemoved": "Camoufox không còn được hỗ trợ. Hãy tạo lại hồ sơ này bằng Wayfern.",
|
||||
"noE2ePasswordSet": "Chưa đặt mật khẩu mã hóa đầu cuối. Hãy đặt trước khi đồng bộ dữ liệu đã mã hóa."
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "Profile",
|
||||
@@ -2126,14 +2160,9 @@
|
||||
"description": "Xóa cookie, lịch sử và bộ nhớ đệm khi trình duyệt đóng. Tiện ích mở rộng và dấu trang được giữ lại."
|
||||
},
|
||||
"consistencyWarning": {
|
||||
"title": "Vân tay không khớp",
|
||||
"intro": "Điểm thoát proxy của \"{{name}}\" không khớp với vân tay của hồ sơ này:",
|
||||
"timezoneTitle": "Múi giờ không khớp",
|
||||
"timezoneDetail": "Nút thoát nằm ở {{exit}} nhưng vân tay báo là {{fingerprint}}.",
|
||||
"languageTitle": "Ngôn ngữ không khớp",
|
||||
"languageDetail": "Quốc gia thoát là {{country}} nhưng ngôn ngữ của vân tay là {{fingerprint}}.",
|
||||
"explainer": "Múi giờ hoặc ngôn ngữ không khớp với IP thoát là một tín hiệu chống bot rất mạnh, dù thiết bị thật của bạn không bao giờ bị lộ. Hãy căn chỉnh vân tay theo vị trí proxy để giảm bị đối xử khắt khe.",
|
||||
"dontWarnAgain": "Không cảnh báo lại cho hồ sơ này",
|
||||
"matchToProxy": "Khớp vân tay với proxy",
|
||||
"matching": "Đang khớp…",
|
||||
"matchSuccess": "Đã cập nhật vân tay để khớp với proxy. Khởi động lại hồ sơ để áp dụng."
|
||||
@@ -2331,13 +2360,48 @@
|
||||
"confirmBulkButton_other": "Tiếp tục với {{count}} hồ sơ",
|
||||
"sitesRequired": "Hãy thêm ít nhất một trang.",
|
||||
"addSitesFirst": "Hãy thêm một trang trước",
|
||||
"presetsMissing": "Không có cài đặt sẵn về độ sâu"
|
||||
"presetsMissing": "Không có cài đặt sẵn về độ sâu",
|
||||
"sourceOwn": "Danh sách của tôi",
|
||||
"sourceCurated": "Tuyển chọn",
|
||||
"sourceSaved": "Đã lưu",
|
||||
"curatedEmpty": "Hiện không có danh sách tuyển chọn nào.",
|
||||
"curatedNote": "Danh sách tuyển chọn do chúng tôi cập nhật. Mỗi hồ sơ lấy một mẫu riêng từ đó, nên không hồ sơ nào duyệt cùng một tập trang — chính điều này khiến danh sách không trở thành dấu hiệu nhận dạng. Các địa chỉ vẫn nằm ở phía chúng tôi.",
|
||||
"savedEmpty": "Chưa lưu gì cả. Hãy nhập danh sách ở mục Danh sách của tôi rồi lưu từ đó.",
|
||||
"savedNote": "Các trang được sao chép vào lịch khi bạn lưu, nên sửa danh sách sau này không ảnh hưởng tới các lịch đã có.",
|
||||
"savedUnavailable": "Không tải được các danh sách đã lưu của bạn.",
|
||||
"saveAsList": "Lưu thành danh sách",
|
||||
"listNamePlaceholder": "Đặt tên cho danh sách",
|
||||
"listSaved": "Đã lưu danh sách",
|
||||
"listRenamed": "Đã đổi tên danh sách",
|
||||
"listDeleted": "Đã xóa danh sách",
|
||||
"listRename": "Đổi tên",
|
||||
"listDeleteConfirm": "Xóa?",
|
||||
"templateMissing": "Danh sách đã lưu đó không còn nữa. Hãy chọn danh sách khác.",
|
||||
"templateNameTaken": "Bạn đã có một danh sách trùng tên.",
|
||||
"templateNameInvalid": "Đặt tên danh sách tối đa {{max}} ký tự.",
|
||||
"calendarLabel": "Thời điểm chạy",
|
||||
"daysLabel": "Các ngày trong tuần",
|
||||
"addSlot": "Thêm một giờ",
|
||||
"slotsFull": "Tối đa {{max}} giờ bắt đầu.",
|
||||
"removeSlot": "Bỏ giờ này",
|
||||
"pickListFirst": "Chọn một danh sách trước",
|
||||
"finishCalendarFirst": "Hoàn tất lịch trước",
|
||||
"duplicateSlot": "Hai hàng có cùng ngày và giờ",
|
||||
"listSites_one": "{{count}} trang",
|
||||
"listSites_other": "{{count}} trang",
|
||||
"summarySlots_one": "Chạy {{count}} lần mỗi tuần, tối đa {{minutes}} phút mỗi lần.",
|
||||
"summarySlots_other": "Chạy {{count}} lần mỗi tuần, tối đa {{minutes}} phút mỗi lần.",
|
||||
"templateNameInvalidNoMax": "Không thể dùng tên đó cho danh sách đã lưu."
|
||||
},
|
||||
"preset": {
|
||||
"light": "Nhẹ",
|
||||
"balanced": "Tiêu chuẩn",
|
||||
"deep": "Sâu"
|
||||
},
|
||||
"template": {
|
||||
"lowIntentPurchaser": "Người mua ít ý định",
|
||||
"lowIntentPurchaserHint": "Định vị hồ sơ như một người mua nhạy cảm về giá: các trang so sánh, mã giảm giá, hoàn tiền và mua bán lại, đến với người bán qua trang tổng hợp thay vì trực tiếp."
|
||||
},
|
||||
"preflight": {
|
||||
"ineligible_one": "{{count}} hồ sơ không chạy từ xa được",
|
||||
"ineligible_other": "{{count}} hồ sơ không chạy từ xa được",
|
||||
@@ -2419,5 +2483,31 @@
|
||||
"cancelledByUser": "Đã dừng thủ công",
|
||||
"unknown": "Lý do không xác định ({{code}})"
|
||||
}
|
||||
},
|
||||
"prelaunchGate": {
|
||||
"titleBlocked": "Đã chặn khởi chạy",
|
||||
"titleWarning": "Trước khi khởi chạy",
|
||||
"intro": "Hãy xem lại các vấn đề của \"{{name}}\" trước khi khởi động trình duyệt.",
|
||||
"fingerprintHeading": "Điểm ra của proxy không khớp với dấu vân tay",
|
||||
"vpnExtensionHeading": "Đã phát hiện tiện ích VPN",
|
||||
"vpnExtensionIntro": "Các tiện ích trong hồ sơ này có thể định tuyến lại lưu lượng của trình duyệt:",
|
||||
"vpnExtensionConfirmed": "Có thể thay đổi proxy",
|
||||
"vpnExtensionLikely": "Có khả năng thay đổi proxy",
|
||||
"vpnExtensionExplainer": "Nếu một trong số đó chuyển lưu lượng của bạn đi nơi khác, vị trí thực của trình duyệt sẽ không còn khớp với múi giờ, ngôn ngữ và vị trí địa lý mà hồ sơ này được tạo ra, và Donut không thể phát hiện điều đó từ bên ngoài.",
|
||||
"sourceDonut": "Do Donut quản lý",
|
||||
"sourceBrowser": "Đã cài trong hồ sơ",
|
||||
"measurementUnreliable": "Vì tiện ích VPN có thể ghi đè proxy, kết quả kiểm tra điểm ra có thể không phản ánh tuyến đường mà trình duyệt thực sự dùng.",
|
||||
"scanIncompleteEncrypted": "Hồ sơ này được mã hóa nên chỉ có thể kiểm tra các tiện ích do Donut quản lý.",
|
||||
"scanIncompleteEphemeral": "Hồ sơ này chưa có dữ liệu nên chỉ có thể kiểm tra các tiện ích do Donut quản lý.",
|
||||
"scanIncompletePartial": "Quá trình quét tiện ích bị ngắt giữa chừng nên có thể thiếu một số tiện ích.",
|
||||
"probePending": "Điểm ra của proxy chưa được đo. Donut sẽ kiểm tra trong lúc khởi động và dừng lại nếu không khớp.",
|
||||
"launchAnyway": "Vẫn khởi chạy",
|
||||
"dontBlockAgain": "Không chặn lại với đúng sai lệch này",
|
||||
"dontWarnExtensions": "Không cảnh báo lại về các tiện ích này",
|
||||
"applyToRemaining": "Áp dụng lựa chọn này cho các hồ sơ còn lại",
|
||||
"cancelledSummary": "Đã hủy {{cancelled}} trên {{total}} lượt khởi chạy",
|
||||
"cancelled": "Đã hủy khởi chạy",
|
||||
"vpnExtensionEntry": " {{version}} — {{capability}}, {{source}}",
|
||||
"scanIncompleteMissing": "Hồ sơ này chưa từng được khởi chạy nên chỉ có thể kiểm tra các tiện ích do Donut quản lý."
|
||||
}
|
||||
}
|
||||
|
||||
+102
-12
@@ -92,7 +92,8 @@
|
||||
"window": {
|
||||
"minimize": "最小化",
|
||||
"maximize": "最大化",
|
||||
"restore": "还原"
|
||||
"restore": "还原",
|
||||
"close": "关闭窗口"
|
||||
},
|
||||
"commandPalette": {
|
||||
"title": "命令面板",
|
||||
@@ -199,11 +200,13 @@
|
||||
"keepDecryptedProfilesInRam": "在内存中保留已解密的配置文件",
|
||||
"keepDecryptedProfilesInRamDescription": "在启动之间保留密码保护配置文件的已解密内存副本,以便更快地启动。无论如何磁盘上的副本始终保持加密。",
|
||||
"privacy": {
|
||||
"consistencyWarning": "指纹一致性警告",
|
||||
"consistencyWarningDescription": "当配置文件的时区或语言与其代理出口节点不匹配时,在启动时发出警告。",
|
||||
"consistencyWarning": "指纹不匹配时阻止启动",
|
||||
"consistencyWarningDescription": "当配置文件的时区或语言与其代理出口节点不一致时,阻止浏览器启动。你仍可以选择启动。",
|
||||
"clearTraffic": "清除所有流量历史",
|
||||
"clearTrafficDescription": "安全清除所有配置文件的已记录流量统计数据。",
|
||||
"clearTrafficSuccess": "流量历史已清除"
|
||||
"clearTrafficSuccess": "流量历史已清除",
|
||||
"vpnExtensionWarning": "VPN 扩展警告",
|
||||
"vpnExtensionWarningDescription": "当配置文件中存在可改变浏览器流量路径的扩展时,在启动前发出警告。"
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
@@ -345,6 +348,10 @@
|
||||
"nameDesc": "名称 (Z–A)",
|
||||
"newest": "最新优先",
|
||||
"oldest": "最早优先"
|
||||
},
|
||||
"remote": {
|
||||
"runningTooltip": "正在远程计算机上运行。停止后配置文件会回到本机。",
|
||||
"pendingSyncTooltip": "正在下载远程会话所做的更改。完成后即可再次使用。"
|
||||
}
|
||||
},
|
||||
"createProfile": {
|
||||
@@ -461,7 +468,7 @@
|
||||
"ssCipherRequired": "Shadowsocks 需要密码学和密码",
|
||||
"selectType": "选择代理类型",
|
||||
"saveFailed": "保存代理失败: {{error}}",
|
||||
"vlessType": "VLESS · Vision · REALITY",
|
||||
"vlessType": "VLESS",
|
||||
"vlessUri": "VLESS URI",
|
||||
"vlessUriPlaceholder": "vless://…",
|
||||
"vlessUriHint": "需要 XTLS Vision 和 REALITY。",
|
||||
@@ -1859,6 +1866,8 @@
|
||||
"remoteRateLimited": "请求过于频繁。请稍候再试。",
|
||||
"remoteNoCapacity": "当前没有空闲的远程机器。请几分钟后再试。",
|
||||
"remoteNotEntitled": "你的套餐不包含远程运行。",
|
||||
"remoteInteractiveNotEntitled": "您的套餐中的远程时长仅供 Cookie Bot 使用,不能用于手动远程会话。",
|
||||
"remoteRequiresRemoteExitNode": "此配置文件的代理仅在本机可用(例如 127.0.0.1 或局域网地址)。远程会话在我们的主机上运行,因此需要具有公网地址的代理。",
|
||||
"remoteSessionRefused": "远程机器拒绝了此会话。",
|
||||
"remoteSessionNotFound": "该远程会话已不存在。",
|
||||
"remoteSessionConflict": "此配置文件已在别处打开。",
|
||||
@@ -1879,8 +1888,33 @@
|
||||
"cookieBotUnknownPlatform": "此配置文件没有记录操作系统,无法匹配到机器。",
|
||||
"cookieBotUnsupportedPlatform": "Cookie Bot 无法运行 {{platform}} 配置文件。仅支持 Windows 和 macOS 配置文件。",
|
||||
"cookieBotRequiresExitNode": "请先绑定代理或 VPN。否则运行会从数据中心地址发出,损害配置文件的身份。",
|
||||
"cookieBotRequiresRemoteExitNode": "此配置文件的代理仅在本机可用(例如 127.0.0.1 或局域网地址)。Cookie Bot 在我们的主机上运行,因此需要具有公网地址的代理。",
|
||||
"unknownCode": "出现问题: {{code}}",
|
||||
"cookieBotTouchFingerprintUnsupported": "该配置文件声称是触摸设备,机器人无法操作。请使用桌面端指纹。"
|
||||
"cookieBotTouchFingerprintUnsupported": "该配置文件声称是触摸设备,机器人无法操作。请使用桌面端指纹。",
|
||||
"profileRunningRemotely": "该配置文件正在远程计算机上运行。请先停止远程会话。",
|
||||
"profileRemoteSyncPending": "远程会话刚刚结束。正在等待其更改下载完成后才能在此打开该配置文件。",
|
||||
"profileLockedByMember": "该配置文件正在被 {{email}} 使用。",
|
||||
"profileLockedElsewhere": "该配置文件正在另一台设备上使用。",
|
||||
"profileLockUnavailable": "无法检查该配置文件是否正在别处使用。请检查网络连接后重试。",
|
||||
"fingerprintExitMismatch": "代理出口节点与此配置文件的指纹不匹配。",
|
||||
"launchConsentExpired": "该确认已失效。请重新启动。",
|
||||
"vpnWorkerStartFailed": "无法启动 VPN 连接:{{detail}}",
|
||||
"exitProbeFailed": "无法连接代理出口节点以检查其位置。",
|
||||
"vlessUnsupported": {
|
||||
"security": "此 VLESS 服务器未使用 REALITY。Donut 仅支持搭配 REALITY 的 VLESS。",
|
||||
"flow": "此 VLESS 服务器未使用 Donut 所需的 XTLS Vision 流控。",
|
||||
"transport": "Donut 仅支持基于普通 TCP 的 VLESS —— 此服务器使用了其他传输方式(如 WebSocket 或 gRPC)。",
|
||||
"encryption": "此 VLESS 服务器使用了 Donut 不支持的加密设置。",
|
||||
"headerType": "此 VLESS 服务器使用了 Donut 不支持的头部混淆。",
|
||||
"fingerprint": "此 VLESS URI 请求了 Donut 不支持的 TLS 指纹。",
|
||||
"sni": "VLESS URI 缺少 REALITY 所需的 SNI(sni)。",
|
||||
"publicKey": "VLESS URI 缺少 REALITY 公钥(pbk)。",
|
||||
"scheme": "这不是 VLESS 链接,必须以 vless:// 开头。",
|
||||
"parameter": "VLESS URI 含有 Donut 不支持的选项。",
|
||||
"malformed": "VLESS URI 无效。"
|
||||
},
|
||||
"camoufoxRemoved": "Camoufox 已不再受支持。请使用 Wayfern 重新创建此配置文件。",
|
||||
"noE2ePasswordSet": "尚未设置端到端加密密码。请先设置后再同步加密数据。"
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "配置文件",
|
||||
@@ -2126,14 +2160,9 @@
|
||||
"description": "浏览器关闭时清除 Cookie、历史记录和缓存。扩展和书签将被保留。"
|
||||
},
|
||||
"consistencyWarning": {
|
||||
"title": "指纹不匹配",
|
||||
"intro": "「{{name}}」的代理出口与此配置文件的指纹不匹配:",
|
||||
"timezoneTitle": "时区不匹配",
|
||||
"timezoneDetail": "出口节点位于 {{exit}},但指纹报告为 {{fingerprint}}。",
|
||||
"languageTitle": "语言不匹配",
|
||||
"languageDetail": "出口国家/地区为 {{country}},但指纹语言为 {{fingerprint}}。",
|
||||
"explainer": "时区或语言与出口 IP 不一致是强烈的反机器人信号,即使您的真实设备信息从未泄露。请让指纹与代理位置保持一致,以减少被针对的风险。",
|
||||
"dontWarnAgain": "不再为此配置文件发出警告",
|
||||
"matchToProxy": "将指纹匹配到代理",
|
||||
"matching": "匹配中…",
|
||||
"matchSuccess": "指纹已更新以匹配代理。重新启动配置文件以生效。"
|
||||
@@ -2331,13 +2360,48 @@
|
||||
"confirmBulkButton_other": "继续({{count}} 个配置文件)",
|
||||
"sitesRequired": "请至少添加一个网站。",
|
||||
"addSitesFirst": "请先添加网站",
|
||||
"presetsMissing": "深度预设不可用"
|
||||
"presetsMissing": "深度预设不可用",
|
||||
"sourceOwn": "我的列表",
|
||||
"sourceCurated": "精选",
|
||||
"sourceSaved": "已保存",
|
||||
"curatedEmpty": "目前没有可用的精选列表。",
|
||||
"curatedNote": "由我们持续维护的精选列表。每个配置文件都会从中抽取各自的样本,因此不会有两个配置文件浏览同一组网站——这正是让列表本身不会变成特征的原因。地址只保留在我们这边。",
|
||||
"savedEmpty": "还没有保存任何内容。请在“我的列表”中输入列表,然后从那里保存。",
|
||||
"savedNote": "保存计划时会把网站复制到计划中,因此以后编辑列表不会影响已有的计划。",
|
||||
"savedUnavailable": "无法加载你保存的列表。",
|
||||
"saveAsList": "保存为列表",
|
||||
"listNamePlaceholder": "为该列表命名",
|
||||
"listSaved": "已保存列表",
|
||||
"listRenamed": "已重命名列表",
|
||||
"listDeleted": "已删除列表",
|
||||
"listRename": "重命名",
|
||||
"listDeleteConfirm": "删除?",
|
||||
"templateMissing": "该保存的列表已不存在。请另选一个。",
|
||||
"templateNameTaken": "你已经有同名的列表。",
|
||||
"templateNameInvalid": "列表名称不超过 {{max}} 个字符。",
|
||||
"calendarLabel": "运行时间",
|
||||
"daysLabel": "星期",
|
||||
"addSlot": "添加时间",
|
||||
"slotsFull": "最多 {{max}} 个开始时间。",
|
||||
"removeSlot": "移除此时间",
|
||||
"pickListFirst": "请先选择列表",
|
||||
"finishCalendarFirst": "请先完成计划",
|
||||
"duplicateSlot": "有两行的星期和时间相同",
|
||||
"listSites_one": "{{count}} 个网站",
|
||||
"listSites_other": "{{count}} 个网站",
|
||||
"summarySlots_one": "每周运行 {{count}} 次,每次最多 {{minutes}} 分钟。",
|
||||
"summarySlots_other": "每周运行 {{count}} 次,每次最多 {{minutes}} 分钟。",
|
||||
"templateNameInvalidNoMax": "该名称不能用于已保存的列表。"
|
||||
},
|
||||
"preset": {
|
||||
"light": "轻度",
|
||||
"balanced": "标准",
|
||||
"deep": "深度"
|
||||
},
|
||||
"template": {
|
||||
"lowIntentPurchaser": "低购买意向买家",
|
||||
"lowIntentPurchaserHint": "把配置文件定位为对价格敏感的买家:比价、优惠券、返现和二手转卖网站,并通过聚合站点而非直接访问零售商。"
|
||||
},
|
||||
"preflight": {
|
||||
"ineligible_one": "{{count}} 个配置文件无法远程运行",
|
||||
"ineligible_other": "{{count}} 个配置文件无法远程运行",
|
||||
@@ -2419,5 +2483,31 @@
|
||||
"cancelledByUser": "已手动停止",
|
||||
"unknown": "未知原因({{code}})"
|
||||
}
|
||||
},
|
||||
"prelaunchGate": {
|
||||
"titleBlocked": "启动已阻止",
|
||||
"titleWarning": "启动前请注意",
|
||||
"intro": "启动浏览器前,请检查“{{name}}”的以下问题。",
|
||||
"fingerprintHeading": "代理出口与指纹不匹配",
|
||||
"vpnExtensionHeading": "检测到 VPN 扩展",
|
||||
"vpnExtensionIntro": "此配置文件中可能改变浏览器流量路径的扩展:",
|
||||
"vpnExtensionConfirmed": "可以更改代理",
|
||||
"vpnExtensionLikely": "可能会更改代理",
|
||||
"vpnExtensionExplainer": "如果其中之一将流量转发到别处,浏览器的真实位置将不再与创建此配置文件时使用的时区、语言和地理位置一致,而 Donut 无法从外部察觉。",
|
||||
"sourceDonut": "由 Donut 管理",
|
||||
"sourceBrowser": "已安装在配置文件中",
|
||||
"measurementUnreliable": "由于 VPN 扩展可以覆盖代理设置,出口检测结果可能并非浏览器实际使用的线路。",
|
||||
"scanIncompleteEncrypted": "此配置文件已加密,因此只能检查由 Donut 管理的扩展。",
|
||||
"scanIncompleteEphemeral": "此配置文件尚无数据,因此只能检查由 Donut 管理的扩展。",
|
||||
"scanIncompletePartial": "扩展扫描被中断,可能有部分扩展未列出。",
|
||||
"probePending": "尚未测量代理出口。Donut 会在启动过程中检查,如不匹配则停止。",
|
||||
"launchAnyway": "仍要启动",
|
||||
"dontBlockAgain": "不再因这一完全相同的不匹配而阻止",
|
||||
"dontWarnExtensions": "不再就这些扩展发出警告",
|
||||
"applyToRemaining": "将此选择应用于其余配置文件",
|
||||
"cancelledSummary": "已取消 {{total}} 次启动中的 {{cancelled}} 次",
|
||||
"cancelled": "已取消启动",
|
||||
"vpnExtensionEntry": " {{version}} — {{capability}}、{{source}}",
|
||||
"scanIncompleteMissing": "此配置文件尚未启动过,因此只能检查由 Donut 管理的扩展。"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,11 +73,22 @@ export type BackendErrorCode =
|
||||
| "REMOTE_RATE_LIMITED"
|
||||
| "REMOTE_NO_CAPACITY"
|
||||
| "REMOTE_NOT_ENTITLED"
|
||||
| "REMOTE_INTERACTIVE_NOT_ENTITLED"
|
||||
// The profile's exit only resolves on this computer, so a leased host cannot
|
||||
// use it. Its own code rather than the Cookie Bot's twin: the two refusals
|
||||
// name different features, and a user told their "Cookie Bot" needs a public
|
||||
// proxy while they were opening a browser by hand cannot act on that.
|
||||
| "REMOTE_REQUIRES_REMOTE_EXIT_NODE"
|
||||
| "REMOTE_SESSION_REFUSED"
|
||||
| "REMOTE_SESSION_NOT_FOUND"
|
||||
| "REMOTE_SESSION_CONFLICT"
|
||||
| "REMOTE_SYNC_IN_PROGRESS"
|
||||
| "REMOTE_HOURS_EXHAUSTED"
|
||||
| "PROFILE_RUNNING_REMOTELY"
|
||||
| "PROFILE_REMOTE_SYNC_PENDING"
|
||||
| "PROFILE_LOCKED_BY_MEMBER"
|
||||
| "PROFILE_LOCKED_ELSEWHERE"
|
||||
| "PROFILE_LOCK_UNAVAILABLE"
|
||||
| "NOT_TEAM_MEMBER"
|
||||
| "COOKIE_BOT_NOT_ENTITLED"
|
||||
| "COOKIE_BOT_NOT_ENROLLED"
|
||||
@@ -93,6 +104,11 @@ export type BackendErrorCode =
|
||||
| "COOKIE_BOT_UNKNOWN_PLATFORM"
|
||||
| "COOKIE_BOT_UNSUPPORTED_PLATFORM"
|
||||
| "COOKIE_BOT_REQUIRES_EXIT_NODE"
|
||||
// The profile HAS an exit, but only this machine can reach it (127.0.0.1, a
|
||||
// LAN address, a `.local` name). Its own code because the fix is different:
|
||||
// "attach a proxy" is unactionable advice for someone whose proxy is plainly
|
||||
// attached.
|
||||
| "COOKIE_BOT_REQUIRES_REMOTE_EXIT_NODE"
|
||||
// The server's own names for two refusals it throws from `putSchedule`,
|
||||
// `updateProfileState` and `runNow`. `COOKIE_BOT_REQUIRES_PROXY` is the
|
||||
// server-side twin of the local `COOKIE_BOT_REQUIRES_EXIT_NODE` precondition;
|
||||
@@ -100,6 +116,12 @@ export type BackendErrorCode =
|
||||
// rendered as the raw machine identifier.
|
||||
| "COOKIE_BOT_REQUIRES_PROXY"
|
||||
| "COOKIE_BOT_TOUCH_FINGERPRINT_UNSUPPORTED"
|
||||
| "FINGERPRINT_EXIT_MISMATCH"
|
||||
| "LAUNCH_CONSENT_EXPIRED"
|
||||
| "VPN_WORKER_START_FAILED"
|
||||
| "EXIT_PROBE_FAILED"
|
||||
| "CAMOUFOX_REMOVED"
|
||||
| "NO_E2E_PASSWORD_SET"
|
||||
| "INTERNAL_ERROR";
|
||||
|
||||
export interface BackendError {
|
||||
@@ -279,8 +301,29 @@ export function translateBackendError(t: TFunction, err: unknown): string {
|
||||
return t("backendErrors.mcpAgentRemoveFailed", {
|
||||
detail: parsed.params?.detail ?? "",
|
||||
});
|
||||
case "VLESS_CONFIG_INVALID":
|
||||
// Donut supports exactly one VLESS shape (REALITY + XTLS Vision over TCP),
|
||||
// so most rejections mean "your server is a kind we do not support", not
|
||||
// "you mistyped". Name the unsupported part instead of implying a typo.
|
||||
case "VLESS_CONFIG_INVALID": {
|
||||
const reason = parsed.params?.reason;
|
||||
const known = [
|
||||
"security",
|
||||
"flow",
|
||||
"transport",
|
||||
"encryption",
|
||||
"headerType",
|
||||
"fingerprint",
|
||||
"sni",
|
||||
"publicKey",
|
||||
"scheme",
|
||||
"parameter",
|
||||
"malformed",
|
||||
];
|
||||
if (reason && known.includes(reason)) {
|
||||
return t(`backendErrors.vlessUnsupported.${reason}`);
|
||||
}
|
||||
return t("backendErrors.vlessConfigInvalid");
|
||||
}
|
||||
case "XRAY_UNAVAILABLE":
|
||||
return t("backendErrors.xrayUnavailable");
|
||||
case "XRAY_UNSUPPORTED_OS":
|
||||
@@ -301,6 +344,14 @@ export function translateBackendError(t: TFunction, err: unknown): string {
|
||||
return t("backendErrors.remoteNoCapacity");
|
||||
case "REMOTE_NOT_ENTITLED":
|
||||
return t("backendErrors.remoteNotEntitled");
|
||||
// Distinct from the above: the plan HAS remote hours, it just may not spend
|
||||
// them by hand (solo funds a nightly Cookie Bot only). Telling such a user
|
||||
// "your plan does not include remote execution" while their bot visibly
|
||||
// runs every night is the confusing case this code exists to avoid.
|
||||
case "REMOTE_INTERACTIVE_NOT_ENTITLED":
|
||||
return t("backendErrors.remoteInteractiveNotEntitled");
|
||||
case "REMOTE_REQUIRES_REMOTE_EXIT_NODE":
|
||||
return t("backendErrors.remoteRequiresRemoteExitNode");
|
||||
case "REMOTE_SESSION_REFUSED":
|
||||
return t("backendErrors.remoteSessionRefused");
|
||||
case "REMOTE_SESSION_NOT_FOUND":
|
||||
@@ -314,6 +365,18 @@ export function translateBackendError(t: TFunction, err: unknown): string {
|
||||
granted: parsed.params?.granted ?? "0",
|
||||
used: parsed.params?.used ?? "0",
|
||||
});
|
||||
case "PROFILE_RUNNING_REMOTELY":
|
||||
return t("backendErrors.profileRunningRemotely");
|
||||
case "PROFILE_REMOTE_SYNC_PENDING":
|
||||
return t("backendErrors.profileRemoteSyncPending");
|
||||
case "PROFILE_LOCKED_BY_MEMBER":
|
||||
return t("backendErrors.profileLockedByMember", {
|
||||
email: parsed.params?.email ?? "",
|
||||
});
|
||||
case "PROFILE_LOCKED_ELSEWHERE":
|
||||
return t("backendErrors.profileLockedElsewhere");
|
||||
case "PROFILE_LOCK_UNAVAILABLE":
|
||||
return t("backendErrors.profileLockUnavailable");
|
||||
case "NOT_TEAM_MEMBER":
|
||||
return t("backendErrors.notTeamMember");
|
||||
case "COOKIE_BOT_NOT_ENTITLED":
|
||||
@@ -361,8 +424,27 @@ export function translateBackendError(t: TFunction, err: unknown): string {
|
||||
// resolve to the one sentence a user can act on.
|
||||
case "COOKIE_BOT_REQUIRES_PROXY":
|
||||
return t("backendErrors.cookieBotRequiresExitNode");
|
||||
case "COOKIE_BOT_REQUIRES_REMOTE_EXIT_NODE":
|
||||
return t("backendErrors.cookieBotRequiresRemoteExitNode");
|
||||
case "COOKIE_BOT_TOUCH_FINGERPRINT_UNSUPPORTED":
|
||||
return t("backendErrors.cookieBotTouchFingerprintUnsupported");
|
||||
// The launch gate's block. The dialog renders the mismatch detail from
|
||||
// `params` itself; this string is the fallback for anywhere that only has
|
||||
// room for one sentence.
|
||||
case "FINGERPRINT_EXIT_MISMATCH":
|
||||
return t("backendErrors.fingerprintExitMismatch");
|
||||
case "LAUNCH_CONSENT_EXPIRED":
|
||||
return t("backendErrors.launchConsentExpired");
|
||||
case "VPN_WORKER_START_FAILED":
|
||||
return t("backendErrors.vpnWorkerStartFailed", {
|
||||
detail: parsed.params?.detail ?? "",
|
||||
});
|
||||
case "EXIT_PROBE_FAILED":
|
||||
return t("backendErrors.exitProbeFailed");
|
||||
case "CAMOUFOX_REMOVED":
|
||||
return t("backendErrors.camoufoxRemoved");
|
||||
case "NO_E2E_PASSWORD_SET":
|
||||
return t("backendErrors.noE2ePasswordSet");
|
||||
case "INTERNAL_ERROR":
|
||||
return t("backendErrors.internal", {
|
||||
detail: parsed.params?.detail ?? "",
|
||||
|
||||
+163
-2
@@ -13,24 +13,70 @@ import { invoke } from "@tauri-apps/api/core";
|
||||
/** Bit 0 = Monday, bit 6 = Sunday. */
|
||||
export const COOKIE_BOT_DAY_BITS = [1, 2, 4, 8, 16, 32, 64] as const;
|
||||
|
||||
/**
|
||||
* What marks a `template_id` as one of the USER's own rather than a curated one.
|
||||
*
|
||||
* The two kinds share one field and behave in opposite ways — a curated
|
||||
* template's URLs are server-owned and expanded per profile at dispatch, a
|
||||
* user's are copied onto the enrolment when it is saved. An id read as the wrong
|
||||
* kind is a schedule that browses the wrong list, so every question about which
|
||||
* kind an id is goes through the helper below rather than a `startsWith` at the
|
||||
* call site.
|
||||
*/
|
||||
export const COOKIE_BOT_USER_TEMPLATE_PREFIX = "user:";
|
||||
|
||||
export function isUserTemplateId(id: string | null | undefined): boolean {
|
||||
return (
|
||||
typeof id === "string" && id.startsWith(COOKIE_BOT_USER_TEMPLATE_PREFIX)
|
||||
);
|
||||
}
|
||||
|
||||
/** Hosts the fleet can lease. Linux is refused at enrolment. */
|
||||
export type CookieBotPlatform = "windows" | "macos";
|
||||
|
||||
/** `mine` shows the caller's enrolments, `team` the whole team's. */
|
||||
export type CookieBotScope = "mine" | "team";
|
||||
|
||||
/** One time-of-day an enrolment fires, on a set of local weekdays. */
|
||||
export interface CookieBotSlot {
|
||||
/** Bitmask of local weekdays, bit 0 = Monday. At least one bit set. */
|
||||
days_mask: number;
|
||||
/** Minutes past local midnight, in the schedule's timezone. */
|
||||
run_at_minute: number;
|
||||
}
|
||||
|
||||
export interface CookieBotSchedule {
|
||||
profile_id: string;
|
||||
profile_name: string;
|
||||
platform: string;
|
||||
enabled: boolean;
|
||||
/** Minutes past local midnight the run is anchored to. */
|
||||
/**
|
||||
* Minutes past local midnight the FIRST slot is anchored to. The server
|
||||
* mirrors `slots[0]` onto this pair on every write.
|
||||
*/
|
||||
run_at_minute: number;
|
||||
/** Bitmask of local weekdays, bit 0 = Monday. */
|
||||
/** The first slot's weekdays, bit 0 = Monday. See `run_at_minute`. */
|
||||
days_mask: number;
|
||||
/**
|
||||
* Every time-of-day this enrolment fires.
|
||||
*
|
||||
* Optional because a server that predates multi-slot scheduling sends only
|
||||
* the mirrored pair above. Read it through `scheduleSlots()` rather than
|
||||
* directly, so the fallback happens in one place instead of at each renderer
|
||||
* — an empty list here means "this server did not say", never "never fires".
|
||||
*/
|
||||
slots?: CookieBotSlot[];
|
||||
timezone: string;
|
||||
/** Opaque server-issued preset id. */
|
||||
preset: string;
|
||||
/**
|
||||
* The template the site list came from, or null for the user's own list.
|
||||
*
|
||||
* A built-in id means `sites` is EMPTY on purpose: those URLs are curated
|
||||
* server-side and deliberately never sent to a client. A `user:<uuid>` id is
|
||||
* provenance — the sites were copied onto the enrolment and are present.
|
||||
*/
|
||||
template_id?: string | null;
|
||||
max_minutes: number;
|
||||
sites: string[];
|
||||
jitter_seconds: number;
|
||||
@@ -70,10 +116,23 @@ export interface CookieBotScheduleInput {
|
||||
profile_name: string;
|
||||
platform: CookieBotPlatform;
|
||||
enabled: boolean;
|
||||
/** Mirror of `slots[0]`, for a server that predates multi-slot scheduling. */
|
||||
run_at_minute: number;
|
||||
/** Mirror of `slots[0]`. See `run_at_minute`. */
|
||||
days_mask: number;
|
||||
/**
|
||||
* The whole calendar. Omit it — never send an empty array — for "one slot,
|
||||
* from the pair above": the server refuses an empty list, because a schedule
|
||||
* that fires at no time is a mistake rather than a way to pause one.
|
||||
*/
|
||||
slots?: CookieBotSlot[];
|
||||
timezone: string;
|
||||
preset: string;
|
||||
/**
|
||||
* A browsing template instead of a typed site list. Mutually exclusive with a
|
||||
* non-empty `sites`: the server refuses a write carrying both.
|
||||
*/
|
||||
template_id?: string;
|
||||
max_minutes: number;
|
||||
sites: string[];
|
||||
jitter_seconds?: number;
|
||||
@@ -162,9 +221,70 @@ export interface CookieBotPreset {
|
||||
description?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A curated browsing template: a named answer to "what is this profile for",
|
||||
* picked INSTEAD of typing a site list.
|
||||
*
|
||||
* Carries a count and never the URLs. That is the product working as designed —
|
||||
* the pool is curated server-side and each profile draws its own sample from it,
|
||||
* so the template never becomes one recognisable fleet-wide set of visits. Any
|
||||
* copy describing this must say so as the feature it is.
|
||||
*/
|
||||
export interface CookieBotTemplate {
|
||||
id: string;
|
||||
/** How many sites this template browses. Not which. */
|
||||
site_count: number;
|
||||
/** Server-supplied English fallbacks, for a template newer than this build. */
|
||||
name?: string | null;
|
||||
description?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The server's own form bounds, when it publishes them.
|
||||
*
|
||||
* Every field is optional: a deployment that predates this object sends none of
|
||||
* them, and treating a missing bound as `0` would refuse every value the form
|
||||
* can produce. `SCHEDULE_BOUNDS` in `cookie-bot-limits.ts` is the fallback.
|
||||
*/
|
||||
export interface CookieBotLimits {
|
||||
min_minutes?: number | null;
|
||||
max_minutes?: number | null;
|
||||
min_sites?: number | null;
|
||||
max_sites?: number | null;
|
||||
/** Most entries a calendar may carry. */
|
||||
max_slots?: number | null;
|
||||
/** Longest name a saved site list may be given. */
|
||||
max_template_name_length?: number | null;
|
||||
}
|
||||
|
||||
export interface CookieBotPresetList {
|
||||
presets: CookieBotPreset[];
|
||||
default_preset?: string | null;
|
||||
/**
|
||||
* The curated templates on offer. Served with the presets so one added
|
||||
* server-side becomes selectable without a desktop release.
|
||||
*/
|
||||
templates?: CookieBotTemplate[];
|
||||
limits?: CookieBotLimits | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* One of the caller's OWN saved site lists.
|
||||
*
|
||||
* Carries its URLs, unlike {@link CookieBotTemplate}: they are the user's own
|
||||
* and there is nothing to withhold. Applying one COPIES the sites onto the
|
||||
* enrolment, so editing a list later does not silently change what an existing
|
||||
* schedule browses.
|
||||
*/
|
||||
export interface CookieBotUserTemplate {
|
||||
/**
|
||||
* Already prefixed `user:<uuid>` — the value `template_id` takes verbatim.
|
||||
* Nothing on this side assembles that convention.
|
||||
*/
|
||||
id: string;
|
||||
name: string;
|
||||
sites: string[];
|
||||
updated_at?: string | null;
|
||||
}
|
||||
|
||||
export interface RemoteHoursBreakdown {
|
||||
@@ -327,6 +447,47 @@ export function getCookieBotPresets(): Promise<CookieBotPresetList> {
|
||||
return invoke<CookieBotPresetList>("get_cookie_bot_presets");
|
||||
}
|
||||
|
||||
/** Every site list this user has saved, most recently edited first. */
|
||||
export function getCookieBotUserTemplates(): Promise<CookieBotUserTemplate[]> {
|
||||
return invoke<CookieBotUserTemplate[]>("get_cookie_bot_user_templates");
|
||||
}
|
||||
|
||||
/** Save the current site list under a name. */
|
||||
export function createCookieBotUserTemplate(
|
||||
name: string,
|
||||
sites: string[],
|
||||
): Promise<CookieBotUserTemplate> {
|
||||
return invoke<CookieBotUserTemplate>("create_cookie_bot_user_template", {
|
||||
name,
|
||||
sites,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename a saved list, replace its sites, or both.
|
||||
*
|
||||
* Send only what changed. A rename that also carried the site list would
|
||||
* silently revert an edit made to it from another device in between.
|
||||
*/
|
||||
export function updateCookieBotUserTemplate(
|
||||
id: string,
|
||||
changes: { name?: string; sites?: string[] },
|
||||
): Promise<CookieBotUserTemplate> {
|
||||
return invoke<CookieBotUserTemplate>("update_cookie_bot_user_template", {
|
||||
id,
|
||||
name: changes.name,
|
||||
sites: changes.sites,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a saved list. `false` means there was nothing left to delete, which is
|
||||
* a success — enrolments that used it keep the sites they copied either way.
|
||||
*/
|
||||
export function deleteCookieBotUserTemplate(id: string): Promise<boolean> {
|
||||
return invoke<boolean>("delete_cookie_bot_user_template", { id });
|
||||
}
|
||||
|
||||
export function getRemoteHoursQuota(): Promise<RemoteHoursQuota> {
|
||||
return invoke<RemoteHoursQuota>("get_remote_hours_quota");
|
||||
}
|
||||
|
||||
+28
-10
@@ -8,6 +8,7 @@ interface Capabilities {
|
||||
cloudBackup: boolean;
|
||||
teamCollaboration: boolean;
|
||||
cookieBot: boolean;
|
||||
remoteInteractive: boolean;
|
||||
}
|
||||
|
||||
const NONE: Entitlements = {
|
||||
@@ -17,6 +18,7 @@ const NONE: Entitlements = {
|
||||
cloudBackup: false,
|
||||
teamCollaboration: false,
|
||||
cookieBot: false,
|
||||
remoteInteractive: false,
|
||||
profileLimit: 0,
|
||||
requestsPerHour: 0,
|
||||
remoteBrowserHours: 0,
|
||||
@@ -25,12 +27,16 @@ const NONE: Entitlements = {
|
||||
// Mirror of PLAN_CAPABILITIES in apps/backend/src/plans/entitlements.ts. Keep in
|
||||
// sync — a new plan must be declared here too, or it falls back to DEFAULT_PAID.
|
||||
const PLAN_CAPABILITIES: Record<string, Capabilities> = {
|
||||
starter: {
|
||||
// The one row where cookieBot, browserAutomation and remoteInteractive all
|
||||
// disagree: solo pays for a nightly bot and nothing else that drives a
|
||||
// browser. No fingerprint editing either.
|
||||
solo: {
|
||||
browserAutomation: false,
|
||||
crossOsFingerprints: true,
|
||||
crossOsFingerprints: false,
|
||||
cloudBackup: true,
|
||||
teamCollaboration: false,
|
||||
cookieBot: false,
|
||||
cookieBot: true,
|
||||
remoteInteractive: false,
|
||||
},
|
||||
pro: {
|
||||
browserAutomation: true,
|
||||
@@ -38,6 +44,7 @@ const PLAN_CAPABILITIES: Record<string, Capabilities> = {
|
||||
cloudBackup: true,
|
||||
teamCollaboration: false,
|
||||
cookieBot: true,
|
||||
remoteInteractive: true,
|
||||
},
|
||||
team: {
|
||||
browserAutomation: true,
|
||||
@@ -45,6 +52,7 @@ const PLAN_CAPABILITIES: Record<string, Capabilities> = {
|
||||
cloudBackup: true,
|
||||
teamCollaboration: true,
|
||||
cookieBot: true,
|
||||
remoteInteractive: true,
|
||||
},
|
||||
enterprise: {
|
||||
browserAutomation: true,
|
||||
@@ -52,6 +60,7 @@ const PLAN_CAPABILITIES: Record<string, Capabilities> = {
|
||||
cloudBackup: true,
|
||||
teamCollaboration: true,
|
||||
cookieBot: true,
|
||||
remoteInteractive: true,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -62,6 +71,7 @@ const DEFAULT_PAID: Capabilities = {
|
||||
cloudBackup: true,
|
||||
teamCollaboration: false,
|
||||
cookieBot: true,
|
||||
remoteInteractive: true,
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -75,16 +85,23 @@ export function getEntitlements(
|
||||
): Entitlements {
|
||||
if (user?.entitlements) {
|
||||
const server = user.entitlements;
|
||||
// A backend (or a cached login) older than the cookie-bot release omits
|
||||
// these two keys. Reading them as `undefined` would hide a paid feature
|
||||
// from a paying customer with nothing logged anywhere, so resolve them
|
||||
// here — the one place every caller already goes through. Cookie Bot is
|
||||
// remote automation on leased hardware, so it tracks `browserAutomation`
|
||||
// exactly; `remoteBrowserHours` stays 0 because the spendable figure is
|
||||
// whatever `get_remote_hours_quota` reports, never a client guess.
|
||||
// A backend (or a cached login) older than the current release omits these
|
||||
// keys. Reading them as `undefined` would hide a paid feature from a paying
|
||||
// customer with nothing logged anywhere, so resolve them here — the one
|
||||
// place every caller already goes through.
|
||||
//
|
||||
// Both absent flags fall back to `browserAutomation`, which is what they
|
||||
// were derived from before solo existed: on every plan a pre-solo backend
|
||||
// knows about, automation implied both the bot and interactive remote
|
||||
// control. A solo user never hits this branch — the backend that can put
|
||||
// them on solo is by definition new enough to send both keys.
|
||||
//
|
||||
// `remoteBrowserHours` stays 0 because the spendable figure is whatever
|
||||
// `get_remote_hours_quota` reports, never a client guess.
|
||||
return {
|
||||
...server,
|
||||
cookieBot: server.cookieBot ?? server.browserAutomation,
|
||||
remoteInteractive: server.remoteInteractive ?? server.browserAutomation,
|
||||
remoteBrowserHours: server.remoteBrowserHours ?? 0,
|
||||
};
|
||||
}
|
||||
@@ -103,6 +120,7 @@ export function getEntitlements(
|
||||
cloudBackup: caps.cloudBackup,
|
||||
teamCollaboration: caps.teamCollaboration,
|
||||
cookieBot: caps.cookieBot,
|
||||
remoteInteractive: caps.remoteInteractive,
|
||||
profileLimit: user.profileLimit,
|
||||
requestsPerHour: caps.browserAutomation ? DEFAULT_REQUESTS_PER_HOUR : 0,
|
||||
remoteBrowserHours: 0,
|
||||
|
||||
@@ -47,6 +47,19 @@ export interface RemoteSessionEnded {
|
||||
billed_seconds: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Why a profile cannot be opened on this computer right now.
|
||||
*
|
||||
* - `running`: a browser is open on the fleet holding this profile.
|
||||
* - `pending_sync`: a session has finished and what it wrote is still being
|
||||
* pulled down. Opening the local copy now would make the local files look
|
||||
* newer than the host's push, and the next sync would then upload the stale
|
||||
* copy over the session's work and delete the rest of it.
|
||||
*
|
||||
* Both states are temporary and neither is an error.
|
||||
*/
|
||||
export type RemoteHandoffState = "running" | "pending_sync";
|
||||
|
||||
/**
|
||||
* States a session cannot leave under its own steam.
|
||||
*
|
||||
@@ -83,8 +96,31 @@ export const REMOTE_SESSION_EVENTS = {
|
||||
snapshot: "remote-session-snapshot",
|
||||
/** Stream connectivity. Payload: `RemoteSessionStreamStatus`. */
|
||||
stream: "remote-session-stream",
|
||||
/**
|
||||
* The set of profiles that cannot be launched locally changed.
|
||||
* Payload: `Record<profileId, RemoteHandoffState>`.
|
||||
*/
|
||||
handoff: "remote-handoff-changed",
|
||||
} as const;
|
||||
|
||||
/** Which profiles are blocked from launching locally, and why. */
|
||||
export function getRemoteHandoffStates(): Promise<
|
||||
Record<string, RemoteHandoffState>
|
||||
> {
|
||||
return invoke<Record<string, RemoteHandoffState>>(
|
||||
"get_remote_handoff_states",
|
||||
);
|
||||
}
|
||||
|
||||
export function onRemoteHandoffChanged(
|
||||
handler: (states: Record<string, RemoteHandoffState>) => void,
|
||||
): Promise<UnlistenFn> {
|
||||
return listen<Record<string, RemoteHandoffState>>(
|
||||
REMOTE_SESSION_EVENTS.handoff,
|
||||
(event) => handler(event.payload),
|
||||
);
|
||||
}
|
||||
|
||||
export function listRemoteSessions(): Promise<RemoteSessionState[]> {
|
||||
return invoke<RemoteSessionState[]>("list_remote_sessions");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import {
|
||||
DEFAULT_DECORATION_LAYOUT,
|
||||
parseDecorationLayout,
|
||||
} from "./window-decorations.ts";
|
||||
|
||||
/**
|
||||
* The app draws its own titlebar on Linux, so it owns the window controls —
|
||||
* and where they go is a desktop-wide user preference. This parser is the only
|
||||
* thing standing between that preference and the buttons we render, and only
|
||||
* GNOME can be exercised on the machine this was written on, so KDE's real
|
||||
* layout strings are pinned here instead.
|
||||
*/
|
||||
|
||||
test("GNOME's default puts every control on the right", () => {
|
||||
assert.deepEqual(parseDecorationLayout(":minimize,maximize,close"), {
|
||||
left: [],
|
||||
right: ["minimize", "maximize", "close"],
|
||||
});
|
||||
});
|
||||
|
||||
test("a left-hand layout is honored", () => {
|
||||
// GNOME users who prefer macOS ordering set exactly this.
|
||||
assert.deepEqual(parseDecorationLayout("close,minimize,maximize:"), {
|
||||
left: ["close", "minimize", "maximize"],
|
||||
right: [],
|
||||
});
|
||||
});
|
||||
|
||||
test("controls can be split across both sides", () => {
|
||||
assert.deepEqual(parseDecorationLayout("close:minimize,maximize"), {
|
||||
left: ["close"],
|
||||
right: ["minimize", "maximize"],
|
||||
});
|
||||
});
|
||||
|
||||
test("GTK's own default drops the appmenu it cannot draw", () => {
|
||||
assert.deepEqual(parseDecorationLayout("appmenu:close"), {
|
||||
left: [],
|
||||
right: ["close"],
|
||||
});
|
||||
});
|
||||
|
||||
test("non-button tokens are ignored rather than rendered", () => {
|
||||
// `icon`, `menu`, `appmenu` and `spacer` are all legal GTK tokens for things
|
||||
// this titlebar does not draw.
|
||||
assert.deepEqual(parseDecorationLayout("icon,menu:spacer,close"), {
|
||||
left: [],
|
||||
right: ["close"],
|
||||
});
|
||||
});
|
||||
|
||||
test("KDE's extra decoration buttons are ignored", () => {
|
||||
// KWin offers buttons GTK has no concept of. kde-gtk-config maps what it can
|
||||
// and may pass these through; rendering an unknown box would be worse than
|
||||
// dropping it, which is what GTK itself does.
|
||||
assert.deepEqual(
|
||||
parseDecorationLayout(
|
||||
"menu,applicationmenu:shade,keepabove,keepbelow,help,minimize,maximize,close",
|
||||
),
|
||||
{ left: [], right: ["minimize", "maximize", "close"] },
|
||||
);
|
||||
});
|
||||
|
||||
test("a duplicated control is rendered once", () => {
|
||||
assert.deepEqual(parseDecorationLayout("close:close,minimize"), {
|
||||
left: ["close"],
|
||||
right: ["minimize"],
|
||||
});
|
||||
});
|
||||
|
||||
test("whitespace and capitalization are tolerated", () => {
|
||||
assert.deepEqual(parseDecorationLayout(" : Minimize , CLOSE "), {
|
||||
left: [],
|
||||
right: ["minimize", "close"],
|
||||
});
|
||||
});
|
||||
|
||||
test("a string with no colon is entirely the left side, as GTK reads it", () => {
|
||||
// `g_strsplit(layout, ":", 2)` leaves the right-hand token NULL, so GTK puts
|
||||
// every button on the left. No mainstream desktop emits this, but matching
|
||||
// GTK is the only defensible reading.
|
||||
assert.deepEqual(parseDecorationLayout("minimize,close"), {
|
||||
left: ["minimize", "close"],
|
||||
right: [],
|
||||
});
|
||||
});
|
||||
|
||||
test("only the first colon splits the sides", () => {
|
||||
// GTK's split has a limit of 2, so the second colon is not a separator: the
|
||||
// right side becomes the single token "minimize:maximize", which matches no
|
||||
// button name and is dropped — exactly as GTK drops it.
|
||||
assert.deepEqual(parseDecorationLayout("close:minimize:maximize"), {
|
||||
left: ["close"],
|
||||
right: [],
|
||||
});
|
||||
});
|
||||
|
||||
test("missing, empty and unusable layouts fall back to the default", () => {
|
||||
const fallback = { left: [], right: ["minimize", "maximize", "close"] };
|
||||
for (const input of [null, undefined, "", " "]) {
|
||||
assert.deepEqual(parseDecorationLayout(input), fallback, `input: ${input}`);
|
||||
}
|
||||
// A layout naming only buttons we cannot draw would otherwise leave the user
|
||||
// with no way to close the window.
|
||||
assert.deepEqual(parseDecorationLayout("appmenu:spacer"), fallback);
|
||||
assert.deepEqual(parseDecorationLayout(":"), fallback);
|
||||
});
|
||||
|
||||
test("the documented default parses to the default", () => {
|
||||
assert.deepEqual(parseDecorationLayout(DEFAULT_DECORATION_LAYOUT), {
|
||||
left: [],
|
||||
right: ["minimize", "maximize", "close"],
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Parsing for the desktop's titlebar button layout on Linux.
|
||||
*
|
||||
* The backend reads `GtkSettings::gtk-decoration-layout`, which GNOME populates
|
||||
* from `org.gnome.desktop.wm.preferences button-layout` and KDE populates via
|
||||
* `kde-gtk-config` from KWin's decoration settings. The grammar is the one GTK
|
||||
* itself parses: a comma-separated list of button names for the left side, a
|
||||
* `:`, then the list for the right side. Either side may be empty.
|
||||
*
|
||||
* ":minimize,maximize,close" GNOME default — everything on the right
|
||||
* "close,minimize,maximize:" macOS-like — everything on the left
|
||||
* "appmenu:close" upstream GTK default
|
||||
*/
|
||||
|
||||
/** What the backend reports about this window's decorations. */
|
||||
export interface WindowDecorationsInfo {
|
||||
/** True when the app owns the titlebar and must draw controls and edges. */
|
||||
client_side: boolean;
|
||||
/** The desktop's button layout; only meaningful when `client_side`. */
|
||||
layout: string | null;
|
||||
}
|
||||
|
||||
/** Controls this app can actually draw. */
|
||||
export type WindowControl = "minimize" | "maximize" | "close";
|
||||
|
||||
export interface DecorationLayout {
|
||||
left: WindowControl[];
|
||||
right: WindowControl[];
|
||||
}
|
||||
|
||||
/** What an unconfigured GNOME or KDE session shows. */
|
||||
export const DEFAULT_DECORATION_LAYOUT = ":minimize,maximize,close";
|
||||
|
||||
const CONTROLS: WindowControl[] = ["minimize", "maximize", "close"];
|
||||
|
||||
function parseSide(side: string, seen: Set<WindowControl>): WindowControl[] {
|
||||
const out: WindowControl[] = [];
|
||||
for (const raw of side.split(",")) {
|
||||
const name = raw.trim().toLowerCase();
|
||||
// Everything else a desktop can put here is deliberately dropped rather
|
||||
// than rendered as an unknown box: `icon`, `menu`, `appmenu` and `spacer`
|
||||
// from GTK, plus KDE's extras (`shade`, `above`/`keepabove`,
|
||||
// `below`/`keepbelow`, `help`, `applicationmenu`, `ontop`). Silently
|
||||
// ignoring an unrecognized token is also what GTK does.
|
||||
if (!CONTROLS.includes(name as WindowControl)) {
|
||||
continue;
|
||||
}
|
||||
const control = name as WindowControl;
|
||||
// A desktop could list the same button on both sides; the shared `seen` set
|
||||
// means the first occurrence wins and it is never drawn twice.
|
||||
if (seen.has(control)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(control);
|
||||
out.push(control);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a layout string into the buttons to draw on each side.
|
||||
*
|
||||
* Falls back to the GNOME/KDE default whenever the string is missing, empty, or
|
||||
* names no button this app can draw — a titlebar with no way to close the
|
||||
* window would be far worse than one that ignores an exotic preference.
|
||||
*/
|
||||
export function parseDecorationLayout(
|
||||
layout: string | null | undefined,
|
||||
): DecorationLayout {
|
||||
const source = layout?.trim() ? layout : DEFAULT_DECORATION_LAYOUT;
|
||||
// GTK splits on the FIRST colon only, and a string with no colon at all is
|
||||
// entirely the left side (`g_strsplit(layout, ":", 2)` leaves the right
|
||||
// token NULL). Matching that exactly beats inventing a friendlier rule.
|
||||
const split = source.indexOf(":");
|
||||
const leftRaw = split === -1 ? source : source.slice(0, split);
|
||||
const rightRaw = split === -1 ? "" : source.slice(split + 1);
|
||||
|
||||
const seen = new Set<WindowControl>();
|
||||
const left = parseSide(leftRaw, seen);
|
||||
const right = parseSide(rightRaw, seen);
|
||||
|
||||
if (left.length === 0 && right.length === 0) {
|
||||
return { left: [], right: [...CONTROLS] };
|
||||
}
|
||||
return { left, right };
|
||||
}
|
||||
+60
-10
@@ -85,11 +85,11 @@ export interface SyncSettings {
|
||||
|
||||
/**
|
||||
* Capability/limit set derived from the plan by the backend. Features are gated
|
||||
* on these flags instead of a single "is paid?" check, so a plan like the future
|
||||
* "starter" tier (cross-OS fingerprints + cloud backup, no automation) is just
|
||||
* data. Mirrors `apps/backend/src/plans/entitlements.ts`. Resolve via
|
||||
* `getEntitlements()` — the desktop populates it, but it stays optional for
|
||||
* safety on older state.
|
||||
* on these flags instead of a single "is paid?" check, so a plan like "solo"
|
||||
* (cloud backup + nightly cookie bot, no automation, no fingerprint editing, no
|
||||
* hands-on remote session) is just data. Mirrors
|
||||
* `apps/backend/src/plans/entitlements.ts`. Resolve via `getEntitlements()` —
|
||||
* the desktop populates it, but it stays optional for safety on older state.
|
||||
*/
|
||||
export interface Entitlements {
|
||||
active: boolean;
|
||||
@@ -99,6 +99,13 @@ export interface Entitlements {
|
||||
teamCollaboration: boolean;
|
||||
/** Overnight profile warming on a leased remote host. */
|
||||
cookieBot: boolean;
|
||||
/**
|
||||
* May open a HANDS-ON remote session. Not implied by `cookieBot` or by a
|
||||
* non-zero `remoteBrowserHours`: solo funds a nightly bot out of its hours and
|
||||
* may not drive a remote browser itself, so any UI offering interactive remote
|
||||
* control must read this flag.
|
||||
*/
|
||||
remoteInteractive: boolean;
|
||||
profileLimit: number;
|
||||
requestsPerHour: number;
|
||||
/**
|
||||
@@ -110,15 +117,22 @@ export interface Entitlements {
|
||||
}
|
||||
|
||||
/**
|
||||
* What a backend older than the cookie-bot release actually sends. Read it
|
||||
* through `getEntitlements()`, which fills the gap — never off `CloudUser`
|
||||
* directly, or a paying customer's Cookie Bot silently reads `false`.
|
||||
* What a backend older than the current release actually sends. Read it through
|
||||
* `getEntitlements()`, which fills the gaps — never off `CloudUser` directly, or
|
||||
* a paying customer's Cookie Bot silently reads `false`.
|
||||
*
|
||||
* `remoteInteractive` joins the optional set for the same reason `cookieBot`
|
||||
* did: a backend predating the solo tier omits it, and reading the absent key as
|
||||
* `false` would take interactive remote sessions away from a Pro customer whose
|
||||
* only mistake was a stale cached login.
|
||||
*/
|
||||
export type ServerEntitlements = Omit<
|
||||
Entitlements,
|
||||
"cookieBot" | "remoteBrowserHours"
|
||||
"cookieBot" | "remoteBrowserHours" | "remoteInteractive"
|
||||
> &
|
||||
Partial<Pick<Entitlements, "cookieBot" | "remoteBrowserHours">>;
|
||||
Partial<
|
||||
Pick<Entitlements, "cookieBot" | "remoteBrowserHours" | "remoteInteractive">
|
||||
>;
|
||||
|
||||
export interface CloudUser {
|
||||
id: string;
|
||||
@@ -645,3 +659,39 @@ export interface VpnStatus {
|
||||
bytes_received?: number;
|
||||
last_handshake?: number;
|
||||
}
|
||||
|
||||
/** Result of comparing a proxy's exit node against a profile's fingerprint. */
|
||||
export interface ConsistencyResult {
|
||||
consistent: boolean;
|
||||
checked: boolean;
|
||||
exit_ip: string | null;
|
||||
exit_country_code: string | null;
|
||||
exit_timezone: string | null;
|
||||
fingerprint_timezone: string | null;
|
||||
fingerprint_language: string | null;
|
||||
/** Which dimensions disagree: "timezone", "language". */
|
||||
mismatches: string[];
|
||||
}
|
||||
|
||||
/** A VPN/proxy extension found in a profile, which can reroute browser traffic. */
|
||||
export interface DetectedVpnExtension {
|
||||
/** Acknowledgement identity: `donut:<uuid>` or `crx:<id>`. */
|
||||
key: string;
|
||||
name: string;
|
||||
version: string | null;
|
||||
/** "donut" (managed by Donut) or "browser" (installed in the profile). */
|
||||
source: string;
|
||||
/** "confirmed" (holds the proxy permission) or "likely". */
|
||||
confidence: string;
|
||||
signals: string[];
|
||||
}
|
||||
|
||||
/** Local-only checks answered before a launch starts any worker. */
|
||||
export interface PreLaunchChecks {
|
||||
vpn_extensions: DetectedVpnExtension[];
|
||||
scan_state: string;
|
||||
consistency: ConsistencyResult;
|
||||
exit_probe_pending: boolean;
|
||||
exit_measurement_unreliable: boolean;
|
||||
consent_token: string | null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user