mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-07-28 15:18:48 +02:00
refactor: cleanup
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
@@ -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 },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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
@@ -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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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!("®ion={}", 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!("®ion={}", 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
@@ -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
@@ -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
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 d’exécution avant d’installer la mise à jour.",
|
||||
"updatePreparationFailed": "Donut Browser n’a pas pu arrêter en toute sécurité un processus réseau en arrière-plan. Redémarrez l’ordinateur, puis réessayez la mise à jour."
|
||||
"updatePreparationFailed": "Donut Browser n’a pas pu arrêter en toute sécurité un processus réseau en arrière-plan. Redémarrez l’ordinateur, 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 n’a trouvé aucun port disponible.",
|
||||
"mcpServerAlreadyRunning": "Le serveur MCP est déjà en cours d’exécution.",
|
||||
"mcpServerNotRunning": "Le serveur MCP n’est pas en cours d’exécution.",
|
||||
"mcpPortUnavailable": "Le serveur MCP n’a trouvé aucun port disponible.",
|
||||
"mcpConfigurationUnavailable": "La configuration du serveur MCP est indisponible. Redémarrez le serveur et réessayez.",
|
||||
"mcpAgentUnknown": "Ce client MCP n’est pas pris en charge.",
|
||||
"mcpAgentInstallFailed": "Impossible d’ajouter Donut Browser au client MCP : {{detail}}",
|
||||
"mcpAgentRemoveFailed": "Impossible de retirer Donut Browser du client MCP : {{detail}}"
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "Profils",
|
||||
|
||||
+10
-42
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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": "配置文件",
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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
@@ -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(" ")}`);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,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));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user