refactor: cleanup

This commit is contained in:
zhom
2026-07-25 22:59:13 +04:00
parent 59a3e5f2a1
commit b9070693ed
49 changed files with 1204 additions and 2208 deletions
+2
View File
@@ -31,6 +31,7 @@ donutbrowser/
│ │ ├── proxy_storage.rs # Proxy config persistence (JSON files)
│ │ ├── api_server.rs # REST API (utoipa + axum)
│ │ ├── mcp_server.rs # MCP protocol server
│ │ ├── automation_rate_limiter.rs # Shared REST/MCP automation quota
│ │ ├── sync/ # Cloud sync (engine, encryption, manifest, scheduler)
│ │ ├── vpn/ # WireGuard tunnels
│ │ ├── wayfern_manager.rs # Wayfern (Chromium) browser management
@@ -160,6 +161,7 @@ Handlers route manager errors through `manager_error_response`, which maps messa
- `404` — entity not found (`… not found` / `*_NOT_FOUND`).
- `400` — validation, duplicates, empty names, invalid/unsupported/unavailable input.
- `409` — conflicts: browser version already being downloaded, profile locked by another team member (run), browser running during cookie import.
- `429` — authenticated automation request quota exceeded (`Retry-After` header included).
- `500` — internal failures (IO, network, poisoned locks).
Error bodies are plain-text diagnostics; some are the JSON `{"code": ...}` strings shared with the Tauri commands (e.g. `NAME_CANNOT_BE_EMPTY`, `GROUP_ALREADY_EXISTS`). The translated-error rule above applies to Tauri commands, not to REST bodies.
-3
View File
@@ -241,9 +241,6 @@ export const commandCoverage = {
"cloud_auth::cloud_logout",
"cloud_auth::cloud_get_proxy_usage",
"cloud_auth::cloud_get_countries",
"cloud_auth::cloud_get_regions",
"cloud_auth::cloud_get_cities",
"cloud_auth::cloud_get_isps",
"cloud_auth::create_cloud_location_proxy",
"cloud_auth::cloud_get_wayfern_token",
"cloud_auth::cloud_refresh_wayfern_token",
+237 -107
View File
@@ -52,6 +52,11 @@ async function invokeContract(app, command, args = {}) {
}
}
async function assertCommandErrorCode(app, command, code, args = {}) {
const error = await app.invokeError(command, args);
assert.match(error, new RegExp(`"code":"${code}"`));
}
test("authenticated REST API serves its complete OpenAPI contract and CRUD lifecycle", async () => {
await withApp("integrations-rest", async (app) => {
await seedTerms(app);
@@ -189,7 +194,17 @@ test("authenticated REST API serves its complete OpenAPI contract and CRUD lifec
test("MCP Streamable HTTP initialization, auth, discovery, calls, and isolated agent install", async () => {
await withApp("integrations-mcp", async (app) => {
await seedTerms(app);
await assertCommandErrorCode(
app,
"stop_mcp_server",
"MCP_SERVER_NOT_RUNNING",
);
const port = await app.invoke("start_mcp_server");
await assertCommandErrorCode(
app,
"start_mcp_server",
"MCP_SERVER_ALREADY_RUNNING",
);
assert.equal(await app.invoke("get_mcp_server_status"), true);
const config = await app.invoke("get_mcp_config");
assert.equal(config.port, port);
@@ -263,6 +278,9 @@ test("MCP Streamable HTTP initialization, auth, discovery, calls, and isolated a
const agents = await app.invoke("list_mcp_agents");
assert.ok(agents.some((agent) => agent.id === "cursor"));
await assertCommandErrorCode(app, "add_mcp_to_agent", "MCP_AGENT_UNKNOWN", {
agentId: "missing-e2e-agent",
});
await app.invoke("add_mcp_to_agent", { agentId: "cursor" });
assert.equal(
(await app.invoke("list_mcp_agents")).find(
@@ -291,113 +309,225 @@ test("MCP Streamable HTTP initialization, auth, discovery, calls, and isolated a
});
});
test("offline cloud, update, team-lock, trial, and synchronizer contracts are deterministic", async () => {
await withApp("integrations-contracts", async (app) => {
assert.equal(await app.invoke("cloud_get_user"), null);
assert.equal(await app.invoke("cloud_get_proxy_usage"), null);
assert.ok(await app.invoke("cloud_get_wayfern_token"));
assert.deepEqual(await app.invoke("get_team_locks"), []);
assert.equal(
await app.invoke("get_team_lock_status", {
profileId: "00000000-0000-0000-0000-000000000000",
}),
null,
);
assert.deepEqual(await app.invoke("get_sync_sessions"), []);
const startResult = await invokeContract(app, "start_sync_session", {
leaderProfileId: "00000000-0000-0000-0000-000000000001",
followerProfileIds: ["00000000-0000-0000-0000-000000000002"],
});
assert.equal(startResult.ok, false);
const stopError = await app.invokeError("stop_sync_session", {
sessionId: "missing",
});
assert.match(stopError, /not found|session/i);
const removeError = await app.invokeError("remove_sync_follower", {
sessionId: "missing",
followerProfileId: "missing",
});
assert.match(removeError, /not found|session/i);
assert.equal(await app.invoke("check_for_app_updates"), null);
assert.equal(await app.invoke("check_for_app_updates_manual"), null);
assert.ok(
await invokeContract(app, "cloud_exchange_device_code", {
code: "DONUT-E2E-INVALID-CODE",
}),
);
assert.ok(await invokeContract(app, "cloud_refresh_profile"));
assert.ok(await invokeContract(app, "cloud_get_countries"));
assert.ok(
await invokeContract(app, "cloud_get_regions", {
country: "ZZ",
}),
);
assert.ok(
await invokeContract(app, "cloud_get_cities", {
country: "ZZ",
region: null,
}),
);
assert.ok(
await invokeContract(app, "cloud_get_isps", {
country: "ZZ",
region: null,
city: null,
}),
);
assert.ok(
await invokeContract(app, "create_cloud_location_proxy", {
name: "E2E unavailable cloud proxy",
country: "ZZ",
region: null,
city: null,
isp: null,
}),
);
assert.ok(await invokeContract(app, "cloud_refresh_wayfern_token"));
assert.ok(await invokeContract(app, "trigger_manual_version_update"));
assert.ok(await invokeContract(app, "clear_all_version_cache_and_refetch"));
assert.ok(await invokeContract(app, "check_for_browser_updates"));
await app.invoke("dismiss_update_notification", {
notificationId: "missing-e2e-notification",
});
assert.deepEqual(
await app.invoke("complete_browser_update_with_auto_update", {
browser: "wayfern",
newVersion: "150.0.7871.100",
}),
[],
);
const prepareError = await app.invokeError(
"download_and_prepare_app_update",
{
updateInfo: {
current_version: "0.0.0",
new_version: "0.0.1-e2e",
release_notes: "E2E invalid update contract",
download_url: `${process.env.DONUT_E2E_FIXTURE_URL}/invalid-update.zip`,
is_nightly: false,
published_at: "2026-01-01T00:00:00Z",
manual_update_required: false,
release_page_url: null,
repo_update: false,
checksums_url: null,
asset_digest: null,
test("REST and MCP share the browser automation rate limit", async () => {
await withApp(
"integrations-rate-limit",
async (app) => {
await seedTerms(app);
const settings = await app.invoke("get_app_settings");
const saved = await app.invoke("save_app_settings", {
settings: {
...settings,
api_enabled: true,
api_port: 0,
api_token: null,
onboarding_completed: true,
},
},
);
assert.match(prepareError, /checksum|verif|Failed to download/i);
const versionStatus = await app.invoke("get_version_update_status");
assert.ok(versionStatus && typeof versionStatus === "object");
assert.equal(typeof (await app.invoke("is_default_browser")), "boolean");
});
const trial = await app.invoke("get_commercial_trial_status");
assert.ok(trial && typeof trial === "object");
await app.invoke("acknowledge_trial_expiration");
assert.equal(await app.invoke("has_acknowledged_trial_expiration"), true);
await app.invoke("cloud_logout");
assert.equal(await app.invoke("cloud_get_user"), null);
});
const apiPort = await app.invoke("start_api_server", { port: 0 });
const mcpPort = await app.invoke("start_mcp_server");
const mcpConfig = await app.invoke("get_mcp_config");
const apiBase = `http://127.0.0.1:${apiPort}`;
const mcpUrl = `http://127.0.0.1:${mcpPort}/mcp/${mcpConfig.token}`;
const initialized = await jsonRequest(mcpUrl, {
method: "POST",
body: {
jsonrpc: "2.0",
id: 1,
method: "initialize",
params: {
protocolVersion: "2025-11-25",
capabilities: {},
clientInfo: { name: "donut-e2e-rate-limit", version: "1" },
},
},
});
assert.equal(initialized.response.status, 200);
const mcpHeaders = {
"mcp-session-id": initialized.response.headers.get("mcp-session-id"),
};
const missingProfileId = "00000000-0000-0000-0000-000000000000";
const first = await jsonRequest(
`${apiBase}/v1/profiles/${missingProfileId}/run`,
{
method: "POST",
token: saved.api_token,
body: {},
},
);
assert.equal(first.response.status, 404);
const second = await jsonRequest(mcpUrl, {
method: "POST",
headers: mcpHeaders,
body: {
jsonrpc: "2.0",
id: 2,
method: "tools/call",
params: {
name: "run_profile",
arguments: { profile_id: missingProfileId },
},
},
});
assert.equal(second.response.status, 200);
assert.equal(second.value.error.code, -32000);
const restLimited = await jsonRequest(
`${apiBase}/v1/profiles/${missingProfileId}/run`,
{
method: "POST",
token: saved.api_token,
body: {},
},
);
assert.equal(restLimited.response.status, 429);
assert.ok(Number(restLimited.response.headers.get("retry-after")) > 0);
const mcpLimited = await jsonRequest(mcpUrl, {
method: "POST",
headers: mcpHeaders,
body: {
jsonrpc: "2.0",
id: 3,
method: "tools/call",
params: {
name: "run_profile",
arguments: { profile_id: missingProfileId },
},
},
});
assert.equal(mcpLimited.response.status, 429);
assert.ok(Number(mcpLimited.response.headers.get("retry-after")) > 0);
const freeCall = await jsonRequest(mcpUrl, {
method: "POST",
headers: mcpHeaders,
body: {
jsonrpc: "2.0",
id: 4,
method: "tools/call",
params: { name: "list_profiles", arguments: {} },
},
});
assert.equal(freeCall.response.status, 200);
assert.equal(freeCall.value.error, undefined);
await app.invoke("stop_mcp_server");
await app.invoke("stop_api_server");
},
{
extraEnv: {
DONUT_E2E_REQUESTS_PER_HOUR: "2",
WAYFERN_TEST_TOKEN: "donut-e2e-rate-limit",
},
},
);
});
test("offline cloud, update, team-lock, trial, and synchronizer contracts are deterministic", async () => {
await withApp(
"integrations-contracts",
async (app) => {
await assertCommandErrorCode(
app,
"start_mcp_server",
"WAYFERN_TERMS_REQUIRED",
);
assert.equal(await app.invoke("cloud_get_user"), null);
assert.equal(await app.invoke("cloud_get_proxy_usage"), null);
assert.ok(await app.invoke("cloud_get_wayfern_token"));
assert.deepEqual(await app.invoke("get_team_locks"), []);
assert.equal(
await app.invoke("get_team_lock_status", {
profileId: "00000000-0000-0000-0000-000000000000",
}),
null,
);
assert.deepEqual(await app.invoke("get_sync_sessions"), []);
const startResult = await invokeContract(app, "start_sync_session", {
leaderProfileId: "00000000-0000-0000-0000-000000000001",
followerProfileIds: ["00000000-0000-0000-0000-000000000002"],
});
assert.equal(startResult.ok, false);
const stopError = await app.invokeError("stop_sync_session", {
sessionId: "missing",
});
assert.match(stopError, /not found|session/i);
const removeError = await app.invokeError("remove_sync_follower", {
sessionId: "missing",
followerProfileId: "missing",
});
assert.match(removeError, /not found|session/i);
assert.equal(await app.invoke("check_for_app_updates"), null);
assert.equal(await app.invoke("check_for_app_updates_manual"), null);
assert.ok(
await invokeContract(app, "cloud_exchange_device_code", {
code: "DONUT-E2E-INVALID-CODE",
}),
);
assert.ok(await invokeContract(app, "cloud_refresh_profile"));
assert.ok(await invokeContract(app, "cloud_get_countries"));
assert.ok(
await invokeContract(app, "create_cloud_location_proxy", {
name: "E2E unavailable cloud proxy",
country: "ZZ",
region: null,
city: null,
isp: null,
}),
);
assert.ok(await invokeContract(app, "cloud_refresh_wayfern_token"));
assert.ok(await invokeContract(app, "trigger_manual_version_update"));
assert.ok(
await invokeContract(app, "clear_all_version_cache_and_refetch"),
);
assert.ok(await invokeContract(app, "check_for_browser_updates"));
await app.invoke("dismiss_update_notification", {
notificationId: "missing-e2e-notification",
});
assert.deepEqual(
await app.invoke("complete_browser_update_with_auto_update", {
browser: "wayfern",
newVersion: "150.0.7871.100",
}),
[],
);
const prepareError = await app.invokeError(
"download_and_prepare_app_update",
{
updateInfo: {
current_version: "0.0.0",
new_version: "0.0.1-e2e",
release_notes: "E2E invalid update contract",
download_url: `${process.env.DONUT_E2E_FIXTURE_URL}/invalid-update.zip`,
is_nightly: false,
published_at: "2026-01-01T00:00:00Z",
manual_update_required: false,
release_page_url: null,
repo_update: false,
checksums_url: null,
asset_digest: null,
},
},
);
assert.match(prepareError, /checksum|verif|Failed to download/i);
const versionStatus = await app.invoke("get_version_update_status");
assert.ok(versionStatus && typeof versionStatus === "object");
assert.equal(typeof (await app.invoke("is_default_browser")), "boolean");
const trial = await app.invoke("get_commercial_trial_status");
assert.ok(trial && typeof trial === "object");
await app.invoke("acknowledge_trial_expiration");
assert.equal(await app.invoke("has_acknowledged_trial_expiration"), true);
await app.invoke("cloud_logout");
assert.equal(await app.invoke("cloud_get_user"), null);
},
{ wayfernTermsAccepted: false },
);
});
+56
View File
@@ -335,6 +335,62 @@ test("settings tabs, command palette filtering, and responsive layout survive re
});
});
test("first-run onboarding stays recoverable, responsive, and platform-aware", async () => {
await withApp(
"ui-onboarding",
async (app) => {
await app.waitForText("Welcome to Donut Browser");
assert.equal(await app.invoke("get_onboarding_completed"), false);
await app.session.command("POST", "/window/rect", {
width: 640,
height: 400,
});
const layout = await app.execute(`
const dialog = document.querySelector("[role='dialog']");
const progress = dialog?.querySelector("[role='progressbar']");
if (!dialog || !progress) return null;
const rect = dialog.getBoundingClientRect();
return {
left: rect.left,
top: rect.top,
right: rect.right,
bottom: rect.bottom,
viewportWidth: innerWidth,
viewportHeight: innerHeight,
progressNow: progress.getAttribute("aria-valuenow"),
};
`);
assert.ok(layout);
assert.ok(layout.left >= 0 && layout.right <= layout.viewportWidth);
assert.ok(layout.top >= 0 && layout.bottom <= layout.viewportHeight);
assert.equal(layout.progressNow, "1");
await app.clickText("Next", { roles: ["button"] });
await app.waitForText("Licensing");
await app.clickText("I understand", { roles: ["button"] });
await app.waitFor(
async () =>
/Allow microphone & camera|Setting things up|Setup failed/.test(
await app.bodyText(),
),
{ description: "platform-appropriate onboarding step" },
);
const body = await app.bodyText();
if (process.platform !== "darwin") {
assert.doesNotMatch(body, /Allow microphone & camera/);
assert.match(body, /Setting things up|Setup failed/);
}
// Setup and the optional product tour are still unfinished, so a crash
// or restart must be able to resume onboarding.
assert.equal(await app.invoke("get_onboarding_completed"), false);
},
{ onboardingCompleted: false },
);
});
test("predefined theme remains rendered across navigation and restart", async () => {
await withApp("ui-theme-predefined", async (app) => {
await app.clickSelector('[aria-label="Settings"]');
+95 -18
View File
@@ -6,9 +6,9 @@ use crate::proxy_manager::PROXY_MANAGER;
use crate::tag_manager::TAG_MANAGER;
use axum::{
extract::{Path, Query, State},
http::{HeaderMap, StatusCode},
http::{header, HeaderMap, Method, StatusCode},
middleware::{self, Next},
response::{Json, Response},
response::{IntoResponse, Json, Response},
routing::get,
Router,
};
@@ -494,14 +494,16 @@ impl ApiServer {
);
listener
}
Err(e) => return Err(format!("Failed to bind to any port: {e}")),
Err(e) => {
return Err(crate::backend_error_with_detail("API_PORT_UNAVAILABLE", e));
}
}
}
};
let actual_port = listener
.local_addr()
.map_err(|e| format!("Failed to get local address: {e}"))?
.map_err(|e| crate::backend_error_with_detail("INTERNAL_ERROR", e))?
.port();
// Create router with OpenAPI documentation
@@ -538,8 +540,7 @@ impl ApiServer {
let api = ApiDoc::openapi();
let v1_routes = v1_routes
// Inert chokepoint (innermost → runs after auth) for the future per-hour
// automation request limit. See rate_limit_middleware.
// Innermost so only authenticated automation requests consume quota.
.layer(middleware::from_fn(rate_limit_middleware))
.layer(middleware::from_fn_with_state(
state.clone(),
@@ -692,18 +693,47 @@ async fn request_logging_middleware(request: axum::extract::Request, next: Next)
response
}
/// Chokepoint for the future per-hour automation request limit. The limit
/// (`requests_per_hour`, default 100) is already plumbed through entitlements;
/// this middleware is intentionally inert today — it resolves the limit but
/// never blocks. To enforce, count authenticated requests per rolling hour and
/// return `StatusCode::TOO_MANY_REQUESTS` once the limit (when > 0) is exceeded.
async fn rate_limit_middleware(
request: axum::extract::Request,
next: Next,
) -> Result<Response, StatusCode> {
let _requests_per_hour = crate::cloud_auth::CLOUD_AUTH.requests_per_hour().await;
// TODO(rate-limit): enforce `_requests_per_hour` for automation routes.
Ok(next.run(request).await)
fn is_automation_request(method: &Method, path: &str) -> bool {
if method != Method::POST {
return false;
}
if matches!(path, "/v1/profiles/batch/run" | "/v1/profiles/batch/stop") {
return true;
}
let Some(profile_action) = path.strip_prefix("/v1/profiles/") else {
return false;
};
let mut segments = profile_action.split('/');
matches!(
(segments.next(), segments.next(), segments.next()),
(Some(_), Some("run" | "open-url" | "kill"), None)
)
}
async fn rate_limit_middleware(request: axum::extract::Request, next: Next) -> Response {
if !is_automation_request(request.method(), request.uri().path()) {
return next.run(request).await;
}
match crate::automation_rate_limiter::check_automation_rate_limit().await {
crate::automation_rate_limiter::RateLimitOutcome::Limited { retry_after_secs } => {
log::warn!(
"[api] Rejected {}: automation rate limit exceeded; retry in {}s",
request.uri().path(),
retry_after_secs
);
(
StatusCode::TOO_MANY_REQUESTS,
[(header::RETRY_AFTER, retry_after_secs.to_string())],
"automation request rate limit exceeded",
)
.into_response()
}
crate::automation_rate_limiter::RateLimitOutcome::Unlimited
| crate::automation_rate_limiter::RateLimitOutcome::Allowed { .. } => next.run(request).await,
}
}
// Global API server instance
@@ -2036,6 +2066,7 @@ async fn delete_extension_group_api(
(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 = 429, description = "Automation request rate limit exceeded"),
(status = 500, description = "Internal server error")
),
security(
@@ -2124,6 +2155,7 @@ async fn run_profile(
(status = 401, description = "Unauthorized"),
(status = 402, description = "Active paid plan with browser automation required"),
(status = 404, description = "Profile not found"),
(status = 429, description = "Automation request rate limit exceeded"),
(status = 500, description = "Internal server error")
),
security(
@@ -2165,6 +2197,7 @@ async fn open_url_in_profile(
(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 = 500, description = "Internal server error")
),
security(
@@ -2217,6 +2250,7 @@ async fn kill_profile(
(status = 200, description = "Batch launch completed; inspect per-profile results", body = BatchRunResponse),
(status = 401, description = "Unauthorized"),
(status = 402, description = "Active paid plan with browser automation required"),
(status = 429, description = "Automation request rate limit exceeded"),
(status = 500, description = "Internal server error")
),
security(
@@ -2312,6 +2346,7 @@ async fn batch_run_profiles(
(status = 200, description = "Batch stop completed; inspect per-profile results", body = BatchStopResponse),
(status = 401, description = "Unauthorized"),
(status = 402, description = "Active paid plan with browser automation required"),
(status = 429, description = "Automation request rate limit exceeded"),
(status = 500, description = "Internal server error")
),
security(
@@ -2672,6 +2707,35 @@ mod tests {
assert!(!is_valid(""));
}
#[test]
fn rate_limit_only_classifies_browser_automation_routes() {
for path in [
"/v1/profiles/profile-id/run",
"/v1/profiles/profile-id/open-url",
"/v1/profiles/profile-id/kill",
"/v1/profiles/batch/run",
"/v1/profiles/batch/stop",
] {
assert!(
is_automation_request(&Method::POST, path),
"automation route was not limited: {path}"
);
}
for (method, path) in [
(Method::GET, "/v1/profiles/profile-id/run"),
(Method::POST, "/v1/profiles"),
(Method::POST, "/v1/profiles/import"),
(Method::GET, "/v1/profiles"),
(Method::GET, "/openapi.json"),
] {
assert!(
!is_automation_request(&method, path),
"free or non-mutating route was limited: {method} {path}"
);
}
}
fn schema_required(spec: &serde_json::Value, schema: &str) -> Vec<String> {
spec["components"]["schemas"][schema]["required"]
.as_array()
@@ -2764,5 +2828,18 @@ mod tests {
!paths.keys().any(|p| p.contains("wayfern-token")),
"wayfern-token endpoints were removed and must stay out of the spec"
);
for path in [
"/v1/profiles/{id}/run",
"/v1/profiles/{id}/open-url",
"/v1/profiles/{id}/kill",
"/v1/profiles/batch/run",
"/v1/profiles/batch/stop",
] {
assert!(
paths[path]["post"]["responses"].get("429").is_some(),
"automation route is missing its 429 response: {path}"
);
}
}
}
+137
View File
@@ -0,0 +1,137 @@
use std::collections::{HashMap, VecDeque};
use std::sync::{LazyLock, Mutex};
use std::time::{Duration, Instant};
use crate::cloud_auth::CLOUD_AUTH;
const RATE_LIMIT_WINDOW: Duration = Duration::from_secs(60 * 60);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RateLimitOutcome {
Unlimited,
Allowed { remaining: u64 },
Limited { retry_after_secs: u64 },
}
#[derive(Default)]
struct AutomationRateLimiter {
requests: HashMap<String, VecDeque<Instant>>,
}
impl AutomationRateLimiter {
fn check_at(&mut self, identity: &str, requests_per_hour: u64, now: Instant) -> RateLimitOutcome {
if requests_per_hour == 0 {
return RateLimitOutcome::Unlimited;
}
self.requests.retain(|_, requests| {
while requests
.front()
.is_some_and(|started| now.duration_since(*started) >= RATE_LIMIT_WINDOW)
{
requests.pop_front();
}
!requests.is_empty()
});
let requests = self.requests.entry(identity.to_string()).or_default();
if requests.len() as u64 >= requests_per_hour {
let retry_after_secs = requests
.front()
.map(|started| {
let remaining = RATE_LIMIT_WINDOW.saturating_sub(now.duration_since(*started));
remaining
.as_secs()
.saturating_add(u64::from(remaining.subsec_nanos() > 0))
.max(1)
})
.unwrap_or(1);
return RateLimitOutcome::Limited { retry_after_secs };
}
requests.push_back(now);
RateLimitOutcome::Allowed {
remaining: requests_per_hour.saturating_sub(requests.len() as u64),
}
}
}
static AUTOMATION_RATE_LIMITER: LazyLock<Mutex<AutomationRateLimiter>> =
LazyLock::new(|| Mutex::new(AutomationRateLimiter::default()));
pub async fn check_automation_rate_limit() -> RateLimitOutcome {
let Some((identity, requests_per_hour)) = CLOUD_AUTH.automation_rate_limit().await else {
return RateLimitOutcome::Unlimited;
};
AUTOMATION_RATE_LIMITER
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.check_at(&identity, requests_per_hour, Instant::now())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rolling_window_limits_per_identity_and_recovers() {
let mut limiter = AutomationRateLimiter::default();
let now = Instant::now();
assert_eq!(
limiter.check_at("user-a", 2, now),
RateLimitOutcome::Allowed { remaining: 1 }
);
assert_eq!(
limiter.check_at("user-a", 2, now + Duration::from_secs(1)),
RateLimitOutcome::Allowed { remaining: 0 }
);
assert_eq!(
limiter.check_at("user-a", 2, now + Duration::from_secs(2)),
RateLimitOutcome::Limited {
retry_after_secs: 3598
}
);
assert_eq!(
limiter.check_at("user-b", 2, now + Duration::from_secs(2)),
RateLimitOutcome::Allowed { remaining: 1 }
);
assert_eq!(
limiter.check_at("user-a", 2, now + RATE_LIMIT_WINDOW),
RateLimitOutcome::Allowed { remaining: 0 }
);
assert_eq!(
limiter.check_at(
"user-a",
2,
now + RATE_LIMIT_WINDOW + Duration::from_secs(1)
),
RateLimitOutcome::Allowed { remaining: 0 }
);
assert_eq!(
limiter.check_at(
"user-a",
2,
now + RATE_LIMIT_WINDOW * 2 + Duration::from_secs(1)
),
RateLimitOutcome::Allowed { remaining: 1 }
);
}
#[test]
fn zero_limit_is_unlimited_and_does_not_consume_capacity() {
let mut limiter = AutomationRateLimiter::default();
let now = Instant::now();
assert_eq!(
limiter.check_at("user-a", 0, now),
RateLimitOutcome::Unlimited
);
assert_eq!(
limiter.check_at("user-a", 1, now),
RateLimitOutcome::Allowed { remaining: 0 }
);
}
}
+19 -154
View File
@@ -23,8 +23,7 @@ pub const CLOUD_API_URL: &str = "https://api.donutbrowser.com";
pub const CLOUD_SYNC_URL: &str = "https://sync.donutbrowser.com";
/// Default per-hour cap on local automation API / MCP requests. Mirrors the
/// backend's DEFAULT_REQUESTS_PER_HOUR. Not enforced yet — see the inert
/// rate-limit chokepoints in api_server / mcp_server.
/// backend's DEFAULT_REQUESTS_PER_HOUR.
const DEFAULT_REQUESTS_PER_HOUR: i64 = 100;
/// Capability + limit set the account is entitled to, derived from its plan.
@@ -828,14 +827,24 @@ impl CloudAuthManager {
}
}
/// Per-hour cap on automation requests (0 when automation is unavailable).
/// Carried for the future local rate limiter; read by the inert chokepoints.
pub async fn requests_per_hour(&self) -> i64 {
self
.entitlements()
.await
.map(|e| e.requests_per_hour)
.unwrap_or(0)
/// Identity and positive per-hour cap for the shared REST/MCP automation
/// limiter. No active automation entitlement means no limiter entry; the
/// capability gates still reject paid operations independently.
pub async fn automation_rate_limit(&self) -> Option<(String, u64)> {
#[cfg(feature = "e2e")]
if crate::e2e_automation_enabled() {
if let Ok(limit) = std::env::var("DONUT_E2E_REQUESTS_PER_HOUR") {
if let Ok(limit) = limit.parse::<u64>() {
if limit > 0 {
return Some(("e2e-automation".to_string(), limit));
}
}
}
}
let state = self.get_user().await?;
let limit = state.user.entitlements().requests_per_hour;
(limit > 0).then_some((state.user.id, limit as u64))
}
pub async fn is_fingerprint_os_allowed(&self, fingerprint_os: Option<&str>) -> bool {
@@ -1040,126 +1049,6 @@ impl CloudAuthManager {
.await
}
/// Fetch region list for a country from the cloud backend
pub async fn fetch_regions(&self, country: &str) -> Result<Vec<LocationItem>, String> {
let country = country.to_string();
self
.api_call_with_retry(move |access_token| {
let url = format!(
"{CLOUD_API_URL}/api/proxy/locations/regions?country={}",
country
);
let client = reqwest::Client::new();
async move {
let response = client
.get(&url)
.header("Authorization", format!("Bearer {access_token}"))
.send()
.await
.map_err(|e| format!("Failed to fetch regions: {e}"))?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(format!("Regions fetch failed ({status}): {body}"));
}
response
.json::<Vec<LocationItem>>()
.await
.map_err(|e| format!("Failed to parse regions: {e}"))
}
})
.await
}
/// Fetch city list for a country, optionally filtered by region
pub async fn fetch_cities(
&self,
country: &str,
region: Option<&str>,
) -> Result<Vec<LocationItem>, String> {
let country = country.to_string();
let region = region.map(|s| s.to_string());
self
.api_call_with_retry(move |access_token| {
let mut url = format!(
"{CLOUD_API_URL}/api/proxy/locations/cities?country={}",
country
);
if let Some(ref r) = region {
url.push_str(&format!("&region={}", r));
}
let client = reqwest::Client::new();
async move {
let response = client
.get(&url)
.header("Authorization", format!("Bearer {access_token}"))
.send()
.await
.map_err(|e| format!("Failed to fetch cities: {e}"))?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(format!("Cities fetch failed ({status}): {body}"));
}
response
.json::<Vec<LocationItem>>()
.await
.map_err(|e| format!("Failed to parse cities: {e}"))
}
})
.await
}
/// Fetch ISP list for a country, optionally filtered by region and city
pub async fn fetch_isps(
&self,
country: &str,
region: Option<&str>,
city: Option<&str>,
) -> Result<Vec<LocationItem>, String> {
let country = country.to_string();
let region = region.map(|s| s.to_string());
let city = city.map(|s| s.to_string());
self
.api_call_with_retry(move |access_token| {
let mut url = format!(
"{CLOUD_API_URL}/api/proxy/locations/isps?country={}",
country
);
if let Some(ref r) = region {
url.push_str(&format!("&region={}", r));
}
if let Some(ref c) = city {
url.push_str(&format!("&city={}", c));
}
let client = reqwest::Client::new();
async move {
let response = client
.get(&url)
.header("Authorization", format!("Bearer {access_token}"))
.send()
.await
.map_err(|e| format!("Failed to fetch ISPs: {e}"))?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(format!("ISPs fetch failed ({status}): {body}"));
}
response
.json::<Vec<LocationItem>>()
.await
.map_err(|e| format!("Failed to parse ISPs: {e}"))
}
})
.await
}
/// Request a wayfern token from the cloud API. Only succeeds for paid users.
pub async fn request_wayfern_token(&self) -> Result<(), String> {
if !self.has_active_paid_subscription().await {
@@ -1459,30 +1348,6 @@ pub async fn cloud_get_countries() -> Result<Vec<LocationItem>, String> {
CLOUD_AUTH.fetch_countries().await
}
#[tauri::command]
pub async fn cloud_get_regions(country: String) -> Result<Vec<LocationItem>, String> {
CLOUD_AUTH.fetch_regions(&country).await
}
#[tauri::command]
pub async fn cloud_get_cities(
country: String,
region: Option<String>,
) -> Result<Vec<LocationItem>, String> {
CLOUD_AUTH.fetch_cities(&country, region.as_deref()).await
}
#[tauri::command]
pub async fn cloud_get_isps(
country: String,
region: Option<String>,
city: Option<String>,
) -> Result<Vec<LocationItem>, String> {
CLOUD_AUTH
.fetch_isps(&country, region.as_deref(), city.as_deref())
.await
}
#[tauri::command]
pub async fn create_cloud_location_proxy(
name: String,
+53 -24
View File
@@ -15,6 +15,14 @@ static PENDING_URLS: Mutex<Vec<String>> = Mutex::new(Vec::new());
// to the confirmation dialog.
static QUIT_CONFIRMED: AtomicBool = AtomicBool::new(false);
pub(crate) fn backend_error(code: &str) -> String {
serde_json::json!({ "code": code }).to_string()
}
pub(crate) fn backend_error_with_detail(code: &str, detail: impl std::fmt::Display) -> String {
serde_json::json!({ "code": code, "params": { "detail": detail.to_string() } }).to_string()
}
fn e2e_automation_enabled() -> bool {
#[cfg(feature = "e2e")]
{
@@ -39,6 +47,7 @@ mod api_server;
mod app_auto_updater;
pub mod app_dirs;
mod auto_updater;
mod automation_rate_limiter;
mod browser;
mod browser_runner;
mod browser_version_manager;
@@ -271,15 +280,14 @@ async fn handle_url_open(app: tauri::AppHandle, url: String) -> Result<(), Strin
Ok(())
}
/// Prefix a command error with context, but pass structured `{"code": ...}`
/// backend errors through untouched the frontend can only translate a code
/// when the JSON is the entire message (see src/lib/backend-errors.ts).
/// Preserve structured backend errors and wrap lower-level diagnostics in the
/// generic structured error shape expected by the frontend.
pub(crate) fn wrap_backend_error(e: impl std::fmt::Display, context: &str) -> String {
let msg = e.to_string();
if msg.starts_with('{') {
msg
} else {
format!("{context}: {msg}")
backend_error_with_detail("INTERNAL_ERROR", format!("{context}: {msg}"))
}
}
@@ -531,14 +539,14 @@ async fn get_mcp_config(app_handle: tauri::AppHandle) -> Result<Option<McpConfig
let port = mcp_server
.get_port()
.ok_or("MCP server port not available")?;
.ok_or_else(|| backend_error("MCP_CONFIGURATION_UNAVAILABLE"))?;
let settings_manager = settings_manager::SettingsManager::instance();
let token = settings_manager
.get_mcp_token(&app_handle)
.await
.map_err(|e| format!("Failed to get MCP token: {e}"))?
.ok_or("MCP token not found")?;
.map_err(|e| backend_error_with_detail("INTERNAL_ERROR", e))?
.ok_or_else(|| backend_error("MCP_CONFIGURATION_UNAVAILABLE"))?;
Ok(Some(McpConfig { port, token }))
}
@@ -739,13 +747,15 @@ fn update_claude_extensions_registry(
async fn current_mcp_url(app_handle: &tauri::AppHandle) -> Result<String, String> {
let mcp_server = mcp_server::McpServer::instance();
let port = mcp_server.get_port().ok_or("MCP server is not running")?;
let port = mcp_server
.get_port()
.ok_or_else(|| backend_error("MCP_SERVER_NOT_RUNNING"))?;
let settings_manager = settings_manager::SettingsManager::instance();
let token = settings_manager
.get_mcp_token(app_handle)
.await
.map_err(|e| format!("Failed to get MCP token: {e}"))?
.ok_or("MCP token not found")?;
.map_err(|e| backend_error_with_detail("INTERNAL_ERROR", e))?
.ok_or_else(|| backend_error("MCP_CONFIGURATION_UNAVAILABLE"))?;
Ok(format!("http://127.0.0.1:{port}/mcp/{token}"))
}
@@ -761,24 +771,28 @@ async fn list_mcp_agents() -> Result<Vec<mcp_integrations::McpAgentInfo>, String
#[tauri::command]
async fn add_mcp_to_agent(app_handle: tauri::AppHandle, agent_id: String) -> Result<(), String> {
if !mcp_integrations::agent_exists(&agent_id) {
return Err(format!("Unknown agent: {agent_id}"));
return Err(backend_error("MCP_AGENT_UNKNOWN"));
}
if agent_id == "claude-desktop" {
return add_mcp_to_claude_desktop_internal(&app_handle).await;
}
let url = current_mcp_url(&app_handle).await?;
mcp_integrations::install_generic(&agent_id, &url)
let result = if agent_id == "claude-desktop" {
add_mcp_to_claude_desktop_internal(&app_handle).await
} else {
let url = current_mcp_url(&app_handle).await?;
mcp_integrations::install_generic(&agent_id, &url)
};
result.map_err(|e| backend_error_with_detail("MCP_AGENT_INSTALL_FAILED", e))
}
#[tauri::command]
async fn remove_mcp_from_agent(agent_id: String) -> Result<(), String> {
if !mcp_integrations::agent_exists(&agent_id) {
return Err(format!("Unknown agent: {agent_id}"));
return Err(backend_error("MCP_AGENT_UNKNOWN"));
}
if agent_id == "claude-desktop" {
return remove_mcp_from_claude_desktop_internal();
}
mcp_integrations::uninstall_generic(&agent_id)
let result = if agent_id == "claude-desktop" {
remove_mcp_from_claude_desktop_internal()
} else {
mcp_integrations::uninstall_generic(&agent_id)
};
result.map_err(|e| backend_error_with_detail("MCP_AGENT_REMOVE_FAILED", e))
}
#[tauri::command]
@@ -2432,9 +2446,6 @@ pub fn run_with_builder(
cloud_auth::cloud_logout,
cloud_auth::cloud_get_proxy_usage,
cloud_auth::cloud_get_countries,
cloud_auth::cloud_get_regions,
cloud_auth::cloud_get_cities,
cloud_auth::cloud_get_isps,
cloud_auth::create_cloud_location_proxy,
cloud_auth::restart_sync_service,
cloud_auth::cloud_get_wayfern_token,
@@ -2481,6 +2492,24 @@ pub fn run_with_builder(
mod tests {
use std::fs;
#[test]
fn backend_error_helpers_preserve_codes_and_structure_diagnostics() {
let coded = super::backend_error("PROFILE_NOT_FOUND");
assert_eq!(
serde_json::from_str::<serde_json::Value>(&coded).unwrap()["code"],
"PROFILE_NOT_FOUND"
);
assert_eq!(super::wrap_backend_error(&coded, "ignored"), coded);
let wrapped = super::wrap_backend_error("disk unavailable", "Failed to save");
let parsed = serde_json::from_str::<serde_json::Value>(&wrapped).unwrap();
assert_eq!(parsed["code"], "INTERNAL_ERROR");
assert_eq!(
parsed["params"]["detail"],
"Failed to save: disk unavailable"
);
}
#[test]
fn test_no_unused_tauri_commands() {
check_unused_commands(false); // Run in strict mode for CI
+117 -44
View File
@@ -184,19 +184,17 @@ impl McpServer {
pub async fn start(&self, app_handle: AppHandle) -> Result<u16, String> {
if !WayfernTermsManager::instance().is_terms_accepted() {
return Err(
"Wayfern Terms and Conditions must be accepted before starting MCP server".to_string(),
);
return Err(crate::backend_error("WAYFERN_TERMS_REQUIRED"));
}
if self.is_running() {
return Err("MCP server is already running".to_string());
return Err(crate::backend_error("MCP_SERVER_ALREADY_RUNNING"));
}
let settings_manager = SettingsManager::instance();
let settings = settings_manager
.load_settings()
.map_err(|e| format!("Failed to load settings: {e}"))?;
.map_err(|e| crate::backend_error_with_detail("INTERNAL_ERROR", e))?;
// Get or generate token
let existing_token = settings_manager
@@ -211,12 +209,16 @@ impl McpServer {
settings_manager
.generate_mcp_token(&app_handle)
.await
.map_err(|e| format!("Failed to generate MCP token: {e}"))?
.map_err(|e| crate::backend_error_with_detail("INTERNAL_ERROR", e))?
};
// Determine port (use saved port, or try default, or random)
let preferred_port = settings.mcp_port.unwrap_or(DEFAULT_MCP_PORT);
let actual_port = self.bind_to_available_port(preferred_port).await?;
let listener = self.bind_to_available_port(preferred_port).await?;
let actual_port = listener
.local_addr()
.map_err(|e| crate::backend_error_with_detail("INTERNAL_ERROR", e))?
.port();
// Save port if it changed
if settings.mcp_port != Some(actual_port) {
@@ -224,7 +226,7 @@ impl McpServer {
new_settings.mcp_port = Some(actual_port);
settings_manager
.save_settings(&new_settings)
.map_err(|e| format!("Failed to save settings: {e}"))?;
.map_err(|e| crate::backend_error_with_detail("INTERNAL_ERROR", e))?;
}
// Store state
@@ -244,31 +246,31 @@ impl McpServer {
server: McpServer::instance(),
token,
};
tokio::spawn(Self::run_http_server(actual_port, http_state, shutdown_rx));
tokio::spawn(Self::run_http_server(listener, http_state, shutdown_rx));
log::info!("[mcp] Server started on port {}", actual_port);
Ok(actual_port)
}
async fn bind_to_available_port(&self, preferred: u16) -> Result<u16, String> {
async fn bind_to_available_port(&self, preferred: u16) -> Result<TcpListener, String> {
let addr = SocketAddr::from(([127, 0, 0, 1], preferred));
if TcpListener::bind(addr).await.is_ok() {
return Ok(preferred);
if let Ok(listener) = TcpListener::bind(addr).await {
return Ok(listener);
}
for _ in 0..10 {
let port = 51000 + (rand::random::<u16>() % 1000);
let addr = SocketAddr::from(([127, 0, 0, 1], port));
if TcpListener::bind(addr).await.is_ok() {
return Ok(port);
if let Ok(listener) = TcpListener::bind(addr).await {
return Ok(listener);
}
}
Err("Could not find available port for MCP server".to_string())
Err(crate::backend_error("MCP_PORT_UNAVAILABLE"))
}
async fn run_http_server(
port: u16,
listener: TcpListener,
state: McpHttpState,
shutdown_rx: tokio::sync::oneshot::Receiver<()>,
) {
@@ -286,28 +288,17 @@ impl McpServer {
.delete(Self::handle_mcp_delete),
)
.route("/health", get(Self::handle_health))
// Inert chokepoint (innermost → runs after auth) for the future per-hour
// automation request limit. See rate_limit_middleware.
.layer(middleware::from_fn(Self::rate_limit_middleware))
.layer(middleware::from_fn_with_state(
state.clone(),
Self::auth_middleware,
))
.with_state(state);
let addr = SocketAddr::from(([127, 0, 0, 1], port));
let server = async {
match TcpListener::bind(addr).await {
Ok(listener) => {
log::info!("[mcp] Server listening on http://127.0.0.1:{}/mcp", port);
if let Err(e) = axum::serve(listener, app).await {
log::error!("[mcp] Server error: {}", e);
}
}
Err(e) => {
log::error!("[mcp] Failed to bind on port {}: {}", port, e);
}
let port = listener.local_addr().map(|addr| addr.port()).unwrap_or(0);
let server = async move {
log::info!("[mcp] Server listening on http://127.0.0.1:{}/mcp", port);
if let Err(e) = axum::serve(listener, app).await {
log::error!("[mcp] Server error: {}", e);
}
};
@@ -319,17 +310,6 @@ impl McpServer {
}
}
/// Chokepoint for the future per-hour automation request limit, mirroring the
/// REST API's. The limit (`requests_per_hour`, default 100) is plumbed through
/// entitlements; this is intentionally inert today — it resolves the limit but
/// never blocks. To enforce, count authenticated tool calls per rolling hour
/// and return StatusCode::TOO_MANY_REQUESTS once the limit (when > 0) is hit.
async fn rate_limit_middleware(req: Request<Body>, next: Next) -> Result<Response, StatusCode> {
let _requests_per_hour = CLOUD_AUTH.requests_per_hour().await;
// TODO(rate-limit): enforce `_requests_per_hour` for MCP tool calls.
Ok(next.run(req).await)
}
async fn auth_middleware(
State(state): State<McpHttpState>,
req: Request<Body>,
@@ -476,14 +456,65 @@ impl McpServer {
}
}
if Self::is_automation_tool_call(&request) {
if let crate::automation_rate_limiter::RateLimitOutcome::Limited { retry_after_secs } =
crate::automation_rate_limiter::check_automation_rate_limit().await
{
log::warn!(
"[mcp] Rejected tools/call: automation rate limit exceeded; retry in {}s",
retry_after_secs
);
return (
StatusCode::TOO_MANY_REQUESTS,
[(header::RETRY_AFTER, retry_after_secs.to_string())],
"automation request rate limit exceeded",
)
.into_response();
}
}
let response = state.server.handle_request(request).await;
Json(response).into_response()
}
}
fn is_automation_tool_call(request: &McpRequest) -> bool {
if request.method != "tools/call" {
return false;
}
let Some(tool_name) = request
.params
.as_ref()
.and_then(|params| params.get("name"))
.and_then(|name| name.as_str())
else {
return false;
};
matches!(
tool_name,
"run_profile"
| "kill_profile"
| "batch_run_profiles"
| "batch_stop_profiles"
| "start_sync_session"
| "navigate"
| "screenshot"
| "evaluate_javascript"
| "click_element"
| "type_text"
| "get_page_content"
| "get_page_info"
| "get_interactive_elements"
| "click_by_index"
| "type_by_index"
)
}
pub async fn stop(&self) -> Result<(), String> {
if !self.is_running() {
return Err("MCP server is not running".to_string());
return Err(crate::backend_error("MCP_SERVER_NOT_RUNNING"));
}
let mut inner = self.inner.lock().await;
@@ -5528,4 +5559,46 @@ mod tests {
let server = McpServer::new();
assert!(!server.is_running());
}
#[test]
fn rate_limit_only_classifies_browser_automation_tools() {
let request = |method: &str, name: Option<&str>| McpRequest {
jsonrpc: "2.0".to_string(),
id: Some(serde_json::json!(1)),
method: method.to_string(),
params: name.map(|name| serde_json::json!({ "name": name, "arguments": {} })),
};
for name in [
"run_profile",
"kill_profile",
"batch_run_profiles",
"batch_stop_profiles",
"start_sync_session",
"navigate",
"screenshot",
"evaluate_javascript",
"click_element",
"type_text",
"get_page_content",
"get_page_info",
"get_interactive_elements",
"click_by_index",
"type_by_index",
] {
assert!(
McpServer::is_automation_tool_call(&request("tools/call", Some(name))),
"automation tool was not limited: {name}"
);
}
assert!(!McpServer::is_automation_tool_call(&request(
"tools/call",
Some("list_profiles")
)));
assert!(!McpServer::is_automation_tool_call(&request(
"tools/list",
None
)));
}
}
+35 -16
View File
@@ -69,6 +69,7 @@ import { translateBackendError } from "@/lib/backend-errors";
import { getEntitlements } from "@/lib/entitlements";
import { MOTION_EASE_OUT } from "@/lib/motion";
import {
ONBOARDING_TOUR_CLOSED_EVENT,
ONBOARDING_TOUR_FINISHED_EVENT,
setOnboardingActive,
} from "@/lib/onboarding-signal";
@@ -113,31 +114,46 @@ export default function Home() {
const onboardingHandledRef = useRef(false);
const [welcomeOpen, setWelcomeOpen] = useState(false);
const [thankYouOpen, setThankYouOpen] = useState(false);
// null = onboarding decision pending; false = not a first-run onboarding (run
// the normal permission checks); true = first-run onboarding, so the welcome
// flow drives permissions and the standalone permission dialog is suppressed.
// null = onboarding decision pending; false = not a first-run session (run
// normal permission checks); true = first-run session, so "Not now" really
// defers the standalone permission dialog until a later launch.
const [firstRunOnboarding, setFirstRunOnboarding] = useState<boolean | null>(
null,
);
const persistOnboardingComplete = useCallback(() => {
void invoke("complete_onboarding").catch((err: unknown) => {
console.error("Failed to persist onboarding completion:", err);
});
}, []);
// Welcome flow finished. Existing-profile users are done after the welcome +
// commercial-use steps; users with no profile yet continue into the in-app
// product tour that walks them through creating their first profile.
const handleWelcomeComplete = useCallback(() => {
setWelcomeOpen(false);
setFirstRunOnboarding(false);
if (profiles.length === 0) {
startOnborda(ONBOARDING_TOUR);
} else {
persistOnboardingComplete();
}
}, [startOnborda, profiles.length]);
}, [persistOnboardingComplete, profiles.length, startOnborda]);
// The product tour finished (user clicked "Finish", not "Skip") → celebrate.
// Finishing or explicitly skipping the product tour completes the one-shot
// onboarding. Only reaching the end triggers the celebration.
useEffect(() => {
const handler = () => setThankYouOpen(true);
window.addEventListener(ONBOARDING_TOUR_FINISHED_EVENT, handler);
return () =>
window.removeEventListener(ONBOARDING_TOUR_FINISHED_EVENT, handler);
}, []);
const handleClosed = () => persistOnboardingComplete();
const handleFinished = () => setThankYouOpen(true);
window.addEventListener(ONBOARDING_TOUR_CLOSED_EVENT, handleClosed);
window.addEventListener(ONBOARDING_TOUR_FINISHED_EVENT, handleFinished);
return () => {
window.removeEventListener(ONBOARDING_TOUR_CLOSED_EVENT, handleClosed);
window.removeEventListener(
ONBOARDING_TOUR_FINISHED_EVENT,
handleFinished,
);
};
}, [persistOnboardingComplete]);
// Suppress the global browser-download toasts while onboarding (welcome or
// tour) is active — the welcome dialog shows setup progress itself.
@@ -162,8 +178,9 @@ export default function Home() {
return () => window.removeEventListener("scroll", pin, true);
}, [isOnbordaVisible]);
// On the very first launch, always show the welcome + commercial-use steps
// (one-shot: the backend flag is set immediately so it can't trigger again).
// On the very first launch, always show the welcome + commercial-use steps.
// The completion flag is only persisted after the user finishes or skips the
// full flow, so an interrupted setup can recover on the next launch.
// The welcome dialog itself decides whether to continue into the browser
// download + profile-creation flow — only when the user has no profile yet.
useEffect(() => {
@@ -176,7 +193,6 @@ export default function Home() {
setFirstRunOnboarding(false);
return;
}
await invoke("complete_onboarding");
setFirstRunOnboarding(true);
setWelcomeOpen(true);
} catch (err) {
@@ -191,8 +207,11 @@ export default function Home() {
useEffect(() => {
if (isOnbordaVisible && currentStep === 0 && profiles.length > 0) {
// Small delay so the new profile row (and its DNS dropdown target) has
// mounted before Onborda re-points at it.
setCurrentStep(1, 300);
// mounted before Onborda re-points at it. Owning the timeout here lets
// cleanup cancel it if the tour closes, instead of reopening a dismissed
// tour when Onborda's delayed setter eventually fires.
const timeout = window.setTimeout(() => setCurrentStep(1), 300);
return () => window.clearTimeout(timeout);
}
}, [isOnbordaVisible, currentStep, profiles.length, setCurrentStep]);
@@ -29,14 +29,6 @@ export interface ConsistencyResult {
const GLOBAL_DISABLE_KEY = "consistency-warn-disabled";
const perProfileKey = (id: string) => `consistency-warn-skip-${id}`;
export function isConsistencyWarningEnabled(): boolean {
try {
return localStorage.getItem(GLOBAL_DISABLE_KEY) !== "1";
} catch {
return true;
}
}
export function isConsistencyWarningSuppressed(profileId: string): boolean {
try {
return (
-25
View File
@@ -1,25 +0,0 @@
export const ZenBrowser = (props: React.SVGProps<SVGSVGElement>) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width={24}
height={24}
role="graphics-symbol img"
fill="currentColor"
viewBox="0 0 24 24"
{...props}
>
<title>Zen Browser</title>
<path
d="M12 8.15c-2.12 0-3.85 1.72-3.85 3.85s1.72 3.85 3.85 3.85 3.85-1.72 3.85-3.85S14.13 8.15 12 8.15m0 6.92c-1.7 0-3.08-1.38-3.08-3.08S10.3 8.91 12 8.91s3.08 1.38 3.08 3.08-1.38 3.08-3.08 3.08"
className="b"
/>
<path
d="M12 5.33c-3.68 0-6.67 2.98-6.67 6.67s2.98 6.67 6.67 6.67 6.67-2.98 6.67-6.67S15.69 5.33 12 5.33m0 12.05c-2.97 0-5.38-2.41-5.38-5.38S9.03 6.62 12 6.62s5.38 2.41 5.38 5.38-2.41 5.38-5.38 5.38"
className="b"
/>
<path
d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2m0 18.2c-4.53 0-8.21-3.67-8.21-8.2S7.47 3.79 12 3.79s8.21 3.67 8.21 8.21-3.67 8.2-8.21 8.2"
className="b"
/>
</svg>
);
+3 -8
View File
@@ -213,8 +213,7 @@ export function IntegrationsDialog({
} catch (e) {
console.error("Failed to toggle API:", e);
showErrorToast(t("integrations.apiToggleFailed"), {
description:
e instanceof Error ? e.message : t("integrations.apiUnknownError"),
description: translateBackendError(t, e),
});
} finally {
setIsApiStarting(false);
@@ -245,8 +244,7 @@ export function IntegrationsDialog({
} catch (e) {
console.error("Failed to toggle MCP server:", e);
showErrorToast(t("integrations.mcpToggleFailed"), {
description:
e instanceof Error ? e.message : t("integrations.apiUnknownError"),
description: translateBackendError(t, e),
});
} finally {
setIsMcpStarting(false);
@@ -452,10 +450,7 @@ export function IntegrationsDialog({
showErrorToast(
t("integrations.apiStartFailed"),
{
description:
e instanceof Error
? e.message
: t("integrations.apiUnknownError"),
description: translateBackendError(t, e),
},
);
} finally {
-358
View File
@@ -1,358 +0,0 @@
"use client";
import { invoke } from "@tauri-apps/api/core";
import { emit } from "@tauri-apps/api/event";
import { Loader2 } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Combobox } from "@/components/ui/combobox";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import type { LocationItem } from "@/types";
import { RippleButton } from "./ui/ripple";
interface LocationProxyDialogProps {
isOpen: boolean;
onClose: () => void;
}
function LoadingSpinner() {
return <Loader2 className="size-4 animate-spin text-muted-foreground" />;
}
export function LocationProxyDialog({
isOpen,
onClose,
}: LocationProxyDialogProps) {
const { t } = useTranslation();
const [countries, setCountries] = useState<LocationItem[]>([]);
const [regions, setRegions] = useState<LocationItem[]>([]);
const [cities, setCities] = useState<LocationItem[]>([]);
const [isps, setIsps] = useState<LocationItem[]>([]);
const [selectedCountry, setSelectedCountry] = useState("");
const [selectedRegion, setSelectedRegion] = useState("");
const [selectedCity, setSelectedCity] = useState("");
const [selectedIsp, setSelectedIsp] = useState("");
const [proxyName, setProxyName] = useState("");
const [isLoadingCountries, setIsLoadingCountries] = useState(false);
const [isLoadingRegions, setIsLoadingRegions] = useState(false);
const [isLoadingCities, setIsLoadingCities] = useState(false);
const [isLoadingIsps, setIsLoadingIsps] = useState(false);
const [isCreating, setIsCreating] = useState(false);
const handleClose = useCallback(() => {
setSelectedCountry("");
setSelectedRegion("");
setSelectedCity("");
setSelectedIsp("");
setProxyName("");
setRegions([]);
setCities([]);
setIsps([]);
onClose();
}, [onClose]);
// Fetch countries on mount
useEffect(() => {
if (!isOpen) return;
setIsLoadingCountries(true);
void invoke<LocationItem[]>("cloud_get_countries")
.then((data) => {
setCountries(data);
})
.catch((err) => {
console.error("Failed to fetch countries:", err);
toast.error(t("locationProxy.loadFailed"));
})
.finally(() => {
setIsLoadingCountries(false);
});
}, [isOpen, t]);
// Fetch regions when country changes
useEffect(() => {
if (!selectedCountry) {
setRegions([]);
return;
}
setIsLoadingRegions(true);
setSelectedRegion("");
setSelectedCity("");
setSelectedIsp("");
setCities([]);
setIsps([]);
void invoke<LocationItem[]>("cloud_get_regions", {
country: selectedCountry,
})
.then((data) => {
setRegions(data);
})
.catch((err) => {
console.error("Failed to fetch regions:", err);
})
.finally(() => {
setIsLoadingRegions(false);
});
}, [selectedCountry]);
// Fetch cities when country or region changes (cities can be loaded without region)
useEffect(() => {
if (!selectedCountry) {
setCities([]);
return;
}
setIsLoadingCities(true);
setSelectedCity("");
const args: { country: string; region?: string } = {
country: selectedCountry,
};
if (selectedRegion) {
args.region = selectedRegion;
}
void invoke<LocationItem[]>("cloud_get_cities", args)
.then((data) => {
setCities(data);
})
.catch((err) => {
console.error("Failed to fetch cities:", err);
})
.finally(() => {
setIsLoadingCities(false);
});
}, [selectedCountry, selectedRegion]);
// Fetch ISPs when country/region/city changes
useEffect(() => {
if (!selectedCountry) {
setIsps([]);
return;
}
setIsLoadingIsps(true);
setSelectedIsp("");
const args: { country: string; region?: string; city?: string } = {
country: selectedCountry,
};
if (selectedRegion) args.region = selectedRegion;
if (selectedCity) args.city = selectedCity;
void invoke<LocationItem[]>("cloud_get_isps", args)
.then((data) => {
setIsps(data);
})
.catch((err) => {
console.error("Failed to fetch ISPs:", err);
})
.finally(() => {
setIsLoadingIsps(false);
});
}, [selectedCountry, selectedRegion, selectedCity]);
// Auto-generate name from selections
useEffect(() => {
const parts: string[] = [];
const countryItem = countries.find((c) => c.code === selectedCountry);
if (countryItem) parts.push(countryItem.name);
const regionItem = regions.find((s) => s.code === selectedRegion);
if (regionItem) parts.push(regionItem.name);
const cityItem = cities.find((c) => c.code === selectedCity);
if (cityItem) parts.push(cityItem.name);
const ispItem = isps.find((i) => i.code === selectedIsp);
if (ispItem) parts.push(ispItem.name);
if (parts.length > 0) {
setProxyName(parts.join(" - "));
}
}, [
selectedCountry,
selectedRegion,
selectedCity,
selectedIsp,
countries,
regions,
cities,
isps,
]);
const handleCreate = useCallback(async () => {
if (!selectedCountry || !proxyName.trim()) return;
setIsCreating(true);
try {
await invoke("create_cloud_location_proxy", {
name: proxyName.trim(),
country: selectedCountry,
region: selectedRegion || null,
city: selectedCity || null,
isp: selectedIsp || null,
});
toast.success(t("locationProxy.createSuccess"));
await emit("stored-proxies-changed");
handleClose();
} catch (error) {
console.error("Failed to create location proxy:", error);
toast.error(
typeof error === "string" ? error : t("locationProxy.createFailed"),
);
} finally {
setIsCreating(false);
}
}, [
selectedCountry,
selectedRegion,
selectedCity,
selectedIsp,
proxyName,
handleClose,
t,
]);
const countryOptions = countries.map((c) => ({
value: c.code,
label: c.name,
}));
const regionOptions = regions.map((s) => ({ value: s.code, label: s.name }));
const cityOptions = cities.map((c) => ({ value: c.code, label: c.name }));
const ispOptions = isps.map((i) => ({ value: i.code, label: i.name }));
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{t("locationProxy.titleCreate")}</DialogTitle>
<DialogDescription>
{t("locationProxy.descriptionCreate")}
</DialogDescription>
</DialogHeader>
<div className="max-h-[calc(100vh-16rem)] min-h-0 space-y-4 overflow-y-auto pr-1">
{/* Country - always visible */}
<div className="space-y-2">
<Label className="flex items-center gap-2">
{t("locationProxy.countryLabel")}
{isLoadingCountries && <LoadingSpinner />}
</Label>
<Combobox
options={countryOptions}
value={selectedCountry}
onValueChange={setSelectedCountry}
placeholder={
isLoadingCountries
? t("locationProxy.loadingCountries")
: t("locationProxy.selectCountryPh")
}
searchPlaceholder={t("locationProxy.searchCountries")}
disabled={isLoadingCountries}
/>
</div>
{/* Region - always visible, disabled until country is selected */}
<div className="space-y-2">
<Label className="flex items-center gap-2">
{t("locationProxy.regionLabel")}
{isLoadingRegions && <LoadingSpinner />}
</Label>
<Combobox
options={regionOptions}
value={selectedRegion}
onValueChange={setSelectedRegion}
placeholder={
!selectedCountry
? t("locationProxy.selectCountryFirst")
: isLoadingRegions
? t("locationProxy.loadingRegions")
: regionOptions.length === 0
? t("locationProxy.noRegions")
: t("locationProxy.selectRegion")
}
searchPlaceholder={t("locationProxy.searchRegions")}
disabled={!selectedCountry || isLoadingRegions}
/>
</div>
{/* City - always visible, disabled until country is selected */}
<div className="space-y-2">
<Label className="flex items-center gap-2">
{t("locationProxy.cityLabel")}
{isLoadingCities && <LoadingSpinner />}
</Label>
<Combobox
options={cityOptions}
value={selectedCity}
onValueChange={setSelectedCity}
placeholder={
!selectedCountry
? t("locationProxy.selectCountryFirst")
: isLoadingCities
? t("locationProxy.loadingCities")
: cityOptions.length === 0
? t("locationProxy.noCities")
: t("locationProxy.selectCity")
}
searchPlaceholder={t("locationProxy.searchCities")}
disabled={!selectedCountry || isLoadingCities}
/>
</div>
{/* ISP - always visible, disabled until country is selected */}
<div className="space-y-2">
<Label className="flex items-center gap-2">
{t("locationProxy.ispLabel")}
{isLoadingIsps && <LoadingSpinner />}
</Label>
<Combobox
options={ispOptions}
value={selectedIsp}
onValueChange={setSelectedIsp}
placeholder={
!selectedCountry
? t("locationProxy.selectCountryFirst")
: isLoadingIsps
? t("locationProxy.loadingIsps")
: ispOptions.length === 0
? t("locationProxy.noIsps")
: t("locationProxy.selectIsp")
}
searchPlaceholder={t("locationProxy.searchIsps")}
disabled={!selectedCountry || isLoadingIsps}
/>
</div>
{/* Name */}
<div className="space-y-2">
<Label>{t("locationProxy.nameLabel")}</Label>
<Input
value={proxyName}
onChange={(e) => {
setProxyName(e.target.value);
}}
placeholder={t("locationProxy.namePlaceholder")}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={handleClose}>
{t("common.buttons.cancel")}
</Button>
<RippleButton
onClick={handleCreate}
disabled={!selectedCountry || !proxyName.trim() || isCreating}
>
{isCreating
? t("locationProxy.creatingButton")
: t("locationProxy.createButton")}
</RippleButton>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+31 -17
View File
@@ -1,14 +1,17 @@
"use client";
import { motion, useReducedMotion } from "motion/react";
import type { CardComponentProps } from "onborda";
import { useOnborda } from "onborda";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { ONBOARDING_TOUR_FINISHED_EVENT } from "@/lib/onboarding-signal";
import {
ONBOARDING_TOUR_CLOSED_EVENT,
ONBOARDING_TOUR_FINISHED_EVENT,
} from "@/lib/onboarding-signal";
// Custom Onborda card, themed with the app's CSS variables. Finishing the last
// step emits ONBOARDING_TOUR_FINISHED_EVENT so the page can show the celebratory
// thank-you dialog (skipping early does not emit it).
// Custom Onborda card, themed with the app's CSS variables. Closing always
// persists completion; finishing the last step also asks the page to celebrate.
export function OnboardingCard({
step,
currentStep,
@@ -19,6 +22,7 @@ export function OnboardingCard({
}: CardComponentProps) {
const { t } = useTranslation();
const { closeOnborda } = useOnborda();
const reduceMotion = useReducedMotion();
const isFirst = currentStep === 0;
const isLast = currentStep === totalSteps - 1;
@@ -26,31 +30,44 @@ export function OnboardingCard({
// button), not by a "Next" button — advancing manually would jump to a step
// whose target doesn't exist yet and block the button. So hide "Next" here.
const requiresAction = step.selector === '[data-onborda="create-profile"]';
const closeTour = () => {
closeOnborda();
window.dispatchEvent(new Event(ONBOARDING_TOUR_CLOSED_EVENT));
};
return (
<div className="relative w-80 max-w-[90vw] rounded-lg border bg-popover p-4 text-popover-foreground shadow-lg">
<motion.div
initial={{ opacity: 0, scale: reduceMotion ? 1 : 0.96 }}
animate={{ opacity: 1, scale: 1 }}
transition={
reduceMotion
? { duration: 0.15 }
: { type: "spring", stiffness: 300, damping: 30 }
}
className="relative flex w-80 max-w-[calc(100vw-2rem)] flex-col gap-4 rounded-lg border bg-popover p-4 text-popover-foreground shadow-lg"
>
<div className="flex items-start justify-between gap-2">
<h3 className="text-sm/tight font-semibold">{step.title}</h3>
<h3 className="text-base font-semibold text-balance sm:text-sm">
{step.title}
</h3>
<span className="shrink-0 text-[11px] text-muted-foreground tabular-nums">
{currentStep + 1}/{totalSteps}
</span>
</div>
<div className="mt-2 text-xs/relaxed text-muted-foreground">
<div className="text-base/7 text-pretty text-muted-foreground sm:text-sm/6">
{step.content}
</div>
<div className="mt-4 flex items-center justify-between gap-2">
<div className="flex items-center justify-between gap-2">
{isLast ? (
<span />
) : (
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-xs text-muted-foreground hover:text-foreground"
onClick={() => {
closeOnborda();
}}
className="text-muted-foreground hover:text-foreground"
onClick={closeTour}
>
{t("onboarding.buttons.skip")}
</Button>
@@ -61,7 +78,6 @@ export function OnboardingCard({
<Button
variant="outline"
size="sm"
className="h-7 px-2.5 text-xs"
onClick={() => {
prevStep();
}}
@@ -72,9 +88,8 @@ export function OnboardingCard({
{isLast ? (
<Button
size="sm"
className="h-7 px-3 text-xs"
onClick={() => {
closeOnborda();
closeTour();
window.dispatchEvent(new Event(ONBOARDING_TOUR_FINISHED_EVENT));
}}
>
@@ -83,7 +98,6 @@ export function OnboardingCard({
) : requiresAction ? null : (
<Button
size="sm"
className="h-7 px-3 text-xs"
onClick={() => {
nextStep();
}}
@@ -95,6 +109,6 @@ export function OnboardingCard({
</div>
<span className="text-popover">{arrow}</span>
</div>
</motion.div>
);
}
+2 -1
View File
@@ -57,7 +57,8 @@ export function OnboardingProvider({
cardComponent={OnboardingCard}
interact
shadowRgb="0,0,0"
shadowOpacity="0.6"
shadowOpacity="0.55"
cardTransition={{ type: "spring", bounce: 0, duration: 0.35 }}
>
{children}
</Onborda>
+10 -12
View File
@@ -40,24 +40,20 @@ export function PermissionDialog({
const { t } = useTranslation();
const [isRequesting, setIsRequesting] = useState(false);
const [isWaitingForGrant, setIsWaitingForGrant] = useState(false);
const [isMacOS, setIsMacOS] = useState(false);
const {
requestPermission,
isMicrophoneAccessGranted,
isCameraAccessGranted,
} = usePermissions();
isInitialized,
requiresSystemPermissions,
} = usePermissions(isOpen);
// Check if we're on macOS and close dialog if not
// This gate only exists for macOS TCC permissions.
useEffect(() => {
const userAgent = navigator.userAgent;
const isMac = userAgent.includes("Mac");
setIsMacOS(isMac);
// If not macOS, close the dialog as permissions aren't needed
if (!isMac) {
if (isOpen && isInitialized && !requiresSystemPermissions) {
onClose();
}
}, [onClose]);
}, [isInitialized, isOpen, onClose, requiresSystemPermissions]);
// Get current permission status
const isCurrentPermissionGranted =
@@ -158,7 +154,9 @@ export function PermissionDialog({
const handleRequestPermission = async () => {
setIsRequesting(true);
try {
await requestPermission(permissionType);
const granted = await requestPermission(permissionType);
if (granted) return;
// The macOS permission poll runs every 5 s, so the new state can take
// a moment to surface. Keep the grant button in its busy state for
// that window so the user has clear feedback, and notify them if the
@@ -187,7 +185,7 @@ export function PermissionDialog({
};
// Don't render if not macOS
if (!isMacOS) {
if (!requiresSystemPermissions) {
return null;
}
+16 -18
View File
@@ -137,9 +137,7 @@ export function SettingsDialog({
const [isLoadingPermissions, setIsLoadingPermissions] = useState(false);
const [requestingPermission, setRequestingPermission] =
useState<PermissionType | null>(null);
const [isMacOS, setIsMacOS] = useState(false);
const [dnsBlocklistDialogOpen, setDnsBlocklistDialogOpen] = useState(false);
const [isLinux, setIsLinux] = useState(false);
const [hasE2ePassword, setHasE2ePassword] = useState(false);
const [e2ePassword, setE2ePassword] = useState("");
const [e2ePasswordConfirm, setE2ePasswordConfirm] = useState("");
@@ -168,7 +166,10 @@ export function SettingsDialog({
requestPermission,
isMicrophoneAccessGranted,
isCameraAccessGranted,
} = usePermissions();
currentOS,
} = usePermissions(isOpen);
const isMacOS = currentOS === "macos";
const isLinux = currentOS === "linux";
const { trialStatus } = useCommercialTrial();
const { user: cloudUser } = useCloudAuth();
// Encryption is available to everyone except team members who aren't owners
@@ -630,14 +631,7 @@ export function SettingsDialog({
console.error(err);
});
// Check if we're on macOS
const userAgent = navigator.userAgent;
const isMac = userAgent.includes("Mac");
setIsMacOS(isMac);
const isLin = !userAgent.includes("Mac") && !userAgent.includes("Win");
setIsLinux(isLin);
if (isMac) {
if (isMacOS) {
loadPermissions();
}
@@ -653,7 +647,13 @@ export function SettingsDialog({
clearInterval(intervalId);
};
}
}, [isOpen, loadPermissions, checkDefaultBrowserStatus, loadSettings]);
}, [
isOpen,
isMacOS,
loadPermissions,
checkDefaultBrowserStatus,
loadSettings,
]);
// Initialize language selection when dialog opens or language loads
useEffect(() => {
@@ -1023,7 +1023,7 @@ export function SettingsDialog({
});
}}
>
Grant
{t("common.buttons.grant")}
</LoadingButton>
)}
</div>
@@ -1032,10 +1032,8 @@ export function SettingsDialog({
</div>
)}
<p className="text-xs text-muted-foreground">
These permissions allow browsers launched from Donut Browser
to access system resources. Each website will still ask for
your permission individually.
<p className="text-base/7 text-pretty text-muted-foreground sm:text-sm/6">
{t("settings.permissions.description")}
</p>
</div>
)}
@@ -1045,7 +1043,7 @@ export function SettingsDialog({
<Label className="text-base font-medium">
{t("settings.integrations.title")}
</Label>
<p className="text-xs text-muted-foreground">
<p className="text-base/7 text-pretty text-muted-foreground sm:text-sm/6">
{t("settings.integrations.description")}
</p>
<RippleButton
+14 -7
View File
@@ -1,7 +1,7 @@
"use client";
import confetti from "canvas-confetti";
import { motion } from "motion/react";
import { motion, useReducedMotion } from "motion/react";
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { Logo } from "@/components/icons/logo";
@@ -20,9 +20,10 @@ export function ThankYouDialog({
onClose: () => void;
}) {
const { t } = useTranslation();
const reduceMotion = useReducedMotion();
useEffect(() => {
if (!isOpen) return;
if (!isOpen || reduceMotion) return;
const fire = (options: confetti.Options) => {
void confetti({ origin: { y: 0.7 }, ...options });
};
@@ -39,7 +40,7 @@ export function ThankYouDialog({
clearTimeout(t1);
clearTimeout(t2);
};
}, [isOpen]);
}, [isOpen, reduceMotion]);
return (
<Dialog
@@ -48,12 +49,15 @@ export function ThankYouDialog({
if (!open) onClose();
}}
>
<DialogContent className="sm:max-w-md">
<DialogContent className="p-4 sm:max-w-md sm:p-6">
<div className="flex flex-col items-center gap-6 text-center">
<motion.div
initial={{ opacity: 0, scale: 0.6, rotate: -12 }}
animate={{ opacity: 1, scale: 1, rotate: 0 }}
transition={{ ...spring, delay: 0.05 }}
transition={{
...(reduceMotion ? { duration: 0.15 } : spring),
delay: reduceMotion ? 0 : 0.05,
}}
className="text-foreground"
>
<Logo className="size-14" />
@@ -66,8 +70,11 @@ export function ThankYouDialog({
<motion.p
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ ...spring, delay: 0.15 }}
className="mx-auto max-w-[46ch] text-sm/6 text-pretty text-muted-foreground"
transition={{
...(reduceMotion ? { duration: 0.15 } : spring),
delay: reduceMotion ? 0 : 0.15,
}}
className="mx-auto max-w-[46ch] text-base/7 text-pretty text-muted-foreground sm:text-sm/6"
>
{t("onboarding.thankYou.body")}
</motion.p>
-90
View File
@@ -1,90 +0,0 @@
import { cn } from "@/lib/utils";
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground shadow-sm",
className,
)}
{...props}
/>
);
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className,
)}
{...props}
/>
);
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
);
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
);
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className,
)}
{...props}
/>
);
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
);
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
);
}
export {
Card,
CardAction,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
};
-386
View File
@@ -1,386 +0,0 @@
"use client";
import * as React from "react";
import * as RechartsPrimitive from "recharts";
import type {
Props as DefaultLegendContentProps,
LegendPayload,
} from "recharts/types/component/DefaultLegendContent";
import type {
NameType,
ValueType,
} from "recharts/types/component/DefaultTooltipContent";
import type { TooltipContentProps } from "recharts/types/component/Tooltip";
import { cn } from "@/lib/utils";
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: "", dark: ".dark" } as const;
export type ChartConfig = {
[k in string]: {
label?: React.ReactNode;
icon?: React.ComponentType;
} & (
| { color?: string; theme?: never }
| { color?: never; theme: Record<keyof typeof THEMES, string> }
);
};
type ChartContextProps = {
config: ChartConfig;
};
const ChartContext = React.createContext<ChartContextProps | null>(null);
function useChart() {
const context = React.useContext(ChartContext);
if (!context) {
throw new Error("useChart must be used within a <ChartContainer />");
}
return context;
}
const ChartContainer = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
config: ChartConfig;
children: React.ComponentProps<
typeof RechartsPrimitive.ResponsiveContainer
>["children"];
}
>(({ id, className, children, config, ...props }, ref) => {
const uniqueId = React.useId();
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`;
return (
<ChartContext.Provider value={{ config }}>
<div
data-chart={chartId}
ref={ref}
className={cn(
"flex aspect-video max-h-[min(45vh,20rem)] w-full justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector]:outline-none [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-none",
className,
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer
width="100%"
height="100%"
minWidth={1}
minHeight={1}
>
{children}
</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
);
});
ChartContainer.displayName = "Chart";
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(
([, config]) => config.theme || config.color,
);
if (!colorConfig.length) {
return null;
}
return (
<style
// biome-ignore lint/security/noDangerouslySetInnerHtml: Safe usage for CSS variables from chart config
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color =
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
itemConfig.color;
return color ? ` --color-${key}: ${color};` : null;
})
.join("\n")}
}
`,
)
.join("\n"),
}}
/>
);
};
const ChartTooltip = RechartsPrimitive.Tooltip;
const ChartTooltipContent = React.forwardRef<
HTMLDivElement,
TooltipContentProps<ValueType, NameType> &
React.ComponentProps<"div"> & {
hideLabel?: boolean;
hideIndicator?: boolean;
indicator?: "line" | "dot" | "dashed";
nameKey?: string;
labelKey?: string;
labelClassName?: string;
color?: string;
}
>(
(
{
active,
payload,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
},
ref,
) => {
const { config } = useChart();
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null;
}
const [item] = payload;
const key = `${labelKey || item?.dataKey || item?.name || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const value =
!labelKey && typeof label === "string"
? config[label as keyof typeof config]?.label || label
: itemConfig?.label;
if (labelFormatter) {
return (
<div className={cn("font-medium", labelClassName)}>
{labelFormatter(value, payload)}
</div>
);
}
if (!value) {
return null;
}
return <div className={cn("font-medium", labelClassName)}>{value}</div>;
}, [
label,
labelFormatter,
payload,
hideLabel,
labelClassName,
config,
labelKey,
]);
if (!active || !payload?.length) {
return null;
}
const nestLabel = payload.length === 1 && indicator !== "dot";
return (
<div
ref={ref}
className={cn(
"grid min-w-32 items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
className,
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload
.filter((item) => item.type !== "none")
.map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const indicatorColor = color || item.payload?.fill || item.color;
return (
<div
key={String(item.dataKey ?? index)}
className={cn(
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:size-2.5 [&>svg]:text-muted-foreground",
indicator === "dot" && "items-center",
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn(
"shrink-0 rounded-[2px] border-[--color-border] bg-[--color-bg]",
{
"size-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent":
indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
},
)}
style={
{
"--color-bg": indicatorColor,
"--color-border": indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
"flex flex-1 justify-between leading-none",
nestLabel ? "items-end" : "items-center",
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">
{itemConfig?.label || item.name}
</span>
</div>
{item.value && (
<span className="font-mono font-medium text-foreground tabular-nums">
{item.value.toLocaleString()}
</span>
)}
</div>
</>
)}
</div>
);
})}
</div>
</div>
);
},
);
ChartTooltipContent.displayName = "ChartTooltip";
const ChartLegend = RechartsPrimitive.Legend;
const ChartLegendContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> &
Pick<DefaultLegendContentProps, "payload" | "verticalAlign"> & {
hideIcon?: boolean;
nameKey?: string;
}
>(
(
{ className, hideIcon = false, payload, verticalAlign = "bottom", nameKey },
ref,
) => {
const { config } = useChart();
if (!payload?.length) {
return null;
}
return (
<div
ref={ref}
className={cn(
"flex items-center justify-center gap-4",
verticalAlign === "top" ? "pb-3" : "pt-3",
className,
)}
>
{payload
.filter((item: LegendPayload) => item.type !== "none")
.map((item: LegendPayload) => {
const key = `${nameKey || item.dataKey || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
return (
<div
key={item.value}
className={cn(
"flex items-center gap-1.5 [&>svg]:size-3 [&>svg]:text-muted-foreground",
)}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="size-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
</div>
);
})}
</div>
);
},
);
ChartLegendContent.displayName = "ChartLegend";
// Helper to extract item config from a payload.
function getPayloadConfigFromPayload(
config: ChartConfig,
payload: unknown,
key: string,
) {
if (typeof payload !== "object" || payload === null) {
return undefined;
}
const payloadPayload =
"payload" in payload &&
typeof payload.payload === "object" &&
payload.payload !== null
? payload.payload
: undefined;
let configLabelKey: string = key;
if (
key in payload &&
typeof payload[key as keyof typeof payload] === "string"
) {
configLabelKey = payload[key as keyof typeof payload] as string;
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
) {
configLabelKey = payloadPayload[
key as keyof typeof payloadPayload
] as string;
}
return configLabelKey in config
? config[configLabelKey]
: config[key as keyof typeof config];
}
export {
ChartContainer,
ChartLegend,
ChartLegendContent,
ChartStyle,
ChartTooltip,
ChartTooltipContent,
};
-115
View File
@@ -1,115 +0,0 @@
"use client";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { LuCheck, LuChevronsUpDown } from "react-icons/lu";
import { Button } from "@/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { cn } from "@/lib/utils";
interface ComboboxOption {
value: string;
label: string;
description?: string;
}
interface ComboboxProps {
options: ComboboxOption[];
value: string;
onValueChange: (value: string) => void;
placeholder?: string;
searchPlaceholder?: string;
className?: string;
disabled?: boolean;
}
export function Combobox({
options,
value,
onValueChange,
placeholder,
searchPlaceholder,
className,
disabled,
}: ComboboxProps) {
const { t } = useTranslation();
const [open, setOpen] = React.useState(false);
const listboxId = React.useId();
const resolvedPlaceholder = placeholder ?? t("common.buttons.select");
const resolvedSearchPlaceholder =
searchPlaceholder ?? t("common.buttons.search");
return (
<Popover open={open} onOpenChange={disabled ? undefined : setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
aria-controls={listboxId}
disabled={disabled}
className={cn("w-full justify-between", className)}
>
<span className="truncate">
{value
? options.find((option) => option.value === value)?.label
: resolvedPlaceholder}
</span>
<LuChevronsUpDown className="ml-2 size-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
id={listboxId}
className="w-(--radix-popover-trigger-width) p-0"
>
<Command>
<CommandInput placeholder={resolvedSearchPlaceholder} />
<CommandList>
<CommandEmpty>{t("common.noResults")}</CommandEmpty>
<CommandGroup>
{options.map((option) => (
<CommandItem
key={option.value}
value={option.value}
onSelect={(currentValue) => {
onValueChange(currentValue === value ? "" : currentValue);
setOpen(false);
}}
>
<LuCheck
className={cn(
"mr-2 size-4",
value === option.value ? "opacity-100" : "opacity-0",
)}
/>
<div className="flex min-w-0 flex-col">
<span className="truncate">{option.label}</span>
{option.description && (
<span className="truncate text-sm text-muted-foreground">
{option.description}
</span>
)}
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
+1 -1
View File
@@ -263,7 +263,7 @@ function DialogContent({
// w-[calc(100%-2rem)] (not w-full + max-w) keeps the 1rem window
// gutter even when callers override max-w-*: tailwind-merge drops
// a base max-w in favor of the caller's, but leaves width alone.
"surface-material fixed top-[50%] left-[50%] z-10000 grid max-h-[calc(100vh-3rem)] w-[calc(100%-2rem)] max-w-lg -translate-[50%] gap-4 overflow-y-auto rounded-lg border p-6 shadow-lg",
"surface-material fixed top-[50%] left-[50%] z-10000 grid max-h-[calc(100dvh-3rem)] w-[calc(100%-2rem)] max-w-lg -translate-[50%] gap-4 overflow-y-auto rounded-lg border p-6 shadow-lg",
className,
)}
{...props}
+181 -77
View File
@@ -1,11 +1,12 @@
"use client";
import { AnimatePresence, motion } from "motion/react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
import {
LuArrowRight,
LuBriefcase,
LuCamera,
LuCookie,
LuFolders,
LuGithub,
@@ -25,10 +26,11 @@ import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
import { useBrowserSetup } from "@/hooks/use-browser-setup";
import { usePermissions } from "@/hooks/use-permissions";
import { getBrowserDisplayName } from "@/lib/browser-utils";
import { getCurrentOS } from "@/lib/platform";
type WelcomeStep = "intro" | "license" | "permissions" | "setup";
const panelTransition = {
const panelSpring = {
type: "spring",
stiffness: 260,
damping: 28,
@@ -52,24 +54,71 @@ const FEATURES = [
{ key: "welcome.features.items.cookies", Icon: LuCookie },
] as const;
function formatBytes(bytes: number): string {
if (!(bytes > 0)) return "0 B";
const units = ["B", "KB", "MB", "GB"];
const BYTE_UNITS = ["byte", "kilobyte", "megabyte", "gigabyte"] as const;
function formatBytes(bytes: number, locale: string): string {
const exponent = Math.min(
units.length - 1,
Math.floor(Math.log(bytes) / Math.log(1024)),
BYTE_UNITS.length - 1,
bytes > 0 ? Math.floor(Math.log(bytes) / Math.log(1024)) : 0,
);
const value = bytes / 1024 ** exponent;
const rounded = exponent === 0 ? value : Math.round(value * 10) / 10;
return `${rounded} ${units[exponent]}`;
return new Intl.NumberFormat(locale, {
style: "unit",
unit: BYTE_UNITS[exponent],
unitDisplay: "short",
maximumFractionDigits: exponent === 0 ? 0 : 1,
}).format(value);
}
function formatDuration(seconds: number): string {
function formatDuration(seconds: number, locale: string): string {
const total = Math.max(0, Math.round(seconds));
if (total < 60) return `${total}s`;
const formatUnit = (
value: number,
unit: "minute" | "second",
minimumIntegerDigits = 1,
) =>
new Intl.NumberFormat(locale, {
style: "unit",
unit,
unitDisplay: "narrow",
minimumIntegerDigits,
}).format(value);
if (total < 60) return formatUnit(total, "second");
const minutes = Math.floor(total / 60);
const remainder = total % 60;
return `${minutes}m ${String(remainder).padStart(2, "0")}s`;
return `${formatUnit(minutes, "minute")} ${formatUnit(
remainder,
"second",
2,
)}`;
}
function SetupProgress({ value, label }: { value: number; label: string }) {
const normalizedValue = Math.min(100, Math.max(0, value));
const determined = normalizedValue > 0;
return (
<div
role="progressbar"
aria-label={label}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={determined ? normalizedValue : undefined}
className="h-1.5 w-full overflow-hidden rounded-full bg-muted"
>
{determined ? (
<motion.div
className="h-full origin-left rounded-full bg-primary"
initial={{ scaleX: 0 }}
animate={{ scaleX: normalizedValue / 100 }}
transition={{ type: "spring", stiffness: 120, damping: 24 }}
/>
) : (
<div className="h-full w-1/3 rounded-full bg-primary motion-safe:animate-progress-indeterminate motion-reduce:translate-x-0" />
)}
</div>
);
}
export function WelcomeDialog({
@@ -87,9 +136,30 @@ export function WelcomeDialog({
needsSetup: boolean;
onComplete: () => void;
}) {
const { t } = useTranslation();
const { requestPermission } = usePermissions();
const { t, i18n } = useTranslation();
const reduceMotion = useReducedMotion();
const {
requestPermission,
isMicrophoneAccessGranted,
isCameraAccessGranted,
isInitialized,
requiresSystemPermissions,
} = usePermissions(isOpen);
const [step, setStep] = useState<WelcomeStep>("intro");
const permissionsGranted = isMicrophoneAccessGranted && isCameraAccessGranted;
const showPermissionsStep =
requiresSystemPermissions &&
(step === "permissions" || !isInitialized || !permissionsGranted);
const visibleSteps: WelcomeStep[] = [
"intro",
"license",
...(showPermissionsStep ? (["permissions"] as const) : []),
...(needsSetup ? (["setup"] as const) : []),
];
const currentStepIndex = Math.max(0, visibleSteps.indexOf(step));
const panelTransition = reduceMotion
? ({ duration: 0.15 } as const)
: panelSpring;
// Where the "skip" / "continue" affordances go: into the setup flow when a
// browser/profile is still needed, otherwise straight to completion.
const advanceToSetup = () => {
@@ -106,24 +176,46 @@ export function WelcomeDialog({
const requestPermissions = useCallback(async () => {
setRequesting(true);
try {
await requestPermission("microphone");
await requestPermission("camera");
if (!isMicrophoneAccessGranted) {
await requestPermission("microphone");
}
if (!isCameraAccessGranted) {
await requestPermission("camera");
}
} catch (err) {
console.error("Permission request failed:", err);
} finally {
setRequesting(false);
setStep("setup");
}
}, [requestPermission]);
}, [isCameraAccessGranted, isMicrophoneAccessGranted, requestPermission]);
return (
<Dialog open={isOpen} onOpenChange={() => {}}>
<DialogContent
dismissible={false}
className="overflow-x-hidden sm:max-w-xl"
className="overflow-x-hidden p-4 sm:max-w-xl sm:p-6"
>
<DialogTitle className="sr-only">{t("welcome.title")}</DialogTitle>
<div
role="progressbar"
aria-label={t("welcome.title")}
aria-valuemin={1}
aria-valuemax={visibleSteps.length}
aria-valuenow={currentStepIndex + 1}
className="mx-auto h-1 w-24 overflow-hidden rounded-full bg-muted"
>
<motion.div
className="h-full origin-left rounded-full bg-primary"
initial={false}
animate={{
scaleX: (currentStepIndex + 1) / visibleSteps.length,
}}
transition={panelTransition}
/>
</div>
<AnimatePresence mode="wait">
{step === "intro" && (
<motion.div
@@ -139,7 +231,10 @@ export function WelcomeDialog({
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ ...panelTransition, delay: 0.05 }}
transition={{
...panelTransition,
delay: reduceMotion ? 0 : 0.05,
}}
className="text-foreground"
>
<Logo className="size-12" />
@@ -148,14 +243,14 @@ export function WelcomeDialog({
<h2 className="text-2xl font-semibold tracking-tight text-balance">
{t("welcome.title")}
</h2>
<p className="mx-auto max-w-[55ch] text-sm text-pretty text-muted-foreground">
<p className="mx-auto max-w-[55ch] text-base/7 text-pretty text-muted-foreground sm:text-sm/6">
{t("welcome.tagline")}
</p>
</div>
</div>
<div className="flex flex-col gap-3">
<p className="text-sm font-medium text-muted-foreground">
<p className="text-base/7 font-medium text-muted-foreground sm:text-sm/6">
{t("welcome.features.title")}
</p>
<dl className="grid grid-cols-1 gap-x-6 gap-y-3 sm:grid-cols-2">
@@ -166,12 +261,12 @@ export function WelcomeDialog({
animate={{ opacity: 1, y: 0 }}
transition={{
...panelTransition,
delay: 0.12 + i * 0.04,
delay: reduceMotion ? 0 : 0.12 + i * 0.04,
}}
className="flex items-center gap-2.5"
className="flex min-w-0 items-center gap-2.5"
>
<Icon className="size-4 shrink-0 text-muted-foreground" />
<dt className="text-sm font-medium text-foreground">
<dt className="text-base/7 font-medium text-foreground sm:text-sm/6">
{t(key)}
</dt>
</motion.div>
@@ -179,7 +274,7 @@ export function WelcomeDialog({
</dl>
</div>
<div className="flex items-center justify-between">
<div className="flex flex-wrap items-center justify-between gap-2">
<Button
variant="ghost"
size="sm"
@@ -214,7 +309,7 @@ export function WelcomeDialog({
<h2 className="text-2xl font-semibold tracking-tight text-balance">
{t("welcome.license.title")}
</h2>
<p className="mx-auto max-w-[55ch] text-sm/6 text-pretty text-muted-foreground">
<p className="mx-auto max-w-[55ch] text-base/7 text-pretty text-muted-foreground sm:text-sm/6">
{t("welcome.license.body")}
</p>
</div>
@@ -223,10 +318,10 @@ export function WelcomeDialog({
<div className="flex items-start gap-3 rounded-lg border p-4">
<LuHeart className="mt-0.5 size-4 shrink-0 text-success" />
<div className="flex flex-col gap-0.5 text-left">
<dt className="text-sm font-medium text-foreground">
<dt className="text-base/7 font-medium text-foreground sm:text-sm/6">
{t("welcome.license.personalTitle")}
</dt>
<dd className="text-sm text-pretty text-muted-foreground">
<dd className="text-base/7 text-pretty text-muted-foreground sm:text-sm/6">
{t("welcome.license.personalDesc")}
</dd>
</div>
@@ -234,20 +329,20 @@ export function WelcomeDialog({
<div className="flex items-start gap-3 rounded-lg border p-4">
<LuBriefcase className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
<div className="flex flex-col gap-0.5 text-left">
<dt className="flex items-center gap-2 text-sm font-medium text-foreground">
<dt className="flex flex-wrap items-center gap-2 text-base/7 font-medium text-foreground sm:text-sm/6">
{t("welcome.license.commercialTitle")}
<span className="rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">
{t("welcome.license.trialBadge")}
</span>
</dt>
<dd className="text-sm text-pretty text-muted-foreground">
<dd className="text-base/7 text-pretty text-muted-foreground sm:text-sm/6">
{t("welcome.license.commercialDesc")}
</dd>
</div>
</div>
</dl>
<div className="flex items-center justify-between">
<div className="flex flex-wrap items-center justify-between gap-2">
<Button
variant="ghost"
size="sm"
@@ -260,8 +355,16 @@ export function WelcomeDialog({
size="sm"
className="gap-1.5"
onClick={() => {
if (needsSetup) setStep("permissions");
else onComplete();
if (!needsSetup) {
onComplete();
} else if (
getCurrentOS() === "macos" &&
!(isInitialized && permissionsGranted)
) {
setStep("permissions");
} else {
setStep("setup");
}
}}
>
{t("welcome.license.agree")}
@@ -281,17 +384,26 @@ export function WelcomeDialog({
transition={panelTransition}
className="flex flex-col gap-7"
>
<div className="flex flex-col gap-2 text-center">
<h2 className="flex items-center justify-center gap-2 text-2xl font-semibold tracking-tight text-balance">
<LuMic className="size-5 shrink-0" />
<div className="flex flex-col items-center gap-3 text-center">
<motion.div
initial={{ opacity: 0, scale: 0.85, rotate: -8 }}
animate={{ opacity: 1, scale: 1, rotate: 0 }}
transition={panelTransition}
className="flex size-12 items-center justify-center gap-1.5 rounded-full bg-primary/10 text-primary"
aria-hidden="true"
>
<LuMic className="size-4 shrink-0" />
<LuCamera className="size-4 shrink-0" />
</motion.div>
<h2 className="text-2xl font-semibold tracking-tight text-balance">
{t("welcome.permissions.title")}
</h2>
<p className="mx-auto max-w-[55ch] text-sm/6 text-pretty text-muted-foreground">
<p className="mx-auto max-w-[55ch] text-base/7 text-pretty text-muted-foreground sm:text-sm/6">
{t("welcome.permissions.desc")}
</p>
</div>
<div className="flex items-center justify-between">
<div className="flex flex-wrap items-center justify-between gap-2">
<Button
variant="ghost"
size="sm"
@@ -337,7 +449,7 @@ export function WelcomeDialog({
<LuTriangleAlert className="size-5 shrink-0" />
{t("welcome.ready.errorTitle")}
</h2>
<p className="max-w-[55ch] text-sm/6 text-pretty text-muted-foreground">
<p className="max-w-[55ch] text-base/7 text-pretty text-muted-foreground sm:text-sm/6">
{setup.error?.stage === "downloading"
? t("welcome.ready.errorDownload", {
browser: browserName,
@@ -371,7 +483,7 @@ export function WelcomeDialog({
<h2 className="text-2xl font-semibold tracking-tight text-balance">
{t("welcome.ready.title")}
</h2>
<p className="max-w-[55ch] text-sm/6 text-pretty text-muted-foreground">
<p className="max-w-[55ch] text-base/7 text-pretty text-muted-foreground sm:text-sm/6">
{setup.phase === "ready"
? t("welcome.ready.descReady")
: setup.phase === "extracting"
@@ -382,40 +494,39 @@ export function WelcomeDialog({
{setup.phase === "downloading" && (
<div className="flex w-full max-w-xs flex-col gap-2">
<div className="h-1.5 w-full overflow-hidden rounded-full bg-muted">
<motion.div
className="h-full rounded-full bg-primary"
initial={{ width: 0 }}
animate={{
width: `${Math.max(setup.downloadPercent, 4)}%`,
}}
transition={{
type: "spring",
stiffness: 120,
damping: 24,
}}
/>
</div>
<div className="flex items-center justify-between text-sm text-muted-foreground tabular-nums">
<SetupProgress
value={setup.downloadPercent}
label={t("welcome.ready.downloading")}
/>
<div className="flex items-center justify-between text-base/7 text-muted-foreground tabular-nums sm:text-sm/6">
<span className="inline-flex items-center gap-1.5">
<LuLoaderCircle className="size-4 shrink-0 animate-spin" />
{t("welcome.ready.downloading")}
</span>
<span>{setup.downloadPercent}%</span>
</div>
<div className="flex flex-wrap items-center justify-center gap-x-3 gap-y-0.5 text-xs text-muted-foreground tabular-nums">
<div className="flex flex-wrap items-center justify-center gap-x-3 gap-y-0.5 text-sm text-muted-foreground tabular-nums">
<span>
{setup.totalBytes != null
? t("welcome.ready.stats", {
downloaded: formatBytes(setup.downloadedBytes),
total: formatBytes(setup.totalBytes),
downloaded: formatBytes(
setup.downloadedBytes,
i18n.language,
),
total: formatBytes(
setup.totalBytes,
i18n.language,
),
})
: formatBytes(setup.downloadedBytes)}
: formatBytes(setup.downloadedBytes, i18n.language)}
</span>
{setup.speedBytesPerSec > 0 && (
<span>
{t("welcome.ready.speed", {
speed: formatBytes(setup.speedBytesPerSec),
speed: formatBytes(
setup.speedBytesPerSec,
i18n.language,
),
})}
</span>
)}
@@ -424,7 +535,10 @@ export function WelcomeDialog({
setup.etaSeconds > 0 && (
<span>
{t("welcome.ready.timeLeft", {
time: formatDuration(setup.etaSeconds),
time: formatDuration(
setup.etaSeconds,
i18n.language,
),
})}
</span>
)}
@@ -435,27 +549,17 @@ export function WelcomeDialog({
{setup.phase === "extracting" && (
<div className="flex w-full max-w-xs flex-col gap-2">
{setup.extractionOvertime ? (
<div className="flex items-center justify-center gap-1.5 text-sm text-muted-foreground tabular-nums">
<div className="flex items-center justify-center gap-1.5 text-base/7 text-muted-foreground tabular-nums sm:text-sm/6">
<LuLoaderCircle className="size-4 shrink-0 animate-spin" />
{t("welcome.ready.almostFinished")}
</div>
) : (
<>
<div className="h-1.5 w-full overflow-hidden rounded-full bg-muted">
<motion.div
className="h-full rounded-full bg-primary"
initial={{ width: 0 }}
animate={{
width: `${Math.max(setup.extractionPercent, 4)}%`,
}}
transition={{
type: "spring",
stiffness: 120,
damping: 24,
}}
/>
</div>
<div className="flex items-center justify-between text-sm text-muted-foreground tabular-nums">
<SetupProgress
value={setup.extractionPercent}
label={t("welcome.ready.extracting")}
/>
<div className="flex items-center justify-between text-base/7 text-muted-foreground tabular-nums sm:text-sm/6">
<span className="inline-flex items-center gap-1.5">
<LuLoaderCircle className="size-4 shrink-0 animate-spin" />
{t("welcome.ready.extracting")}
+4 -12
View File
@@ -3,23 +3,15 @@
import { getCurrentWindow } from "@tauri-apps/api/window";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
type Platform = "macos" | "windows" | "linux";
function detectPlatform(): Platform {
const userAgent = navigator.userAgent.toLowerCase();
if (userAgent.includes("mac")) return "macos";
if (userAgent.includes("win")) return "windows";
return "linux";
}
import { getCurrentOS, type OperatingSystem } from "@/lib/platform";
export function WindowDragArea() {
const { t } = useTranslation();
const [platform, setPlatform] = useState<Platform | null>(null);
const [platform, setPlatform] = useState<OperatingSystem | null>(null);
const [isMaximized, setIsMaximized] = useState(false);
useEffect(() => {
setPlatform(detectPlatform());
setPlatform(getCurrentOS());
}, []);
useEffect(() => {
@@ -64,7 +56,7 @@ export function WindowDragArea() {
};
// Linux: system decorations handle everything
if (!platform || platform === "linux") {
if (!platform || platform === "linux" || platform === "unknown") {
return null;
}
-54
View File
@@ -1,54 +0,0 @@
import { invoke } from "@tauri-apps/api/core";
import { useEffect, useState } from "react";
import i18n from "@/i18n";
export function useBrowserSupport() {
const [supportedBrowsers, setSupportedBrowsers] = useState<string[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const loadSupportedBrowsers = async () => {
try {
setIsLoading(true);
setError(null);
const browsers = await invoke<string[]>("get_supported_browsers");
setSupportedBrowsers(browsers);
} catch (err) {
console.error("Failed to load supported browsers:", err);
setError(
err instanceof Error
? err.message
: i18n.t("errors.loadSupportedBrowsersFailed"),
);
} finally {
setIsLoading(false);
}
};
void loadSupportedBrowsers();
}, []);
const isBrowserSupported = (browser: string): boolean => {
return supportedBrowsers.includes(browser);
};
const checkBrowserSupport = async (browser: string): Promise<boolean> => {
try {
return await invoke<boolean>("is_browser_supported_on_platform", {
browserStr: browser,
});
} catch (err) {
console.error(`Failed to check support for browser ${browser}:`, err);
return false;
}
};
return {
supportedBrowsers,
isLoading,
error,
isBrowserSupported,
checkBrowserSupport,
};
}
-76
View File
@@ -1,76 +0,0 @@
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { useCallback, useEffect, useState } from "react";
import i18n from "@/i18n";
import type { Extension, ExtensionGroup } from "@/types";
export function useExtensionEvents() {
const [extensions, setExtensions] = useState<Extension[]>([]);
const [extensionGroups, setExtensionGroups] = useState<ExtensionGroup[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const loadExtensions = useCallback(async () => {
try {
const exts = await invoke<Extension[]>("list_extensions");
setExtensions(exts);
setError(null);
} catch (err: unknown) {
console.error("Failed to load extensions:", err);
setExtensions([]);
}
}, []);
const loadExtensionGroups = useCallback(async () => {
try {
const groups = await invoke<ExtensionGroup[]>("list_extension_groups");
setExtensionGroups(groups);
setError(null);
} catch (err: unknown) {
console.error("Failed to load extension groups:", err);
setExtensionGroups([]);
}
}, []);
const loadAll = useCallback(async () => {
await Promise.all([loadExtensions(), loadExtensionGroups()]);
}, [loadExtensions, loadExtensionGroups]);
useEffect(() => {
let unlisten: (() => void) | undefined;
const setup = async () => {
try {
await loadAll();
unlisten = await listen("extensions-changed", () => {
void loadAll();
});
} catch (err) {
console.error("Failed to setup extension event listeners:", err);
setError(
i18n.t("errors.setupExtensionListenersFailed", {
error: JSON.stringify(err),
}),
);
} finally {
setIsLoading(false);
}
};
void setup();
return () => {
if (unlisten) unlisten();
};
}, [loadAll]);
return {
extensions,
extensionGroups,
isLoading,
error,
loadExtensions,
loadExtensionGroups,
loadAll,
};
}
+21 -41
View File
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { getCurrentOS, type OperatingSystem } from "@/lib/platform";
// Platform-specific imports
let macOSPermissions:
@@ -25,21 +26,23 @@ interface UsePermissionsReturn {
isMicrophoneAccessGranted: boolean;
isCameraAccessGranted: boolean;
isInitialized: boolean;
currentOS: OperatingSystem | null;
requiresSystemPermissions: boolean;
}
export function usePermissions(): UsePermissionsReturn {
export function usePermissions(active = true): UsePermissionsReturn {
const [isMicrophoneAccessGranted, setIsMicrophoneAccessGranted] =
useState(false);
const [isCameraAccessGranted, setIsCameraAccessGranted] = useState(false);
const [currentPlatform, setCurrentPlatform] = useState<string | null>(null);
const [currentOS, setCurrentOS] = useState<OperatingSystem | null>(null);
const [isInitialized, setIsInitialized] = useState(false);
const intervalRef = useRef<NodeJS.Timeout | null>(null);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
// Check permissions status
const checkPermissions = useCallback(async () => {
if (!currentPlatform) return;
if (!currentOS) return;
if (currentPlatform !== "macos") {
if (currentOS !== "macos") {
// Windows/Linux - assume permissions are granted
setIsMicrophoneAccessGranted(true);
setIsCameraAccessGranted(true);
@@ -58,19 +61,19 @@ export function usePermissions(): UsePermissionsReturn {
setIsMicrophoneAccessGranted(micGranted);
setIsCameraAccessGranted(camGranted);
setIsInitialized(true);
}
} catch (error) {
console.error("Failed to check permissions on macOS:", error);
} finally {
setIsInitialized(true);
}
}, [currentPlatform]);
}, [currentOS]);
// Request permission
const requestPermission = useCallback(
async (type: PermissionType): Promise<boolean> => {
// Non-macOS platforms do not require this permission gate.
if (!currentPlatform || currentPlatform !== "macos") return true;
if ((currentOS ?? getCurrentOS()) !== "macos") return true;
// macOS - use the permissions API
try {
@@ -96,59 +99,34 @@ export function usePermissions(): UsePermissionsReturn {
await permissions.requestCameraPermission();
}
for (let attempt = 0; attempt < 8; attempt += 1) {
const granted = await readPermission();
if (granted) return true;
await new Promise((resolve) => setTimeout(resolve, 1000));
}
// Read once immediately. The hook's macOS poll keeps watching for
// delayed TCC propagation without holding this request (and any next
// system prompt) open for several extra seconds.
return readPermission();
} catch (error) {
console.error(`Failed to request ${type} permission on macOS:`, error);
return false;
}
},
[currentPlatform],
[currentOS],
);
// Initialize platform detection and start interval checking
useEffect(() => {
const initializePlatform = () => {
try {
// Detect platform - on macOS we need permissions, on others we don't
const userAgent = navigator.userAgent;
let platformName = "unknown";
if (userAgent.includes("Mac")) {
platformName = "macos";
} else if (userAgent.includes("Win")) {
platformName = "windows";
} else if (userAgent.includes("Linux")) {
platformName = "linux";
}
setCurrentPlatform(platformName);
} catch (error) {
console.error("Failed to detect platform:", error);
// Fallback - assume non-macOS
setCurrentPlatform("unknown");
}
};
initializePlatform();
setCurrentOS(getCurrentOS());
}, []);
// Set up interval checking when platform is determined.
// On non-macOS platforms, permissions are always granted — a single check
// is enough and we skip the interval entirely to avoid burning CPU.
useEffect(() => {
if (!currentPlatform) return;
if (!currentOS || !active) return;
// Initial check
void checkPermissions();
// Only poll on macOS where permissions can change at runtime
if (currentPlatform !== "macos") return;
if (currentOS !== "macos") return;
intervalRef.current = setInterval(() => {
void checkPermissions();
@@ -160,12 +138,14 @@ export function usePermissions(): UsePermissionsReturn {
intervalRef.current = null;
}
};
}, [currentPlatform, checkPermissions]);
}, [active, currentOS, checkPermissions]);
return {
requestPermission,
isMicrophoneAccessGranted,
isCameraAccessGranted,
isInitialized,
currentOS,
requiresSystemPermissions: currentOS === "macos",
};
}
+1 -1
View File
@@ -27,7 +27,7 @@ export const SUPPORTED_LANGUAGES = [
export type SupportedLanguage = (typeof SUPPORTED_LANGUAGES)[number]["code"];
export const LANGUAGE_FALLBACKS: Record<string, string[]> = {
const LANGUAGE_FALLBACKS: Record<string, string[]> = {
uk: ["ru", "en"],
be: ["ru", "en"],
"zh-TW": ["zh", "en"],
+10 -42
View File
@@ -1542,47 +1542,6 @@
"badgeUnavailable": "Unavailable",
"openButton": "Open"
},
"locationProxy": {
"title": "Quick Location Proxy",
"description": "Choose a country to route this profile through. A proxy will be created automatically.",
"country": "Country",
"selectCountry": "Select a country",
"searchCountry": "Search country...",
"noCountriesFound": "No countries found.",
"apply": "Apply",
"creating": "Creating proxy...",
"success": "Location proxy applied",
"failed": "Failed to apply location proxy",
"titleCreate": "Create Location Proxy",
"descriptionCreate": "Create a geo-targeted proxy with a 24-hour sticky session",
"countryLabel": "Country (required)",
"regionLabel": "Region (optional)",
"cityLabel": "City (optional)",
"ispLabel": "ISP (optional)",
"nameLabel": "Name",
"namePlaceholder": "Proxy name",
"loadingCountries": "Loading countries...",
"selectCountryPh": "Select country",
"searchCountries": "Search countries...",
"loadFailed": "Failed to load countries",
"selectCountryFirst": "Select a country first",
"loadingRegions": "Loading regions...",
"noRegions": "No regions available",
"selectRegion": "Select region",
"searchRegions": "Search regions...",
"loadingCities": "Loading cities...",
"noCities": "No cities available",
"selectCity": "Select city",
"searchCities": "Search cities...",
"loadingIsps": "Loading ISPs...",
"noIsps": "No ISPs available",
"selectIsp": "Select ISP",
"searchIsps": "Search ISPs...",
"createSuccess": "Location proxy created",
"createFailed": "Failed to create location proxy",
"creatingButton": "Creating...",
"createButton": "Create"
},
"wayfernTerms": {
"title": "Wayfern Terms and Conditions",
"description": "Before using Donut Browser, you must read and agree to Wayfern's Terms and Conditions.",
@@ -1873,7 +1832,16 @@
"fingerprintMatchFailed": "Couldn't match the fingerprint to the proxy.",
"proxySidecarVersionMismatch": "Some Donut Browser files are from different versions. Reinstall the latest update; your profiles will stay safe.",
"updateProfilesRunning": "Stop all running profiles before installing the update.",
"updatePreparationFailed": "Donut Browser could not safely stop a background network process. Restart your computer, then try the update again."
"updatePreparationFailed": "Donut Browser could not safely stop a background network process. Restart your computer, then try the update again.",
"wayfernTermsRequired": "Accept the Wayfern Terms and Conditions before starting the MCP server.",
"apiPortUnavailable": "The API server could not find an available port.",
"mcpServerAlreadyRunning": "The MCP server is already running.",
"mcpServerNotRunning": "The MCP server is not running.",
"mcpPortUnavailable": "The MCP server could not find an available port.",
"mcpConfigurationUnavailable": "The MCP server configuration is unavailable. Restart the server and try again.",
"mcpAgentUnknown": "This MCP client is not supported.",
"mcpAgentInstallFailed": "Could not add Donut Browser to the MCP client: {{detail}}",
"mcpAgentRemoveFailed": "Could not remove Donut Browser from the MCP client: {{detail}}"
},
"rail": {
"profiles": "Profiles",
+10 -42
View File
@@ -1542,47 +1542,6 @@
"badgeUnavailable": "No disponible",
"openButton": "Abrir"
},
"locationProxy": {
"title": "Proxy rápido por ubicación",
"description": "Elige un país por el que enrutar este perfil. Se creará un proxy automáticamente.",
"country": "País",
"selectCountry": "Selecciona un país",
"searchCountry": "Buscar país...",
"noCountriesFound": "No se encontraron países.",
"apply": "Aplicar",
"creating": "Creando proxy...",
"success": "Proxy de ubicación aplicado",
"failed": "Error al aplicar el proxy de ubicación",
"titleCreate": "Crear proxy por ubicación",
"descriptionCreate": "Crea un proxy geolocalizado con una sesión persistente de 24 horas",
"countryLabel": "País (obligatorio)",
"regionLabel": "Región (opcional)",
"cityLabel": "Ciudad (opcional)",
"ispLabel": "ISP (opcional)",
"nameLabel": "Nombre",
"namePlaceholder": "Nombre del proxy",
"loadingCountries": "Cargando países...",
"selectCountryPh": "Selecciona país",
"searchCountries": "Buscar países...",
"loadFailed": "Error al cargar los países",
"selectCountryFirst": "Selecciona primero un país",
"loadingRegions": "Cargando regiones...",
"noRegions": "No hay regiones disponibles",
"selectRegion": "Selecciona región",
"searchRegions": "Buscar regiones...",
"loadingCities": "Cargando ciudades...",
"noCities": "No hay ciudades disponibles",
"selectCity": "Selecciona ciudad",
"searchCities": "Buscar ciudades...",
"loadingIsps": "Cargando ISP...",
"noIsps": "No hay ISP disponibles",
"selectIsp": "Selecciona ISP",
"searchIsps": "Buscar ISP...",
"createSuccess": "Proxy de ubicación creado",
"createFailed": "Error al crear el proxy de ubicación",
"creatingButton": "Creando...",
"createButton": "Crear"
},
"wayfernTerms": {
"title": "Términos y condiciones de Wayfern",
"description": "Antes de usar Donut Browser, debes leer y aceptar los Términos y Condiciones de Wayfern.",
@@ -1873,7 +1832,16 @@
"fingerprintMatchFailed": "No se pudo ajustar la huella al proxy.",
"proxySidecarVersionMismatch": "Algunos archivos de Donut Browser pertenecen a versiones diferentes. Reinstala la última actualización; tus perfiles permanecerán seguros.",
"updateProfilesRunning": "Detén todos los perfiles en ejecución antes de instalar la actualización.",
"updatePreparationFailed": "Donut Browser no pudo detener de forma segura un proceso de red en segundo plano. Reinicia el equipo y vuelve a intentar la actualización."
"updatePreparationFailed": "Donut Browser no pudo detener de forma segura un proceso de red en segundo plano. Reinicia el equipo y vuelve a intentar la actualización.",
"wayfernTermsRequired": "Acepta los Términos y condiciones de Wayfern antes de iniciar el servidor MCP.",
"apiPortUnavailable": "El servidor API no pudo encontrar un puerto disponible.",
"mcpServerAlreadyRunning": "El servidor MCP ya está en ejecución.",
"mcpServerNotRunning": "El servidor MCP no está en ejecución.",
"mcpPortUnavailable": "El servidor MCP no pudo encontrar un puerto disponible.",
"mcpConfigurationUnavailable": "La configuración del servidor MCP no está disponible. Reinicia el servidor e inténtalo de nuevo.",
"mcpAgentUnknown": "Este cliente MCP no es compatible.",
"mcpAgentInstallFailed": "No se pudo añadir Donut Browser al cliente MCP: {{detail}}",
"mcpAgentRemoveFailed": "No se pudo eliminar Donut Browser del cliente MCP: {{detail}}"
},
"rail": {
"profiles": "Perfiles",
+10 -42
View File
@@ -1542,47 +1542,6 @@
"badgeUnavailable": "Indisponible",
"openButton": "Ouvrir"
},
"locationProxy": {
"title": "Proxy rapide par lieu",
"description": "Choisissez un pays pour router ce profil. Un proxy sera créé automatiquement.",
"country": "Pays",
"selectCountry": "Sélectionnez un pays",
"searchCountry": "Rechercher un pays...",
"noCountriesFound": "Aucun pays trouvé.",
"apply": "Appliquer",
"creating": "Création du proxy...",
"success": "Proxy de localisation appliqué",
"failed": "Échec de l'application du proxy de localisation",
"titleCreate": "Créer un proxy de localisation",
"descriptionCreate": "Créez un proxy géolocalisé avec une session persistante de 24 heures",
"countryLabel": "Pays (obligatoire)",
"regionLabel": "Région (optionnel)",
"cityLabel": "Ville (optionnel)",
"ispLabel": "FAI (optionnel)",
"nameLabel": "Nom",
"namePlaceholder": "Nom du proxy",
"loadingCountries": "Chargement des pays...",
"selectCountryPh": "Sélectionnez un pays",
"searchCountries": "Rechercher des pays...",
"loadFailed": "Échec du chargement des pays",
"selectCountryFirst": "Sélectionnez d'abord un pays",
"loadingRegions": "Chargement des régions...",
"noRegions": "Aucune région disponible",
"selectRegion": "Sélectionnez une région",
"searchRegions": "Rechercher des régions...",
"loadingCities": "Chargement des villes...",
"noCities": "Aucune ville disponible",
"selectCity": "Sélectionnez une ville",
"searchCities": "Rechercher des villes...",
"loadingIsps": "Chargement des FAI...",
"noIsps": "Aucun FAI disponible",
"selectIsp": "Sélectionnez un FAI",
"searchIsps": "Rechercher des FAI...",
"createSuccess": "Proxy de localisation créé",
"createFailed": "Échec de la création du proxy de localisation",
"creatingButton": "Création...",
"createButton": "Créer"
},
"wayfernTerms": {
"title": "Conditions générales de Wayfern",
"description": "Avant d'utiliser Donut Browser, vous devez lire et accepter les Conditions Générales de Wayfern.",
@@ -1873,7 +1832,16 @@
"fingerprintMatchFailed": "Impossible d'aligner l'empreinte sur le proxy.",
"proxySidecarVersionMismatch": "Certains fichiers de Donut Browser proviennent de versions différentes. Réinstallez la dernière mise à jour ; vos profils resteront intacts.",
"updateProfilesRunning": "Arrêtez tous les profils en cours dexécution avant dinstaller la mise à jour.",
"updatePreparationFailed": "Donut Browser na pas pu arrêter en toute sécurité un processus réseau en arrière-plan. Redémarrez lordinateur, puis réessayez la mise à jour."
"updatePreparationFailed": "Donut Browser na pas pu arrêter en toute sécurité un processus réseau en arrière-plan. Redémarrez lordinateur, puis réessayez la mise à jour.",
"wayfernTermsRequired": "Acceptez les conditions générales de Wayfern avant de démarrer le serveur MCP.",
"apiPortUnavailable": "Le serveur API na trouvé aucun port disponible.",
"mcpServerAlreadyRunning": "Le serveur MCP est déjà en cours dexécution.",
"mcpServerNotRunning": "Le serveur MCP nest pas en cours dexécution.",
"mcpPortUnavailable": "Le serveur MCP na trouvé aucun port disponible.",
"mcpConfigurationUnavailable": "La configuration du serveur MCP est indisponible. Redémarrez le serveur et réessayez.",
"mcpAgentUnknown": "Ce client MCP nest pas pris en charge.",
"mcpAgentInstallFailed": "Impossible dajouter Donut Browser au client MCP : {{detail}}",
"mcpAgentRemoveFailed": "Impossible de retirer Donut Browser du client MCP : {{detail}}"
},
"rail": {
"profiles": "Profils",
+10 -42
View File
@@ -1542,47 +1542,6 @@
"badgeUnavailable": "利用不可",
"openButton": "開く"
},
"locationProxy": {
"title": "クイックロケーションプロキシ",
"description": "このプロファイルを経由する国を選択してください。プロキシが自動的に作成されます。",
"country": "国",
"selectCountry": "国を選択",
"searchCountry": "国を検索...",
"noCountriesFound": "国が見つかりません。",
"apply": "適用",
"creating": "プロキシを作成中...",
"success": "ロケーションプロキシを適用しました",
"failed": "ロケーションプロキシの適用に失敗しました",
"titleCreate": "位置プロキシを作成",
"descriptionCreate": "24 時間スティッキーセッションのジオターゲットプロキシを作成します",
"countryLabel": "国 (必須)",
"regionLabel": "地域 (任意)",
"cityLabel": "都市 (任意)",
"ispLabel": "ISP (任意)",
"nameLabel": "名前",
"namePlaceholder": "プロキシ名",
"loadingCountries": "国を読み込み中...",
"selectCountryPh": "国を選択",
"searchCountries": "国を検索...",
"loadFailed": "国の読み込みに失敗しました",
"selectCountryFirst": "まず国を選択してください",
"loadingRegions": "地域を読み込み中...",
"noRegions": "利用可能な地域がありません",
"selectRegion": "地域を選択",
"searchRegions": "地域を検索...",
"loadingCities": "都市を読み込み中...",
"noCities": "利用可能な都市がありません",
"selectCity": "都市を選択",
"searchCities": "都市を検索...",
"loadingIsps": "ISP を読み込み中...",
"noIsps": "利用可能な ISP がありません",
"selectIsp": "ISP を選択",
"searchIsps": "ISP を検索...",
"createSuccess": "位置プロキシを作成しました",
"createFailed": "位置プロキシの作成に失敗しました",
"creatingButton": "作成中...",
"createButton": "作成"
},
"wayfernTerms": {
"title": "Wayfern 利用規約",
"description": "Donut Browser を使用する前に、Wayfern の利用規約を読み、同意する必要があります。",
@@ -1873,7 +1832,16 @@
"fingerprintMatchFailed": "フィンガープリントをプロキシに合わせられませんでした。",
"proxySidecarVersionMismatch": "Donut Browser のファイルに異なるバージョンが混在しています。最新のアップデートを再インストールしてください。プロファイルはそのまま保持されます。",
"updateProfilesRunning": "アップデートをインストールする前に、実行中のプロファイルをすべて停止してください。",
"updatePreparationFailed": "バックグラウンドのネットワークプロセスを安全に停止できませんでした。コンピューターを再起動してから、もう一度アップデートしてください。"
"updatePreparationFailed": "バックグラウンドのネットワークプロセスを安全に停止できませんでした。コンピューターを再起動してから、もう一度アップデートしてください。",
"wayfernTermsRequired": "MCPサーバーを起動する前にWayfernの利用規約に同意してください。",
"apiPortUnavailable": "APIサーバーで利用可能なポートが見つかりませんでした。",
"mcpServerAlreadyRunning": "MCPサーバーはすでに実行中です。",
"mcpServerNotRunning": "MCPサーバーは実行されていません。",
"mcpPortUnavailable": "MCPサーバーで利用可能なポートが見つかりませんでした。",
"mcpConfigurationUnavailable": "MCPサーバーの設定を取得できません。サーバーを再起動して、もう一度お試しください。",
"mcpAgentUnknown": "このMCPクライアントはサポートされていません。",
"mcpAgentInstallFailed": "MCPクライアントにDonut Browserを追加できませんでした:{{detail}}",
"mcpAgentRemoveFailed": "MCPクライアントからDonut Browserを削除できませんでした:{{detail}}"
},
"rail": {
"profiles": "プロファイル",
+10 -42
View File
@@ -1542,47 +1542,6 @@
"badgeUnavailable": "사용 불가",
"openButton": "열기"
},
"locationProxy": {
"title": "빠른 위치 프록시",
"description": "이 프로필을 라우팅할 국가를 선택하세요. 프록시가 자동으로 생성됩니다.",
"country": "국가",
"selectCountry": "국가 선택",
"searchCountry": "국가 검색...",
"noCountriesFound": "국가를 찾을 수 없습니다.",
"apply": "적용",
"creating": "프록시 생성 중...",
"success": "위치 프록시가 적용되었습니다",
"failed": "위치 프록시 적용 실패",
"titleCreate": "위치 프록시 생성",
"descriptionCreate": "24시간 스티키 세션을 사용한 지역 기반 프록시 생성",
"countryLabel": "국가 (필수)",
"regionLabel": "지역 (선택 사항)",
"cityLabel": "도시 (선택 사항)",
"ispLabel": "ISP (선택 사항)",
"nameLabel": "이름",
"namePlaceholder": "프록시 이름",
"loadingCountries": "국가 불러오는 중...",
"selectCountryPh": "국가 선택",
"searchCountries": "국가 검색...",
"loadFailed": "국가 불러오기 실패",
"selectCountryFirst": "먼저 국가를 선택하세요",
"loadingRegions": "지역 불러오는 중...",
"noRegions": "사용 가능한 지역이 없습니다",
"selectRegion": "지역 선택",
"searchRegions": "지역 검색...",
"loadingCities": "도시 불러오는 중...",
"noCities": "사용 가능한 도시가 없습니다",
"selectCity": "도시 선택",
"searchCities": "도시 검색...",
"loadingIsps": "ISP 불러오는 중...",
"noIsps": "사용 가능한 ISP가 없습니다",
"selectIsp": "ISP 선택",
"searchIsps": "ISP 검색...",
"createSuccess": "위치 프록시가 생성되었습니다",
"createFailed": "위치 프록시 생성 실패",
"creatingButton": "생성 중...",
"createButton": "생성"
},
"wayfernTerms": {
"title": "Wayfern 이용 약관",
"description": "Donut Browser를 사용하기 전에 Wayfern의 이용 약관을 읽고 동의해야 합니다.",
@@ -1873,7 +1832,16 @@
"fingerprintMatchFailed": "지문을 프록시에 맞추지 못했습니다.",
"proxySidecarVersionMismatch": "Donut Browser 파일에 서로 다른 버전이 섞여 있습니다. 최신 업데이트를 다시 설치해 주세요. 프로필은 안전하게 유지됩니다.",
"updateProfilesRunning": "업데이트를 설치하기 전에 실행 중인 모든 프로필을 중지하세요.",
"updatePreparationFailed": "Donut Browser가 백그라운드 네트워크 프로세스를 안전하게 중지하지 못했습니다. 컴퓨터를 다시 시작한 후 업데이트를 다시 시도하세요."
"updatePreparationFailed": "Donut Browser가 백그라운드 네트워크 프로세스를 안전하게 중지하지 못했습니다. 컴퓨터를 다시 시작한 후 업데이트를 다시 시도하세요.",
"wayfernTermsRequired": "MCP 서버를 시작하기 전에 Wayfern 이용 약관에 동의하세요.",
"apiPortUnavailable": "API 서버에서 사용 가능한 포트를 찾지 못했습니다.",
"mcpServerAlreadyRunning": "MCP 서버가 이미 실행 중입니다.",
"mcpServerNotRunning": "MCP 서버가 실행 중이 아닙니다.",
"mcpPortUnavailable": "MCP 서버에서 사용 가능한 포트를 찾지 못했습니다.",
"mcpConfigurationUnavailable": "MCP 서버 구성을 사용할 수 없습니다. 서버를 다시 시작한 후 다시 시도하세요.",
"mcpAgentUnknown": "이 MCP 클라이언트는 지원되지 않습니다.",
"mcpAgentInstallFailed": "MCP 클라이언트에 Donut Browser를 추가하지 못했습니다: {{detail}}",
"mcpAgentRemoveFailed": "MCP 클라이언트에서 Donut Browser를 제거하지 못했습니다: {{detail}}"
},
"rail": {
"profiles": "프로필",
+10 -42
View File
@@ -1542,47 +1542,6 @@
"badgeUnavailable": "Indisponível",
"openButton": "Abrir"
},
"locationProxy": {
"title": "Proxy rápido por localização",
"description": "Escolha um país pelo qual rotear este perfil. Um proxy será criado automaticamente.",
"country": "País",
"selectCountry": "Selecione um país",
"searchCountry": "Buscar país...",
"noCountriesFound": "Nenhum país encontrado.",
"apply": "Aplicar",
"creating": "Criando proxy...",
"success": "Proxy de localização aplicado",
"failed": "Falha ao aplicar proxy de localização",
"titleCreate": "Criar proxy por localização",
"descriptionCreate": "Crie um proxy geolocalizado com sessão persistente de 24 horas",
"countryLabel": "País (obrigatório)",
"regionLabel": "Região (opcional)",
"cityLabel": "Cidade (opcional)",
"ispLabel": "ISP (opcional)",
"nameLabel": "Nome",
"namePlaceholder": "Nome do proxy",
"loadingCountries": "Carregando países...",
"selectCountryPh": "Selecione o país",
"searchCountries": "Buscar países...",
"loadFailed": "Falha ao carregar os países",
"selectCountryFirst": "Selecione primeiro um país",
"loadingRegions": "Carregando regiões...",
"noRegions": "Nenhuma região disponível",
"selectRegion": "Selecione a região",
"searchRegions": "Buscar regiões...",
"loadingCities": "Carregando cidades...",
"noCities": "Nenhuma cidade disponível",
"selectCity": "Selecione a cidade",
"searchCities": "Buscar cidades...",
"loadingIsps": "Carregando ISPs...",
"noIsps": "Nenhum ISP disponível",
"selectIsp": "Selecione o ISP",
"searchIsps": "Buscar ISPs...",
"createSuccess": "Proxy de localização criado",
"createFailed": "Falha ao criar proxy de localização",
"creatingButton": "Criando...",
"createButton": "Criar"
},
"wayfernTerms": {
"title": "Termos e condições da Wayfern",
"description": "Antes de usar o Donut Browser, você deve ler e concordar com os Termos e Condições da Wayfern.",
@@ -1873,7 +1832,16 @@
"fingerprintMatchFailed": "Não foi possível ajustar a impressão digital ao proxy.",
"proxySidecarVersionMismatch": "Alguns arquivos do Donut Browser são de versões diferentes. Reinstale a atualização mais recente; seus perfis permanecerão seguros.",
"updateProfilesRunning": "Pare todos os perfis em execução antes de instalar a atualização.",
"updatePreparationFailed": "O Donut Browser não conseguiu encerrar com segurança um processo de rede em segundo plano. Reinicie o computador e tente atualizar novamente."
"updatePreparationFailed": "O Donut Browser não conseguiu encerrar com segurança um processo de rede em segundo plano. Reinicie o computador e tente atualizar novamente.",
"wayfernTermsRequired": "Aceite os Termos e Condições da Wayfern antes de iniciar o servidor MCP.",
"apiPortUnavailable": "O servidor da API não encontrou uma porta disponível.",
"mcpServerAlreadyRunning": "O servidor MCP já está em execução.",
"mcpServerNotRunning": "O servidor MCP não está em execução.",
"mcpPortUnavailable": "O servidor MCP não encontrou uma porta disponível.",
"mcpConfigurationUnavailable": "A configuração do servidor MCP não está disponível. Reinicie o servidor e tente novamente.",
"mcpAgentUnknown": "Este cliente MCP não é compatível.",
"mcpAgentInstallFailed": "Não foi possível adicionar o Donut Browser ao cliente MCP: {{detail}}",
"mcpAgentRemoveFailed": "Não foi possível remover o Donut Browser do cliente MCP: {{detail}}"
},
"rail": {
"profiles": "Perfis",
+10 -42
View File
@@ -1542,47 +1542,6 @@
"badgeUnavailable": "Недоступен",
"openButton": "Открыть"
},
"locationProxy": {
"title": "Быстрый прокси по местоположению",
"description": "Выберите страну для маршрутизации этого профиля. Прокси будет создан автоматически.",
"country": "Страна",
"selectCountry": "Выберите страну",
"searchCountry": "Поиск страны...",
"noCountriesFound": "Страны не найдены.",
"apply": "Применить",
"creating": "Создание прокси...",
"success": "Прокси местоположения применен",
"failed": "Не удалось применить прокси местоположения",
"titleCreate": "Создать прокси по местоположению",
"descriptionCreate": "Создайте гео-прокси с 24-часовой сессией",
"countryLabel": "Страна (обязательно)",
"regionLabel": "Регион (необязательно)",
"cityLabel": "Город (необязательно)",
"ispLabel": "Провайдер (необязательно)",
"nameLabel": "Имя",
"namePlaceholder": "Имя прокси",
"loadingCountries": "Загрузка стран...",
"selectCountryPh": "Выберите страну",
"searchCountries": "Поиск стран...",
"loadFailed": "Не удалось загрузить страны",
"selectCountryFirst": "Сначала выберите страну",
"loadingRegions": "Загрузка регионов...",
"noRegions": "Нет доступных регионов",
"selectRegion": "Выберите регион",
"searchRegions": "Поиск регионов...",
"loadingCities": "Загрузка городов...",
"noCities": "Нет доступных городов",
"selectCity": "Выберите город",
"searchCities": "Поиск городов...",
"loadingIsps": "Загрузка провайдеров...",
"noIsps": "Нет доступных провайдеров",
"selectIsp": "Выберите провайдера",
"searchIsps": "Поиск провайдеров...",
"createSuccess": "Прокси местоположения создан",
"createFailed": "Не удалось создать прокси местоположения",
"creatingButton": "Создание...",
"createButton": "Создать"
},
"wayfernTerms": {
"title": "Условия использования Wayfern",
"description": "Прежде чем использовать Donut Browser, необходимо прочитать и согласиться с Условиями использования Wayfern.",
@@ -1873,7 +1832,16 @@
"fingerprintMatchFailed": "Не удалось подогнать отпечаток под прокси.",
"proxySidecarVersionMismatch": "Некоторые файлы Donut Browser относятся к разным версиям. Переустановите последнее обновление — ваши профили останутся в безопасности.",
"updateProfilesRunning": "Остановите все запущенные профили перед установкой обновления.",
"updatePreparationFailed": "Donut Browser не удалось безопасно остановить фоновый сетевой процесс. Перезагрузите компьютер и повторите обновление."
"updatePreparationFailed": "Donut Browser не удалось безопасно остановить фоновый сетевой процесс. Перезагрузите компьютер и повторите обновление.",
"wayfernTermsRequired": "Примите Условия использования Wayfern перед запуском сервера MCP.",
"apiPortUnavailable": "Серверу API не удалось найти свободный порт.",
"mcpServerAlreadyRunning": "Сервер MCP уже запущен.",
"mcpServerNotRunning": "Сервер MCP не запущен.",
"mcpPortUnavailable": "Серверу MCP не удалось найти свободный порт.",
"mcpConfigurationUnavailable": "Конфигурация сервера MCP недоступна. Перезапустите сервер и повторите попытку.",
"mcpAgentUnknown": "Этот клиент MCP не поддерживается.",
"mcpAgentInstallFailed": "Не удалось добавить Donut Browser в клиент MCP: {{detail}}",
"mcpAgentRemoveFailed": "Не удалось удалить Donut Browser из клиента MCP: {{detail}}"
},
"rail": {
"profiles": "Профили",
+10 -42
View File
@@ -1542,47 +1542,6 @@
"badgeUnavailable": "Kullanılamıyor",
"openButton": "Aç"
},
"locationProxy": {
"title": "Hızlı Konum Proxy'si",
"description": "Bu profilin trafiğinin yönlendirileceği ülkeyi seçin. Otomatik olarak bir proxy oluşturulacak.",
"country": "Ülke",
"selectCountry": "Bir ülke seçin",
"searchCountry": "Ülke ara...",
"noCountriesFound": "Ülke bulunamadı.",
"apply": "Uygula",
"creating": "Proxy oluşturuluyor...",
"success": "Konum proxy'si uygulandı",
"failed": "Konum proxy'si uygulanamadı",
"titleCreate": "Konum Proxy'si Oluştur",
"descriptionCreate": "24 saatlik sabit oturuma sahip, coğrafi hedefli bir proxy oluşturun",
"countryLabel": "Ülke (zorunlu)",
"regionLabel": "Bölge (isteğe bağlı)",
"cityLabel": "Şehir (isteğe bağlı)",
"ispLabel": "İSS (isteğe bağlı)",
"nameLabel": "Ad",
"namePlaceholder": "Proxy adı",
"loadingCountries": "Ülkeler yükleniyor...",
"selectCountryPh": "Ülke seçin",
"searchCountries": "Ülke ara...",
"loadFailed": "Ülkeler yüklenemedi",
"selectCountryFirst": "Önce bir ülke seçin",
"loadingRegions": "Bölgeler yükleniyor...",
"noRegions": "Kullanılabilir bölge yok",
"selectRegion": "Bölge seçin",
"searchRegions": "Bölge ara...",
"loadingCities": "Şehirler yükleniyor...",
"noCities": "Kullanılabilir şehir yok",
"selectCity": "Şehir seçin",
"searchCities": "Şehir ara...",
"loadingIsps": "İSS'ler yükleniyor...",
"noIsps": "Kullanılabilir İSS yok",
"selectIsp": "İSS seçin",
"searchIsps": "İSS ara...",
"createSuccess": "Konum proxy'si oluşturuldu",
"createFailed": "Konum proxy'si oluşturulamadı",
"creatingButton": "Oluşturuluyor...",
"createButton": "Oluştur"
},
"wayfernTerms": {
"title": "Wayfern Şartlar ve Koşulları",
"description": "Donut Browser'ı kullanmadan önce Wayfern'ün Şartlar ve Koşullarını okuyup kabul etmelisiniz.",
@@ -1873,7 +1832,16 @@
"fingerprintMatchFailed": "Parmak izi proxy'ye eşlenemedi.",
"proxySidecarVersionMismatch": "Bazı Donut Browser dosyaları farklı sürümlere ait. En son güncellemeyi yeniden yükleyin; profilleriniz güvende kalır.",
"updateProfilesRunning": "Güncellemeyi yüklemeden önce çalışan tüm profilleri durdurun.",
"updatePreparationFailed": "Donut Browser arka plandaki bir ağ işlemini güvenli şekilde durduramadı. Bilgisayarınızı yeniden başlatıp güncellemeyi tekrar deneyin."
"updatePreparationFailed": "Donut Browser arka plandaki bir ağ işlemini güvenli şekilde durduramadı. Bilgisayarınızı yeniden başlatıp güncellemeyi tekrar deneyin.",
"wayfernTermsRequired": "MCP sunucusunu başlatmadan önce Wayfern Hüküm ve Koşullarını kabul edin.",
"apiPortUnavailable": "API sunucusu kullanılabilir bir bağlantı noktası bulamadı.",
"mcpServerAlreadyRunning": "MCP sunucusu zaten çalışıyor.",
"mcpServerNotRunning": "MCP sunucusu çalışmıyor.",
"mcpPortUnavailable": "MCP sunucusu kullanılabilir bir bağlantı noktası bulamadı.",
"mcpConfigurationUnavailable": "MCP sunucusu yapılandırması kullanılamıyor. Sunucuyu yeniden başlatıp tekrar deneyin.",
"mcpAgentUnknown": "Bu MCP istemcisi desteklenmiyor.",
"mcpAgentInstallFailed": "Donut Browser MCP istemcisine eklenemedi: {{detail}}",
"mcpAgentRemoveFailed": "Donut Browser MCP istemcisinden kaldırılamadı: {{detail}}"
},
"rail": {
"profiles": "Profiller",
+10 -42
View File
@@ -1542,47 +1542,6 @@
"badgeUnavailable": "Không khả dụng",
"openButton": "Mở"
},
"locationProxy": {
"title": "Proxy vị trí nhanh",
"description": "Chọn một quốc gia để định tuyến profile này qua đó. Proxy sẽ được tạo tự động.",
"country": "Quốc gia",
"selectCountry": "Chọn quốc gia",
"searchCountry": "Tìm kiếm quốc gia...",
"noCountriesFound": "Không tìm thấy quốc gia nào.",
"apply": "Áp dụng",
"creating": "Đang tạo proxy...",
"success": "Đã áp dụng proxy vị trí",
"failed": "Áp dụng proxy vị trí thất bại",
"titleCreate": "Tạo proxy vị trí",
"descriptionCreate": "Tạo proxy nhắm mục tiêu địa lý với phiên bản cố định 24 giờ",
"countryLabel": "Quốc gia (bắt buộc)",
"regionLabel": "Khu vực (tùy chọn)",
"cityLabel": "Thành phố (tùy chọn)",
"ispLabel": "Nhà mạng (tùy chọn)",
"nameLabel": "Tên",
"namePlaceholder": "Tên proxy",
"loadingCountries": "Đang tải quốc gia...",
"selectCountryPh": "Chọn quốc gia",
"searchCountries": "Tìm kiếm quốc gia...",
"loadFailed": "Tải quốc gia thất bại",
"selectCountryFirst": "Hãy chọn quốc gia trước",
"loadingRegions": "Đang tải khu vực...",
"noRegions": "Không có khu vực nào",
"selectRegion": "Chọn khu vực",
"searchRegions": "Tìm kiếm khu vực...",
"loadingCities": "Đang tải thành phố...",
"noCities": "Không có thành phố nào",
"selectCity": "Chọn thành phố",
"searchCities": "Tìm kiếm thành phố...",
"loadingIsps": "Đang tải nhà mạng...",
"noIsps": "Không có nhà mạng nào",
"selectIsp": "Chọn nhà mạng",
"searchIsps": "Tìm kiếm nhà mạng...",
"createSuccess": "Tạo proxy vị trí thành công",
"createFailed": "Tạo proxy vị trí thất bại",
"creatingButton": "Đang tạo...",
"createButton": "Tạo"
},
"wayfernTerms": {
"title": "Điều khoản và điều kiện Wayfern",
"description": "Trước khi sử dụng Donut Browser, bạn phải đọc và đồng ý với Điều khoản và Điều kiện của Wayfern.",
@@ -1873,7 +1832,16 @@
"fingerprintMatchFailed": "Không thể khớp vân tay với proxy.",
"proxySidecarVersionMismatch": "Một số tệp Donut Browser thuộc các phiên bản khác nhau. Hãy cài đặt lại bản cập nhật mới nhất; hồ sơ của bạn vẫn được giữ an toàn.",
"updateProfilesRunning": "Hãy dừng tất cả hồ sơ đang chạy trước khi cài đặt bản cập nhật.",
"updatePreparationFailed": "Donut Browser không thể dừng an toàn một tiến trình mạng chạy nền. Hãy khởi động lại máy tính rồi thử cập nhật lại."
"updatePreparationFailed": "Donut Browser không thể dừng an toàn một tiến trình mạng chạy nền. Hãy khởi động lại máy tính rồi thử cập nhật lại.",
"wayfernTermsRequired": "Hãy chấp nhận Điều khoản và Điều kiện của Wayfern trước khi khởi động máy chủ MCP.",
"apiPortUnavailable": "Máy chủ API không tìm thấy cổng khả dụng.",
"mcpServerAlreadyRunning": "Máy chủ MCP đang chạy.",
"mcpServerNotRunning": "Máy chủ MCP chưa chạy.",
"mcpPortUnavailable": "Máy chủ MCP không tìm thấy cổng khả dụng.",
"mcpConfigurationUnavailable": "Cấu hình máy chủ MCP không khả dụng. Hãy khởi động lại máy chủ rồi thử lại.",
"mcpAgentUnknown": "Ứng dụng MCP này không được hỗ trợ.",
"mcpAgentInstallFailed": "Không thể thêm Donut Browser vào ứng dụng MCP: {{detail}}",
"mcpAgentRemoveFailed": "Không thể xóa Donut Browser khỏi ứng dụng MCP: {{detail}}"
},
"rail": {
"profiles": "Profile",
+10 -42
View File
@@ -1542,47 +1542,6 @@
"badgeUnavailable": "不可用",
"openButton": "打开"
},
"locationProxy": {
"title": "快速位置代理",
"description": "选择一个国家来路由此配置文件。系统将自动创建代理。",
"country": "国家",
"selectCountry": "选择国家",
"searchCountry": "搜索国家...",
"noCountriesFound": "未找到国家。",
"apply": "应用",
"creating": "正在创建代理...",
"success": "已应用位置代理",
"failed": "应用位置代理失败",
"titleCreate": "创建位置代理",
"descriptionCreate": "创建带 24 小时粘性会话的地理定位代理",
"countryLabel": "国家 (必填)",
"regionLabel": "地区 (可选)",
"cityLabel": "城市 (可选)",
"ispLabel": "ISP (可选)",
"nameLabel": "名称",
"namePlaceholder": "代理名称",
"loadingCountries": "正在加载国家...",
"selectCountryPh": "选择国家",
"searchCountries": "搜索国家...",
"loadFailed": "加载国家失败",
"selectCountryFirst": "请先选择国家",
"loadingRegions": "正在加载地区...",
"noRegions": "没有可用的地区",
"selectRegion": "选择地区",
"searchRegions": "搜索地区...",
"loadingCities": "正在加载城市...",
"noCities": "没有可用的城市",
"selectCity": "选择城市",
"searchCities": "搜索城市...",
"loadingIsps": "正在加载 ISP...",
"noIsps": "没有可用的 ISP",
"selectIsp": "选择 ISP",
"searchIsps": "搜索 ISP...",
"createSuccess": "已创建位置代理",
"createFailed": "创建位置代理失败",
"creatingButton": "正在创建...",
"createButton": "创建"
},
"wayfernTerms": {
"title": "Wayfern 条款和条件",
"description": "在使用 Donut Browser 之前,你必须阅读并同意 Wayfern 的条款和条件。",
@@ -1873,7 +1832,16 @@
"fingerprintMatchFailed": "无法将指纹匹配到代理。",
"proxySidecarVersionMismatch": "部分 Donut Browser 文件来自不同版本。请重新安装最新更新;你的配置文件将保持安全。",
"updateProfilesRunning": "安装更新前,请停止所有正在运行的配置文件。",
"updatePreparationFailed": "Donut Browser 无法安全停止后台网络进程。请重启电脑,然后再次尝试更新。"
"updatePreparationFailed": "Donut Browser 无法安全停止后台网络进程。请重启电脑,然后再次尝试更新。",
"wayfernTermsRequired": "请先接受 Wayfern 条款与条件,再启动 MCP 服务器。",
"apiPortUnavailable": "API 服务器找不到可用端口。",
"mcpServerAlreadyRunning": "MCP 服务器已在运行。",
"mcpServerNotRunning": "MCP 服务器未运行。",
"mcpPortUnavailable": "MCP 服务器找不到可用端口。",
"mcpConfigurationUnavailable": "MCP 服务器配置不可用。请重启服务器后重试。",
"mcpAgentUnknown": "不支持此 MCP 客户端。",
"mcpAgentInstallFailed": "无法将 Donut Browser 添加到 MCP 客户端:{{detail}}",
"mcpAgentRemoveFailed": "无法从 MCP 客户端移除 Donut Browser{{detail}}"
},
"rail": {
"profiles": "配置文件",
+31
View File
@@ -54,6 +54,15 @@ export type BackendErrorCode =
| "UNSUPPORTED_DNS_RULES_FORMAT"
| "DNS_RULES_SAVE_FAILED"
| "DNS_RULES_EXPORT_FAILED"
| "WAYFERN_TERMS_REQUIRED"
| "API_PORT_UNAVAILABLE"
| "MCP_SERVER_ALREADY_RUNNING"
| "MCP_SERVER_NOT_RUNNING"
| "MCP_PORT_UNAVAILABLE"
| "MCP_CONFIGURATION_UNAVAILABLE"
| "MCP_AGENT_UNKNOWN"
| "MCP_AGENT_INSTALL_FAILED"
| "MCP_AGENT_REMOVE_FAILED"
| "INTERNAL_ERROR";
export interface BackendError {
@@ -211,6 +220,28 @@ export function translateBackendError(t: TFunction, err: unknown): string {
return t("backendErrors.dnsRulesSaveFailed");
case "DNS_RULES_EXPORT_FAILED":
return t("backendErrors.dnsRulesExportFailed");
case "WAYFERN_TERMS_REQUIRED":
return t("backendErrors.wayfernTermsRequired");
case "API_PORT_UNAVAILABLE":
return t("backendErrors.apiPortUnavailable");
case "MCP_SERVER_ALREADY_RUNNING":
return t("backendErrors.mcpServerAlreadyRunning");
case "MCP_SERVER_NOT_RUNNING":
return t("backendErrors.mcpServerNotRunning");
case "MCP_PORT_UNAVAILABLE":
return t("backendErrors.mcpPortUnavailable");
case "MCP_CONFIGURATION_UNAVAILABLE":
return t("backendErrors.mcpConfigurationUnavailable");
case "MCP_AGENT_UNKNOWN":
return t("backendErrors.mcpAgentUnknown");
case "MCP_AGENT_INSTALL_FAILED":
return t("backendErrors.mcpAgentInstallFailed", {
detail: parsed.params?.detail ?? "",
});
case "MCP_AGENT_REMOVE_FAILED":
return t("backendErrors.mcpAgentRemoveFailed", {
detail: parsed.params?.detail ?? "",
});
case "CLEAR_ON_CLOSE_UNAVAILABLE":
return t("backendErrors.clearOnCloseUnavailable");
case "INTERNAL_ERROR":
+3 -10
View File
@@ -5,6 +5,9 @@
import { FaChrome, FaExclamationTriangle, FaFire } from "react-icons/fa";
import { LuLock } from "react-icons/lu";
import { getCurrentOS } from "@/lib/platform";
export { getCurrentOS } from "@/lib/platform";
/**
* Map internal browser names to display names
@@ -45,16 +48,6 @@ export function getProfileIcon(profile: {
return getBrowserIcon(profile.browser);
}
export const getCurrentOS = () => {
if (typeof window !== "undefined") {
const userAgent = window.navigator.userAgent;
if (userAgent.includes("Win")) return "windows";
if (userAgent.includes("Mac")) return "macos";
if (userAgent.includes("Linux")) return "linux";
}
return "unknown";
};
export function isCrossOsProfile(profile: {
host_os?: string;
wayfern_config?: { os?: string };
-31
View File
@@ -1,31 +0,0 @@
/**
* Extracts the root error message from nested error strings
* Removes redundant "Failed to..." prefixes to show only the most specific error
*/
export function extractRootError(error: unknown): string {
if (!error) return "Unknown error";
const errorStr = error instanceof Error ? error.message : String(error);
// Split by common error prefixes and take the last meaningful part
const errorParts = errorStr.split(/Failed to [^:]+: /);
const rootError = errorParts[errorParts.length - 1];
// Clean up any remaining nested structure
const cleanError = rootError.replace(/^"([^"]+)"$/, "$1");
return cleanError || errorStr;
}
/**
* Shows error toast with cleaned error message
*/
export function showCleanErrorToast(error: unknown, prefix?: string) {
const rootError = extractRootError(error);
const message = prefix ? `${prefix}: ${rootError}` : rootError;
// Import dynamically to avoid circular dependencies
void import("./toast-utils").then(({ showErrorToast }) => {
showErrorToast(message);
});
}
+1 -26
View File
@@ -1,11 +1,4 @@
import {
attachConsole,
debug,
error,
info,
trace,
warn,
} from "@tauri-apps/plugin-log";
import { attachConsole } from "@tauri-apps/plugin-log";
let consoleAttached = false;
@@ -22,21 +15,3 @@ export async function setupLogging() {
console.error("Failed to attach console to logging plugin:", err);
}
}
export const logger = {
error: (message: string, ...args: unknown[]) => {
error(`${message} ${args.map((arg) => JSON.stringify(arg)).join(" ")}`);
},
warn: (message: string, ...args: unknown[]) => {
warn(`${message} ${args.map((arg) => JSON.stringify(arg)).join(" ")}`);
},
info: (message: string, ...args: unknown[]) => {
info(`${message} ${args.map((arg) => JSON.stringify(arg)).join(" ")}`);
},
debug: (message: string, ...args: unknown[]) => {
debug(`${message} ${args.map((arg) => JSON.stringify(arg)).join(" ")}`);
},
log: (message: string, ...args: unknown[]) => {
trace(`${message} ${args.map((arg) => JSON.stringify(arg)).join(" ")}`);
},
};
-9
View File
@@ -1,9 +0,0 @@
/**
* Trims a name to a maximum length and adds ellipsis if needed
* @param name The name to trim
* @param maxLength Maximum length before truncation (default: 30)
* @returns Trimmed name with ellipsis if truncated
*/
export function trimName(name: string, maxLength: number = 30): string {
return name.length > maxLength ? `${name.substring(0, maxLength)}...` : name;
}
+5
View File
@@ -16,3 +16,8 @@ export function isOnboardingActive(): boolean {
// clicks "Finish" (not when they skip early). The page listens for it to show
// the celebratory thank-you dialog.
export const ONBOARDING_TOUR_FINISHED_EVENT = "donut:onboarding-tour-finished";
// Dispatched when the product tour is either finished or explicitly skipped.
// This is separate from the finished event because both outcomes complete the
// one-shot onboarding, while only a full finish earns the celebration.
export const ONBOARDING_TOUR_CLOSED_EVENT = "donut:onboarding-tour-closed";
+25
View File
@@ -0,0 +1,25 @@
export type OperatingSystem = "macos" | "windows" | "linux" | "unknown";
type NavigatorWithUserAgentData = Navigator & {
userAgentData?: {
platform?: string;
};
};
export function getCurrentOS(): OperatingSystem {
if (typeof navigator === "undefined") return "unknown";
const extendedNavigator = navigator as NavigatorWithUserAgentData;
const platform =
extendedNavigator.userAgentData?.platform ?? navigator.platform ?? "";
const signature = `${platform} ${navigator.userAgent}`;
if (/Windows|Win32|Win64/i.test(signature)) return "windows";
if (/Mac|iPhone|iPad|iPod/i.test(signature)) return "macos";
if (/Linux|X11/i.test(signature)) return "linux";
return "unknown";
}
export function isMacOS(): boolean {
return getCurrentOS() === "macos";
}
+4 -11
View File
@@ -5,6 +5,8 @@
* the glyph while everyone else sees `Ctrl`.
*/
import { isMacOS } from "@/lib/platform";
export type ShortcutGroup =
| "navigation"
| "actions"
@@ -137,17 +139,8 @@ export function formatGroupShortcut(digit: number): string[] {
return [mac ? "⌘" : "Ctrl", String(digit)];
}
export function isMac(): boolean {
if (typeof navigator === "undefined") return false;
// userAgentData is preferred but not in all browsers; fall back to platform.
// `navigator.platform` is deprecated but still works in Tauri's webview.
const ua = navigator.userAgent || "";
const platform =
(navigator as unknown as { userAgentData?: { platform?: string } })
.userAgentData?.platform ??
navigator.platform ??
"";
return /Mac|iPhone|iPad|iPod/.test(platform) || /Mac OS X/.test(ua);
function isMac(): boolean {
return isMacOS();
}
/**
-24
View File
@@ -283,27 +283,3 @@ export function showSyncProgressToast(
},
});
}
export function showUnifiedVersionUpdateToast(
title: string,
options?: {
id?: string;
description?: string;
progress?: {
current: number;
total: number;
found: number;
current_browser?: string;
};
duration?: number;
onCancel?: () => void;
},
) {
return showToast({
type: "version-update",
title,
id: "unified-version-update",
duration: Number.POSITIVE_INFINITY, // Keep showing until completed
...options,
});
}
-4
View File
@@ -4,7 +4,3 @@ import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}