From f02397dba92301d7633ba74bf75c1ffaead4f35e Mon Sep 17 00:00:00 2001 From: zhom <2717306+zhom@users.noreply.github.com> Date: Tue, 12 May 2026 20:50:29 +0400 Subject: [PATCH] refactor: creation button disaster recovery --- src-tauri/src/cloud_auth.rs | 13 +- src-tauri/src/settings_manager.rs | 11 + src-tauri/src/sync/engine.rs | 43 +++ src/components/account-page.tsx | 536 +++++++++++++++++++++++------ src/components/home-header.tsx | 12 +- src/components/settings-dialog.tsx | 59 +++- src/i18n/locales/en.json | 20 +- src/i18n/locales/es.json | 20 +- src/i18n/locales/fr.json | 20 +- src/i18n/locales/ja.json | 20 +- src/i18n/locales/pt.json | 20 +- src/i18n/locales/ru.json | 20 +- src/i18n/locales/zh.json | 20 +- src/lib/backend-errors.ts | 3 + 14 files changed, 689 insertions(+), 128 deletions(-) diff --git a/src-tauri/src/cloud_auth.rs b/src-tauri/src/cloud_auth.rs index f4e1a78..a8f9472 100644 --- a/src-tauri/src/cloud_auth.rs +++ b/src-tauri/src/cloud_auth.rs @@ -1215,13 +1215,14 @@ pub async fn cloud_refresh_profile() -> Result { pub async fn cloud_logout(app_handle: tauri::AppHandle) -> Result<(), String> { CLOUD_AUTH.logout().await?; - // Clear sync settings if they point to the cloud URL (prevent leak into Self-Hosted tab) + // Always clear the stored sync URL and token on cloud logout. While the + // user was signed in, the cloud auth flow populated these with the hosted + // sync server's URL + a server-issued token — leaving them in place would + // pre-fill the Self-Hosted tab with our production URL and a token the + // user never typed. The cloud-URL-only check we used to do here missed + // trailing-slash / scheme variants and any future cloud endpoint moves. let manager = crate::settings_manager::SettingsManager::instance(); - if let Ok(sync_settings) = manager.get_sync_settings() { - if sync_settings.sync_server_url.as_deref() == Some(CLOUD_SYNC_URL) { - let _ = manager.save_sync_server_url(None); - } - } + let _ = manager.save_sync_server_url(None); let _ = manager.remove_sync_token(&app_handle).await; // Remove cloud-managed and cloud-derived proxies diff --git a/src-tauri/src/settings_manager.rs b/src-tauri/src/settings_manager.rs index ad3794f..84e87e9 100644 --- a/src-tauri/src/settings_manager.rs +++ b/src-tauri/src/settings_manager.rs @@ -991,6 +991,17 @@ pub async fn save_sync_settings( sync_server_url: Option, sync_token: Option, ) -> Result { + // Cloud login and self-hosted sync share the same sync engine and a + // profile can't be sync'd to two backends at once. Block any *write* + // (non-null URL or token) while the user is signed into their cloud + // account — the clearing path (both `None`) is always allowed so logged- + // in users can wipe a stale self-hosted config that pre-dates their + // sign-in. + let is_setting_self_hosted = sync_server_url.is_some() || sync_token.is_some(); + if is_setting_self_hosted && crate::cloud_auth::CLOUD_AUTH.is_logged_in().await { + return Err(serde_json::json!({ "code": "SELF_HOSTED_REQUIRES_LOGOUT" }).to_string()); + } + let manager = SettingsManager::instance(); manager diff --git a/src-tauri/src/sync/engine.rs b/src-tauri/src/sync/engine.rs index 9f2c6f9..49a37e9 100644 --- a/src-tauri/src/sync/engine.rs +++ b/src-tauri/src/sync/engine.rs @@ -3526,6 +3526,49 @@ pub fn get_unsynced_entity_counts() -> Result { #[tauri::command] pub async fn enable_sync_for_all_entities(app_handle: tauri::AppHandle) -> Result<(), String> { + // Enable sync for all eligible profiles. Without this the user would see + // groups/proxies/vpns syncing while their profiles stay local-only — the + // long-standing source of issue #352. Encrypted mode wins when an E2E + // password is already configured; otherwise we fall back to plain Regular. + { + let profile_manager = ProfileManager::instance(); + let profiles = profile_manager + .list_profiles() + .map_err(|e| format!("Failed to list profiles: {e}"))?; + let desired_mode = if encryption::has_e2e_password() { + SyncMode::Encrypted + } else { + SyncMode::Regular + }; + let desired_mode_str = match desired_mode { + SyncMode::Encrypted => "Encrypted", + SyncMode::Regular => "Regular", + SyncMode::Disabled => "Disabled", + }; + for profile in &profiles { + // Skip profiles that are already syncing (any non-Disabled mode), + // ephemeral profiles (data wipes on quit, sync is meaningless), and + // cross-OS profiles (the OS-specific binary isn't installed locally + // so a sync round-trip would be one-sided). + if profile.sync_mode != SyncMode::Disabled || profile.ephemeral || profile.is_cross_os() { + continue; + } + if let Err(e) = set_profile_sync_mode( + app_handle.clone(), + profile.id.to_string(), + desired_mode_str.to_string(), + ) + .await + { + log::warn!( + "Failed to enable sync for profile {} ({}): {e}", + profile.name, + profile.id + ); + } + } + } + // Enable sync for all unsynced proxies { let proxies = crate::proxy_manager::PROXY_MANAGER.get_stored_proxies(); diff --git a/src/components/account-page.tsx b/src/components/account-page.tsx index 7f10372..267c475 100644 --- a/src/components/account-page.tsx +++ b/src/components/account-page.tsx @@ -1,12 +1,28 @@ "use client"; -import { useState } from "react"; +import { invoke } from "@tauri-apps/api/core"; +import { useCallback, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; -import { LuCloud, LuLogOut, LuRefreshCw, LuUser } from "react-icons/lu"; +import { + LuCloud, + LuEye, + LuEyeOff, + LuLogOut, + LuRefreshCw, + LuUser, +} from "react-icons/lu"; +import { LoadingButton } from "@/components/loading-button"; +import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Dialog, DialogContent } from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { useCloudAuth } from "@/hooks/use-cloud-auth"; +import { translateBackendError } from "@/lib/backend-errors"; import { showErrorToast, showSuccessToast } from "@/lib/toast-utils"; +import { cn } from "@/lib/utils"; +import type { SyncSettings } from "@/types"; interface AccountPageProps { isOpen: boolean; @@ -15,6 +31,8 @@ interface AccountPageProps { onOpenSignIn: () => void; } +type ConnectionStatus = "unknown" | "testing" | "connected" | "error"; + export function AccountPage({ isOpen, onClose, @@ -22,8 +40,34 @@ export function AccountPage({ onOpenSignIn, }: AccountPageProps) { const { t } = useTranslation(); - const { user, isLoggedIn, logout, refreshProfile } = useCloudAuth(); + const { + user, + isLoggedIn, + isLoading: isCloudLoading, + logout, + refreshProfile, + } = useCloudAuth(); const [isRefreshing, setIsRefreshing] = useState(false); + const [isLoggingOut, setIsLoggingOut] = useState(false); + + // Self-hosted server state. Loaded once when the dialog opens and persisted + // via `save_sync_settings` so the rest of the app picks up the new URL/token + // from `SettingsManager`. + const [serverUrl, setServerUrl] = useState(""); + const [token, setToken] = useState(""); + const [showToken, setShowToken] = useState(false); + const [isSavingSelfHosted, setIsSavingSelfHosted] = useState(false); + const [isTestingConnection, setIsTestingConnection] = useState(false); + const [connectionStatus, setConnectionStatus] = + useState("unknown"); + + const hasConfig = Boolean(serverUrl && token); + // Self-hosted and cloud are mutually exclusive — both share the same sync + // engine and a profile can't be sync'd to two backends. The tab trigger is + // disabled here AND the backend rejects mixed state (see `save_sync_settings` + // / `cloud_logout`), so even if someone bypasses the UI we don't end up + // with split-brain. + const selfHostedDisabled = isLoggedIn || isCloudLoading; const handleRefresh = async () => { setIsRefreshing(true); @@ -38,119 +82,407 @@ export function AccountPage({ }; const handleLogout = async () => { + setIsLoggingOut(true); try { await logout(); + // The backend wipes sync URL + token as part of cloud_logout (see + // `cloud_auth::cloud_logout`); pull the now-empty settings back into + // the form so a user who flips to the Self-hosted tab doesn't see the + // pre-logout production URL still sitting there. + await loadSelfHostedSettings(); showSuccessToast(t("account.loggedOut")); } catch (e) { showErrorToast(String(e)); + } finally { + setIsLoggingOut(false); } }; + const loadSelfHostedSettings = useCallback(async () => { + try { + const settings = await invoke("get_sync_settings"); + setServerUrl(settings.sync_server_url ?? ""); + setToken(settings.sync_token ?? ""); + setConnectionStatus( + settings.sync_server_url && settings.sync_token ? "unknown" : "unknown", + ); + } catch (error) { + console.error("Failed to load sync settings:", error); + } + }, []); + + useEffect(() => { + if (isOpen) { + void loadSelfHostedSettings(); + } + }, [isOpen, loadSelfHostedSettings]); + + const handleTestConnection = useCallback(async () => { + if (!serverUrl) { + showErrorToast(t("sync.config.serverUrlRequired")); + return; + } + setIsTestingConnection(true); + setConnectionStatus("testing"); + try { + const healthUrl = `${serverUrl.replace(/\/$/, "")}/health`; + const response = await fetch(healthUrl); + if (response.ok) { + setConnectionStatus("connected"); + showSuccessToast(t("sync.config.connectionSuccess")); + } else { + setConnectionStatus("error"); + showErrorToast(t("sync.config.serverError")); + } + } catch { + setConnectionStatus("error"); + showErrorToast(t("sync.config.connectFailed")); + } finally { + setIsTestingConnection(false); + } + }, [serverUrl, t]); + + const handleSaveSelfHosted = useCallback(async () => { + setIsSavingSelfHosted(true); + try { + await invoke("save_sync_settings", { + syncServerUrl: serverUrl || null, + syncToken: token || null, + }); + try { + await invoke("restart_sync_service"); + } catch (e) { + console.error("Failed to restart sync service:", e); + } + showSuccessToast(t("sync.config.settingsSaved")); + } catch (error) { + console.error("Failed to save sync settings:", error); + // Use the structured backend-error translator so the cloud-vs-self- + // hosted mutex (`SELF_HOSTED_REQUIRES_LOGOUT`) shows a clear message + // instead of the generic "save failed" toast. + showErrorToast(translateBackendError(t as never, error)); + } finally { + setIsSavingSelfHosted(false); + } + }, [serverUrl, token, t]); + + const handleDisconnectSelfHosted = useCallback(async () => { + setIsSavingSelfHosted(true); + try { + await invoke("save_sync_settings", { + syncServerUrl: null, + syncToken: null, + }); + try { + await invoke("restart_sync_service"); + } catch (e) { + console.error("Failed to restart sync service:", e); + } + setServerUrl(""); + setToken(""); + setConnectionStatus("unknown"); + showSuccessToast(t("sync.config.disconnected")); + } catch (error) { + console.error("Failed to disconnect:", error); + showErrorToast(t("sync.config.disconnectFailed")); + } finally { + setIsSavingSelfHosted(false); + } + }, [t]); + return (
-
-
- -
-
- {isLoggedIn && user ? ( - <> -

- {user.email} -

-

- {t("account.plan", { - plan: user.plan, - period: user.planPeriod ?? "—", - })} -

- - ) : ( - <> -

- {t("account.signedOut")} -

-

- {t("account.signedOutDescription")} -

- + + -
- - {isLoggedIn && user && ( -
-
-

- {t("account.fields.plan")} -

-

{user.plan}

-
-
-

- {t("account.fields.status")} -

-

{user.subscriptionStatus ?? "—"}

-
- {user.teamRole && ( -
-

- {t("account.fields.teamRole")} -

-

{user.teamRole}

-
- )} - {user.planPeriod && ( -
-

- {t("account.fields.period")} -

-

{user.planPeriod}

-
- )} -
- )} - -
- {isLoggedIn ? ( - <> - - - - ) : ( - - )} -
+ {t("account.tabs.account")} + + + {t("account.tabs.selfHosted")} + + + + +
+
+
+ +
+
+ {isLoggedIn && user ? ( + <> +

+ {user.email} +

+

+ {t("account.plan", { + plan: user.plan, + period: user.planPeriod ?? "—", + })} +

+ + ) : ( + <> +

+ {t("account.signedOut")} +

+

+ {t("account.signedOutDescription")} +

+ + )} +
+
+ + {isLoggedIn && user && ( +
+
+

+ {t("account.fields.plan")} +

+

+ {user.plan} +

+
+
+

+ {t("account.fields.status")} +

+

{user.subscriptionStatus ?? "—"}

+
+ {user.teamRole && ( +
+

+ {t("account.fields.teamRole")} +

+

{user.teamRole}

+
+ )} + {user.planPeriod && ( +
+

+ {t("account.fields.period")} +

+

{user.planPeriod}

+
+ )} +
+ )} + +
+ {isLoggedIn ? ( + <> + + { + void handleLogout(); + }} + className="h-8 text-xs gap-1.5" + > + + {t("account.logout")} + + + ) : ( + + )} +
+
+
+ + + {selfHostedDisabled ? ( + // Defensive: the tab trigger is disabled while the user is + // logged in, so this branch shouldn't be reachable via UI — + // but if state flips mid-render (e.g. a cloud login finishes + // while the tab is open), show the explanation instead of + // a silent empty card. +

+ {t("account.selfHosted.disabledWhileLoggedIn")} +

+ ) : ( +
+
+

+ {t("account.selfHosted.title")} +

+

+ {t("account.selfHosted.description")} +

+
+ +
+ + { + setServerUrl(e.target.value); + setConnectionStatus("unknown"); + }} + autoComplete="off" + spellCheck={false} + /> +
+ +
+ +
+ { + setToken(e.target.value); + setConnectionStatus("unknown"); + }} + autoComplete="off" + spellCheck={false} + className="pr-9" + /> + +
+
+ +
+ + {t("account.selfHosted.connectionStatus")} + + {connectionStatus === "connected" && ( + + {t("sync.status.connected")} + + )} + {connectionStatus === "error" && ( + + {t("sync.status.error")} + + )} + {connectionStatus === "testing" && ( + + {t("sync.status.syncing")} + + )} + {connectionStatus === "unknown" && ( + + {t("account.selfHosted.statusUnknown")} + + )} +
+ +
+ void handleTestConnection()} + className="h-8 text-xs" + > + {t("account.selfHosted.testConnection")} + + void handleSaveSelfHosted()} + className="h-8 text-xs" + > + {t("common.buttons.save")} + + {hasConfig && ( + + )} +
+
+ )} +
+
diff --git a/src/components/home-header.tsx b/src/components/home-header.tsx index 1c96fd2..52359e1 100644 --- a/src/components/home-header.tsx +++ b/src/components/home-header.tsx @@ -156,6 +156,8 @@ const HomeHeader = ({ }; }, []); + const isWindows = platform === "windows"; + return (
{isMacOS && (
{ setHasE2ePassword(false); setE2ePassword(""); @@ -1003,22 +1005,41 @@ export function SettingsDialog({ > {t("settings.encryption.changePassword")} - +
) : ( @@ -1065,10 +1086,22 @@ export function SettingsDialog({ setHasE2ePassword(true); setE2ePassword(""); setE2ePasswordConfirm(""); + try { + // Await rollover so any failure surfaces to the + // user instead of being lost via fire-and-forget. + // Without this, "change password" leaves entities + // half-re-encrypted with no visible error. + await invoke("rollover_encryption_for_all_entities"); + } catch (rolloverErr) { + console.error( + "Rollover after password set failed:", + rolloverErr, + ); + showErrorToast(String(rolloverErr)); + } showSuccessToast( t("settings.encryption.passwordSaved"), ); - void invoke("rollover_encryption_for_all_entities"); } catch (error) { showErrorToast(String(error)); } finally { @@ -1089,7 +1122,23 @@ export function SettingsDialog({
- {trialStatus?.type === "Active" ? ( + {cloudUser != null && cloudUser.plan !== "free" ? ( + // Paid Donut plan supersedes the local commercial trial — + // the trial only exists to gate commercial use until the + // user subscribes. Showing "Trial expired" to a paying + // customer reads like a billing error, so swap in a + // subscription-active badge instead. +
+

+ {t("settings.commercial.subscriptionActive", { + plan: cloudUser.plan, + })} +

+

+ {t("settings.commercial.subscriptionActiveDescription")} +

+
+ ) : trialStatus?.type === "Active" ? (

{t("settings.commercial.trialActive", { diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index f952b78..bdd3d4f 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -165,7 +165,9 @@ "trialActive": "Trial: {{days}} days, {{hours}} hours remaining", "trialActiveDescription": "Commercial use is free during the trial. When it ends, all features keep working — personal use stays free, only commercial use will require a license.", "trialExpired": "Trial expired", - "trialExpiredDescription": "Personal use remains free. Commercial use requires a license." + "trialExpiredDescription": "Personal use remains free. Commercial use requires a license.", + "subscriptionActive": "Subscribed — {{plan}} plan", + "subscriptionActiveDescription": "Your Donut Browser subscription is active. Commercial use is licensed for the duration of your plan." }, "advanced": { "title": "Advanced", @@ -1742,7 +1744,8 @@ "internal": "Something went wrong: {{detail}}", "invalidLaunchHookUrl": "Invalid launch hook URL. Use a full http:// or https:// URL.", "cookieDbLocked": "Could not read cookies — the database is locked. Close the browser and try again.", - "cookieDbUnavailable": "Could not read cookies — the cookie store is unavailable." + "cookieDbUnavailable": "Could not read cookies — the cookie store is unavailable.", + "selfHostedRequiresLogout": "Sign out of your Donut account before configuring a self-hosted server." }, "rail": { "profiles": "Profiles", @@ -1808,6 +1811,19 @@ "status": "Status", "teamRole": "Team role", "period": "Billing period" + }, + "tabs": { + "account": "Account", + "selfHosted": "Self-hosted" + }, + "selfHosted": { + "title": "Self-hosted sync server", + "description": "Point Donut at your own donut-sync server to sync profiles, proxies, groups, and extensions without using the hosted cloud.", + "disabledWhileLoggedIn": "Self-hosted sync is unavailable while you're signed into your Donut account. Sign out to use a custom server.", + "connectionStatus": "Connection:", + "statusUnknown": "Untested", + "testConnection": "Test connection", + "disconnect": "Disconnect" } } } diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index b625480..6024138 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -165,7 +165,9 @@ "trialActive": "Prueba: {{days}} días, {{hours}} horas restantes", "trialActiveDescription": "El uso comercial es gratuito durante la prueba. Al finalizar, todas las funciones siguen funcionando — el uso personal sigue siendo gratuito, solo el uso comercial requerirá una licencia.", "trialExpired": "Prueba expirada", - "trialExpiredDescription": "El uso personal sigue siendo gratuito. El uso comercial requiere una licencia." + "trialExpiredDescription": "El uso personal sigue siendo gratuito. El uso comercial requiere una licencia.", + "subscriptionActive": "Suscrito — plan {{plan}}", + "subscriptionActiveDescription": "Tu suscripción a Donut Browser está activa. El uso comercial está autorizado mientras tu plan esté vigente." }, "advanced": { "title": "Avanzado", @@ -1742,7 +1744,8 @@ "internal": "Algo salió mal: {{detail}}", "invalidLaunchHookUrl": "URL del hook de inicio no válida. Usa una URL completa http:// o https://.", "cookieDbLocked": "No se pudieron leer las cookies — la base de datos está bloqueada. Cierra el navegador e inténtalo de nuevo.", - "cookieDbUnavailable": "No se pudieron leer las cookies — el almacén de cookies no está disponible." + "cookieDbUnavailable": "No se pudieron leer las cookies — el almacén de cookies no está disponible.", + "selfHostedRequiresLogout": "Cierra sesión en tu cuenta de Donut antes de configurar un servidor autoalojado." }, "rail": { "profiles": "Perfiles", @@ -1808,6 +1811,19 @@ "status": "Estado", "teamRole": "Rol en el equipo", "period": "Período" + }, + "tabs": { + "account": "Cuenta", + "selfHosted": "Autoalojado" + }, + "selfHosted": { + "title": "Servidor de sincronización autoalojado", + "description": "Conecta Donut a tu propio servidor donut-sync para sincronizar perfiles, proxies, grupos y extensiones sin usar la nube alojada.", + "disabledWhileLoggedIn": "La sincronización autoalojada no está disponible mientras estás conectado a tu cuenta de Donut. Cierra sesión para usar un servidor personalizado.", + "connectionStatus": "Conexión:", + "statusUnknown": "Sin probar", + "testConnection": "Probar conexión", + "disconnect": "Desconectar" } } } diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 3bb4164..9eca11c 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -165,7 +165,9 @@ "trialActive": "Essai: {{days}} jours, {{hours}} heures restantes", "trialActiveDescription": "L'utilisation commerciale est gratuite pendant l'essai. À l'expiration, toutes les fonctionnalités continuent de fonctionner — l'utilisation personnelle reste gratuite, seule l'utilisation commerciale nécessitera une licence.", "trialExpired": "Essai expiré", - "trialExpiredDescription": "L'utilisation personnelle reste gratuite. L'utilisation commerciale nécessite une licence." + "trialExpiredDescription": "L'utilisation personnelle reste gratuite. L'utilisation commerciale nécessite une licence.", + "subscriptionActive": "Abonné — formule {{plan}}", + "subscriptionActiveDescription": "Votre abonnement Donut Browser est actif. L'usage commercial est licencié pendant toute la durée de votre formule." }, "advanced": { "title": "Avancé", @@ -1742,7 +1744,8 @@ "internal": "Une erreur s'est produite : {{detail}}", "invalidLaunchHookUrl": "URL du hook de lancement invalide. Utilisez une URL http:// ou https:// complète.", "cookieDbLocked": "Impossible de lire les cookies — la base de données est verrouillée. Fermez le navigateur et réessayez.", - "cookieDbUnavailable": "Impossible de lire les cookies — le magasin de cookies est indisponible." + "cookieDbUnavailable": "Impossible de lire les cookies — le magasin de cookies est indisponible.", + "selfHostedRequiresLogout": "Déconnectez-vous de votre compte Donut avant de configurer un serveur auto-hébergé." }, "rail": { "profiles": "Profils", @@ -1808,6 +1811,19 @@ "status": "Statut", "teamRole": "Rôle d’équipe", "period": "Période" + }, + "tabs": { + "account": "Compte", + "selfHosted": "Auto-hébergé" + }, + "selfHosted": { + "title": "Serveur de synchronisation auto-hébergé", + "description": "Connectez Donut à votre propre serveur donut-sync pour synchroniser profils, proxys, groupes et extensions sans utiliser le cloud hébergé.", + "disabledWhileLoggedIn": "La synchronisation auto-hébergée n'est pas disponible lorsque vous êtes connecté à votre compte Donut. Déconnectez-vous pour utiliser un serveur personnalisé.", + "connectionStatus": "Connexion :", + "statusUnknown": "Non testé", + "testConnection": "Tester la connexion", + "disconnect": "Déconnecter" } } } diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index b472746..3e5a435 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -165,7 +165,9 @@ "trialActive": "トライアル: 残り {{days}} 日 {{hours}} 時間", "trialActiveDescription": "トライアル期間中は商用利用が無料です。期間が終了してもすべての機能はそのまま使用できます — 個人利用は引き続き無料で、商用利用のみライセンスが必要になります。", "trialExpired": "トライアル期限切れ", - "trialExpiredDescription": "個人利用は引き続き無料です。商用利用にはライセンスが必要です。" + "trialExpiredDescription": "個人利用は引き続き無料です。商用利用にはライセンスが必要です。", + "subscriptionActive": "サブスクリプション中 — {{plan}} プラン", + "subscriptionActiveDescription": "Donut Browser のサブスクリプションが有効です。プランの期間中、商用利用がライセンスされます。" }, "advanced": { "title": "詳細設定", @@ -1742,7 +1744,8 @@ "internal": "問題が発生しました: {{detail}}", "invalidLaunchHookUrl": "起動フックURLが無効です。完全な http:// または https:// URL を使用してください。", "cookieDbLocked": "Cookie を読み取れません — データベースがロックされています。ブラウザを閉じてから再試行してください。", - "cookieDbUnavailable": "Cookie を読み取れません — Cookie ストアを利用できません。" + "cookieDbUnavailable": "Cookie を読み取れません — Cookie ストアを利用できません。", + "selfHostedRequiresLogout": "セルフホストサーバーを設定する前に Donut アカウントからサインアウトしてください。" }, "rail": { "profiles": "プロファイル", @@ -1808,6 +1811,19 @@ "status": "ステータス", "teamRole": "チームロール", "period": "請求周期" + }, + "tabs": { + "account": "アカウント", + "selfHosted": "セルフホスト" + }, + "selfHosted": { + "title": "セルフホスト同期サーバー", + "description": "Donut を独自の donut-sync サーバーに接続して、ホスト型クラウドを使わずにプロファイル、プロキシ、グループ、拡張機能を同期します。", + "disabledWhileLoggedIn": "Donut アカウントにサインインしている間はセルフホスト同期を利用できません。カスタムサーバーを使うにはサインアウトしてください。", + "connectionStatus": "接続:", + "statusUnknown": "未テスト", + "testConnection": "接続をテスト", + "disconnect": "切断" } } } diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index 43110d0..ea217a3 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -165,7 +165,9 @@ "trialActive": "Teste: {{days}} dias, {{hours}} horas restantes", "trialActiveDescription": "O uso comercial é gratuito durante o teste. Após o término, todos os recursos continuam funcionando — o uso pessoal permanece gratuito, apenas o uso comercial exigirá uma licença.", "trialExpired": "Teste expirado", - "trialExpiredDescription": "O uso pessoal continua gratuito. O uso comercial requer uma licença." + "trialExpiredDescription": "O uso pessoal continua gratuito. O uso comercial requer uma licença.", + "subscriptionActive": "Assinado — plano {{plan}}", + "subscriptionActiveDescription": "Sua assinatura do Donut Browser está ativa. O uso comercial está licenciado enquanto seu plano estiver vigente." }, "advanced": { "title": "Avançado", @@ -1742,7 +1744,8 @@ "internal": "Algo deu errado: {{detail}}", "invalidLaunchHookUrl": "URL do hook de inicialização inválida. Use uma URL completa http:// ou https://.", "cookieDbLocked": "Não foi possível ler os cookies — o banco de dados está bloqueado. Feche o navegador e tente novamente.", - "cookieDbUnavailable": "Não foi possível ler os cookies — o repositório de cookies está indisponível." + "cookieDbUnavailable": "Não foi possível ler os cookies — o repositório de cookies está indisponível.", + "selfHostedRequiresLogout": "Saia da sua conta Donut antes de configurar um servidor auto-hospedado." }, "rail": { "profiles": "Perfis", @@ -1808,6 +1811,19 @@ "status": "Status", "teamRole": "Função na equipe", "period": "Período" + }, + "tabs": { + "account": "Conta", + "selfHosted": "Auto-hospedado" + }, + "selfHosted": { + "title": "Servidor de sincronização auto-hospedado", + "description": "Conecte o Donut ao seu próprio servidor donut-sync para sincronizar perfis, proxies, grupos e extensões sem usar a nuvem hospedada.", + "disabledWhileLoggedIn": "A sincronização auto-hospedada não está disponível enquanto você está conectado à sua conta Donut. Saia para usar um servidor personalizado.", + "connectionStatus": "Conexão:", + "statusUnknown": "Não testado", + "testConnection": "Testar conexão", + "disconnect": "Desconectar" } } } diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index bb34fa7..6800af2 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -165,7 +165,9 @@ "trialActive": "Пробный период: осталось {{days}} дней, {{hours}} часов", "trialActiveDescription": "Коммерческое использование бесплатно в течение пробного периода. После его окончания все функции продолжают работать — личное использование остаётся бесплатным, и только для коммерческого использования потребуется лицензия.", "trialExpired": "Пробный период истёк", - "trialExpiredDescription": "Личное использование остаётся бесплатным. Для коммерческого использования требуется лицензия." + "trialExpiredDescription": "Личное использование остаётся бесплатным. Для коммерческого использования требуется лицензия.", + "subscriptionActive": "Подписка активна — план {{plan}}", + "subscriptionActiveDescription": "Ваша подписка на Donut Browser активна. Коммерческое использование лицензировано на срок действия плана." }, "advanced": { "title": "Дополнительно", @@ -1742,7 +1744,8 @@ "internal": "Что-то пошло не так: {{detail}}", "invalidLaunchHookUrl": "Неверный URL хука запуска. Используйте полный URL http:// или https://.", "cookieDbLocked": "Не удалось прочитать куки — база данных заблокирована. Закройте браузер и попробуйте снова.", - "cookieDbUnavailable": "Не удалось прочитать куки — хранилище куки недоступно." + "cookieDbUnavailable": "Не удалось прочитать куки — хранилище куки недоступно.", + "selfHostedRequiresLogout": "Выйдите из аккаунта Donut, прежде чем настраивать собственный сервер." }, "rail": { "profiles": "Профили", @@ -1808,6 +1811,19 @@ "status": "Статус", "teamRole": "Роль в команде", "period": "Период" + }, + "tabs": { + "account": "Аккаунт", + "selfHosted": "Свой сервер" + }, + "selfHosted": { + "title": "Свой сервер синхронизации", + "description": "Подключите Donut к собственному серверу donut-sync, чтобы синхронизировать профили, прокси, группы и расширения без использования облака.", + "disabledWhileLoggedIn": "Свой сервер синхронизации недоступен, пока вы вошли в аккаунт Donut. Выйдите из аккаунта, чтобы использовать собственный сервер.", + "connectionStatus": "Соединение:", + "statusUnknown": "Не проверено", + "testConnection": "Проверить соединение", + "disconnect": "Отключить" } } } diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 8c22f0a..e546b1f 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -165,7 +165,9 @@ "trialActive": "试用期:剩余 {{days}} 天 {{hours}} 小时", "trialActiveDescription": "试用期内商业使用免费。试用期结束后,所有功能继续正常使用 — 个人使用仍然免费,只有商业使用需要许可证。", "trialExpired": "试用期已过期", - "trialExpiredDescription": "个人使用仍然免费。商业使用需要许可证。" + "trialExpiredDescription": "个人使用仍然免费。商业使用需要许可证。", + "subscriptionActive": "已订阅 — {{plan}} 方案", + "subscriptionActiveDescription": "您的 Donut Browser 订阅已激活。在订阅有效期内允许商业使用。" }, "advanced": { "title": "高级", @@ -1742,7 +1744,8 @@ "internal": "出现问题:{{detail}}", "invalidLaunchHookUrl": "启动钩子 URL 无效。请使用完整的 http:// 或 https:// URL。", "cookieDbLocked": "无法读取 Cookie — 数据库已锁定。请关闭浏览器后重试。", - "cookieDbUnavailable": "无法读取 Cookie — Cookie 存储不可用。" + "cookieDbUnavailable": "无法读取 Cookie — Cookie 存储不可用。", + "selfHostedRequiresLogout": "在配置自托管服务器之前请先退出 Donut 账户。" }, "rail": { "profiles": "配置文件", @@ -1808,6 +1811,19 @@ "status": "状态", "teamRole": "团队角色", "period": "计费周期" + }, + "tabs": { + "account": "账户", + "selfHosted": "自托管" + }, + "selfHosted": { + "title": "自托管同步服务器", + "description": "将 Donut 连接到您自己的 donut-sync 服务器,无需使用托管云即可同步配置文件、代理、组和扩展程序。", + "disabledWhileLoggedIn": "登录 Donut 账户时无法使用自托管同步。请先退出登录以使用自定义服务器。", + "connectionStatus": "连接:", + "statusUnknown": "未测试", + "testConnection": "测试连接", + "disconnect": "断开连接" } } } diff --git a/src/lib/backend-errors.ts b/src/lib/backend-errors.ts index 9a27aa3..cd2aeb8 100644 --- a/src/lib/backend-errors.ts +++ b/src/lib/backend-errors.ts @@ -19,6 +19,7 @@ export type BackendErrorCode = | "INVALID_LAUNCH_HOOK_URL" | "COOKIE_DB_LOCKED" | "COOKIE_DB_UNAVAILABLE" + | "SELF_HOSTED_REQUIRES_LOGOUT" | "INTERNAL_ERROR"; export interface BackendError { @@ -93,6 +94,8 @@ export function translateBackendError(t: TFunction, err: unknown): string { return t("backendErrors.cookieDbLocked"); case "COOKIE_DB_UNAVAILABLE": return t("backendErrors.cookieDbUnavailable"); + case "SELF_HOSTED_REQUIRES_LOGOUT": + return t("backendErrors.selfHostedRequiresLogout"); case "INTERNAL_ERROR": return t("backendErrors.internal", { detail: parsed.params?.detail ?? "",