import type { TFunction } from "i18next"; /** * Backend error codes returned from Rust Tauri commands. * Keep this list in sync with the codes used in `src-tauri/src/profile/password.rs`. */ export type BackendErrorCode = | "INCORRECT_PASSWORD" | "LOCKED_OUT" | "PROFILE_NOT_FOUND" | "PROFILE_NOT_PROTECTED" | "PROFILE_ALREADY_PROTECTED" | "PROFILE_RUNNING" | "PROFILE_EPHEMERAL" | "PROFILE_MISSING_SALT" | "PROFILE_LOCKED" | "INVALID_PROFILE_ID" | "PASSWORD_TOO_SHORT" | "INVALID_LAUNCH_HOOK_URL" | "COOKIE_DB_LOCKED" | "COOKIE_DB_UNAVAILABLE" | "SELF_HOSTED_REQUIRES_LOGOUT" | "PROXY_NOT_FOUND" | "GROUP_NOT_FOUND" | "GROUP_ALREADY_EXISTS" | "NAME_CANNOT_BE_EMPTY" | "WAYFERN_VERSION_NOT_AVAILABLE" | "VPN_NOT_FOUND" | "EXTENSION_NOT_FOUND" | "EXTENSION_GROUP_NOT_FOUND" | "CANNOT_MODIFY_CLOUD_MANAGED_PROXY" | "SYNC_LOCKED_BY_PROFILE" | "SYNC_NOT_CONFIGURED" | "FINGERPRINT_REQUIRES_PRO" | "PROXY_NOT_WORKING" | "PROXY_PAYMENT_REQUIRED" | "VPN_NOT_WORKING" | "CAMOUFOX_IMPORT_DEPRECATED" | "PROXY_SIDECAR_VERSION_MISMATCH" | "UPDATE_CHECKSUMS_UNAVAILABLE" | "UPDATE_CHECKSUM_MISMATCH" | "UPDATE_PROFILES_RUNNING" | "UPDATE_PREPARATION_FAILED" | "PROFILE_NAME_EXISTS" | "IMPORT_SOURCE_NOT_FOUND" | "IMPORT_NO_ITEMS" | "BROWSER_NOT_DOWNLOADED" | "ARCHIVE_EXTRACTION_FAILED" | "UNSUPPORTED_ARCHIVE_FORMAT" | "CLEAR_ON_CLOSE_UNAVAILABLE" | "PROXY_AND_VPN_MUTUALLY_EXCLUSIVE" | "FINGERPRINT_MATCH_FAILED" | "INVALID_DNS_RULES_JSON" | "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" | "VLESS_CONFIG_INVALID" | "XRAY_UNAVAILABLE" | "XRAY_UNSUPPORTED_OS" | "XRAY_START_FAILED" | "CLOUD_NOT_SIGNED_IN" | "CLOUD_UNREACHABLE" | "CLOUD_REQUEST_FAILED" | "REMOTE_RATE_LIMITED" | "REMOTE_NO_CAPACITY" | "REMOTE_NOT_ENTITLED" | "REMOTE_INTERACTIVE_NOT_ENTITLED" // The profile's exit only resolves on this computer, so a leased host cannot // use it. Its own code rather than the Cookie Bot's twin: the two refusals // name different features, and a user told their "Cookie Bot" needs a public // proxy while they were opening a browser by hand cannot act on that. | "REMOTE_REQUIRES_REMOTE_EXIT_NODE" | "REMOTE_SESSION_REFUSED" | "REMOTE_SESSION_NOT_FOUND" | "REMOTE_SESSION_CONFLICT" | "REMOTE_SYNC_IN_PROGRESS" | "REMOTE_HOURS_EXHAUSTED" | "PROFILE_RUNNING_REMOTELY" | "PROFILE_REMOTE_SYNC_PENDING" | "PROFILE_LOCKED_BY_MEMBER" | "PROFILE_LOCKED_ELSEWHERE" | "PROFILE_LOCK_UNAVAILABLE" | "NOT_TEAM_MEMBER" | "COOKIE_BOT_NOT_ENTITLED" | "COOKIE_BOT_NOT_ENROLLED" | "COOKIE_BOT_SCHEDULE_CONFLICT" | "COOKIE_BOT_RUN_IN_PROGRESS" | "COOKIE_BOT_RUN_NOT_FOUND" | "COOKIE_BOT_INVALID_SCHEDULE" | "COOKIE_BOT_INVALID_TIMEZONE" | "COOKIE_BOT_INVALID_PERIOD" | "COOKIE_BOT_SITE_LIMIT" | "COOKIE_BOT_REQUIRES_CLOUD_SYNC" | "COOKIE_BOT_ENCRYPTED_SYNC_UNSUPPORTED" | "COOKIE_BOT_UNKNOWN_PLATFORM" | "COOKIE_BOT_UNSUPPORTED_PLATFORM" | "COOKIE_BOT_REQUIRES_EXIT_NODE" // The profile HAS an exit, but only this machine can reach it (127.0.0.1, a // LAN address, a `.local` name). Its own code because the fix is different: // "attach a proxy" is unactionable advice for someone whose proxy is plainly // attached. | "COOKIE_BOT_REQUIRES_REMOTE_EXIT_NODE" // The server's own names for two refusals it throws from `putSchedule`, // `updateProfileState` and `runNow`. `COOKIE_BOT_REQUIRES_PROXY` is the // server-side twin of the local `COOKIE_BOT_REQUIRES_EXIT_NODE` precondition; // without a case here the single most important refusal in the feature // rendered as the raw machine identifier. | "COOKIE_BOT_REQUIRES_PROXY" | "COOKIE_BOT_TOUCH_FINGERPRINT_UNSUPPORTED" | "FINGERPRINT_EXIT_MISMATCH" | "LAUNCH_CONSENT_EXPIRED" | "VPN_WORKER_START_FAILED" | "EXIT_PROBE_FAILED" | "CAMOUFOX_REMOVED" | "NO_E2E_PASSWORD_SET" | "INTERNAL_ERROR"; export interface BackendError { code: BackendErrorCode; params?: Record; } /** * Try to parse a backend error string as a structured `{code, params}` payload. * Returns null if the string isn't structured (e.g. raw error from a command * that doesn't yet emit codes — caller should fall back to showing the raw text). */ export function parseBackendError(err: unknown): BackendError | null { const message = err instanceof Error ? err.message : String(err); if (!message.startsWith("{")) return null; try { const parsed = JSON.parse(message); if ( parsed && typeof parsed === "object" && typeof parsed.code === "string" ) { return parsed as BackendError; } } catch { // not JSON } return null; } /** * Translate a backend error to a localized string. Falls back to the raw * message if the error isn't a structured backend error. */ export function translateBackendError(t: TFunction, err: unknown): string { const parsed = parseBackendError(err); if (!parsed) { return err instanceof Error ? err.message : String(err); } switch (parsed.code) { case "INCORRECT_PASSWORD": return t("backendErrors.incorrectPassword"); case "LOCKED_OUT": { const seconds = Number.parseInt(parsed.params?.seconds ?? "0", 10); return t("backendErrors.lockedOut", { duration: formatLockoutDuration(t, seconds), }); } case "PROFILE_NOT_FOUND": return t("backendErrors.profileNotFound"); case "PROFILE_NOT_PROTECTED": return t("backendErrors.profileNotProtected"); case "PROFILE_ALREADY_PROTECTED": return t("backendErrors.profileAlreadyProtected"); case "PROFILE_RUNNING": return t("backendErrors.profileRunning"); case "PROFILE_EPHEMERAL": return t("backendErrors.profileEphemeral"); case "PROFILE_MISSING_SALT": return t("backendErrors.profileMissingSalt"); case "PROFILE_LOCKED": return t("backendErrors.profileLocked"); case "INVALID_PROFILE_ID": return t("backendErrors.invalidProfileId"); case "PASSWORD_TOO_SHORT": { const min = Number.parseInt(parsed.params?.min ?? "8", 10); return t("backendErrors.passwordTooShort", { min }); } case "INVALID_LAUNCH_HOOK_URL": return t("backendErrors.invalidLaunchHookUrl"); case "COOKIE_DB_LOCKED": return t("backendErrors.cookieDbLocked"); case "COOKIE_DB_UNAVAILABLE": return t("backendErrors.cookieDbUnavailable"); case "SELF_HOSTED_REQUIRES_LOGOUT": return t("backendErrors.selfHostedRequiresLogout"); case "PROXY_NOT_FOUND": return t("backendErrors.proxyNotFound"); case "GROUP_NOT_FOUND": return t("backendErrors.groupNotFound"); case "GROUP_ALREADY_EXISTS": return t("backendErrors.groupAlreadyExists"); case "NAME_CANNOT_BE_EMPTY": return t("backendErrors.nameCannotBeEmpty"); case "WAYFERN_VERSION_NOT_AVAILABLE": return t("backendErrors.wayfernVersionNotAvailable", { requested: parsed.params?.requested ?? "", current: parsed.params?.current ?? "", }); case "VPN_NOT_FOUND": return t("backendErrors.vpnNotFound"); case "EXTENSION_NOT_FOUND": return t("backendErrors.extensionNotFound"); case "EXTENSION_GROUP_NOT_FOUND": return t("backendErrors.extensionGroupNotFound"); case "CANNOT_MODIFY_CLOUD_MANAGED_PROXY": return t("backendErrors.cannotModifyCloudManagedProxy"); case "SYNC_LOCKED_BY_PROFILE": return t("backendErrors.syncLockedByProfile"); case "SYNC_NOT_CONFIGURED": return t("backendErrors.syncNotConfigured"); case "FINGERPRINT_REQUIRES_PRO": return t("backendErrors.fingerprintRequiresPro"); case "PROXY_NOT_WORKING": return t("backendErrors.proxyNotWorking"); case "PROXY_PAYMENT_REQUIRED": return t("backendErrors.proxyPaymentRequired"); case "VPN_NOT_WORKING": return t("backendErrors.vpnNotWorking"); case "CAMOUFOX_IMPORT_DEPRECATED": return t("backendErrors.camoufoxImportDeprecated"); case "PROXY_SIDECAR_VERSION_MISMATCH": return t("backendErrors.proxySidecarVersionMismatch"); case "UPDATE_CHECKSUMS_UNAVAILABLE": return t("backendErrors.updateChecksumsUnavailable", { version: parsed.params?.version ?? "", }); case "UPDATE_CHECKSUM_MISMATCH": return t("backendErrors.updateChecksumMismatch", { file: parsed.params?.file ?? "", }); case "UPDATE_PROFILES_RUNNING": return t("backendErrors.updateProfilesRunning"); case "UPDATE_PREPARATION_FAILED": return t("backendErrors.updatePreparationFailed"); case "PROFILE_NAME_EXISTS": return t("backendErrors.profileNameExists", { name: parsed.params?.name ?? "", }); case "IMPORT_SOURCE_NOT_FOUND": return t("backendErrors.importSourceNotFound"); case "IMPORT_NO_ITEMS": return t("backendErrors.importNoItems"); case "BROWSER_NOT_DOWNLOADED": return t("backendErrors.browserNotDownloaded", { browser: parsed.params?.browser ?? "", }); case "ARCHIVE_EXTRACTION_FAILED": return t("backendErrors.archiveExtractionFailed", { detail: parsed.params?.detail ?? "", }); case "UNSUPPORTED_ARCHIVE_FORMAT": return t("backendErrors.unsupportedArchiveFormat"); case "PROXY_AND_VPN_MUTUALLY_EXCLUSIVE": return t("backendErrors.proxyAndVpnMutuallyExclusive"); case "FINGERPRINT_MATCH_FAILED": return t("backendErrors.fingerprintMatchFailed"); case "INVALID_DNS_RULES_JSON": return t("backendErrors.invalidDnsRulesJson"); case "UNSUPPORTED_DNS_RULES_FORMAT": return t("backendErrors.unsupportedDnsRulesFormat", { format: parsed.params?.format ?? "", }); case "DNS_RULES_SAVE_FAILED": 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 ?? "", }); // Donut supports exactly one VLESS shape (REALITY + XTLS Vision over TCP), // so most rejections mean "your server is a kind we do not support", not // "you mistyped". Name the unsupported part instead of implying a typo. case "VLESS_CONFIG_INVALID": { const reason = parsed.params?.reason; const known = [ "security", "flow", "transport", "encryption", "headerType", "fingerprint", "sni", "publicKey", "scheme", "parameter", "malformed", ]; if (reason && known.includes(reason)) { return t(`backendErrors.vlessUnsupported.${reason}`); } return t("backendErrors.vlessConfigInvalid"); } case "XRAY_UNAVAILABLE": return t("backendErrors.xrayUnavailable"); case "XRAY_UNSUPPORTED_OS": return t("backendErrors.xrayUnsupportedOs"); case "XRAY_START_FAILED": return t("backendErrors.xrayStartFailed"); case "CLEAR_ON_CLOSE_UNAVAILABLE": return t("backendErrors.clearOnCloseUnavailable"); case "CLOUD_NOT_SIGNED_IN": return t("backendErrors.cloudNotSignedIn"); case "CLOUD_UNREACHABLE": return t("backendErrors.cloudUnreachable"); case "CLOUD_REQUEST_FAILED": return t("backendErrors.cloudRequestFailed"); case "REMOTE_RATE_LIMITED": return t("backendErrors.remoteRateLimited"); case "REMOTE_NO_CAPACITY": return t("backendErrors.remoteNoCapacity"); case "REMOTE_NOT_ENTITLED": return t("backendErrors.remoteNotEntitled"); // Distinct from the above: the plan HAS remote hours, it just may not spend // them by hand (solo funds a nightly Cookie Bot only). Telling such a user // "your plan does not include remote execution" while their bot visibly // runs every night is the confusing case this code exists to avoid. case "REMOTE_INTERACTIVE_NOT_ENTITLED": return t("backendErrors.remoteInteractiveNotEntitled"); case "REMOTE_REQUIRES_REMOTE_EXIT_NODE": return t("backendErrors.remoteRequiresRemoteExitNode"); case "REMOTE_SESSION_REFUSED": return t("backendErrors.remoteSessionRefused"); case "REMOTE_SESSION_NOT_FOUND": return t("backendErrors.remoteSessionNotFound"); case "REMOTE_SESSION_CONFLICT": return t("backendErrors.remoteSessionConflict"); case "REMOTE_SYNC_IN_PROGRESS": return t("backendErrors.remoteSyncInProgress"); case "REMOTE_HOURS_EXHAUSTED": return t("backendErrors.remoteHoursExhausted", { granted: parsed.params?.granted ?? "0", used: parsed.params?.used ?? "0", }); case "PROFILE_RUNNING_REMOTELY": return t("backendErrors.profileRunningRemotely"); case "PROFILE_REMOTE_SYNC_PENDING": return t("backendErrors.profileRemoteSyncPending"); case "PROFILE_LOCKED_BY_MEMBER": return t("backendErrors.profileLockedByMember", { email: parsed.params?.email ?? "", }); case "PROFILE_LOCKED_ELSEWHERE": return t("backendErrors.profileLockedElsewhere"); case "PROFILE_LOCK_UNAVAILABLE": return t("backendErrors.profileLockUnavailable"); case "NOT_TEAM_MEMBER": return t("backendErrors.notTeamMember"); case "COOKIE_BOT_NOT_ENTITLED": return t("backendErrors.cookieBotNotEntitled"); case "COOKIE_BOT_NOT_ENROLLED": return t("backendErrors.cookieBotNotEnrolled"); case "COOKIE_BOT_SCHEDULE_CONFLICT": return t("backendErrors.cookieBotScheduleConflict", { email: parsed.params?.email ?? "", time: parsed.params?.time ?? "", }); case "COOKIE_BOT_RUN_IN_PROGRESS": return t("backendErrors.cookieBotRunInProgress"); case "COOKIE_BOT_RUN_NOT_FOUND": return t("backendErrors.cookieBotRunNotFound"); case "COOKIE_BOT_INVALID_SCHEDULE": return t("backendErrors.cookieBotInvalidSchedule"); case "COOKIE_BOT_INVALID_TIMEZONE": return t("backendErrors.cookieBotInvalidTimezone", { timezone: parsed.params?.timezone ?? "", }); case "COOKIE_BOT_INVALID_PERIOD": return t("backendErrors.cookieBotInvalidPeriod"); case "COOKIE_BOT_SITE_LIMIT": // The server sends both bounds. Defaulting `min` to 1 was not the // problem — the message never mentioned a minimum at all, so a user who // submitted no sites was told about a maximum they had not reached. return t("backendErrors.cookieBotSiteLimit", { min: parsed.params?.min ?? "1", max: parsed.params?.max ?? "40", }); case "COOKIE_BOT_REQUIRES_CLOUD_SYNC": return t("backendErrors.cookieBotRequiresCloudSync"); case "COOKIE_BOT_ENCRYPTED_SYNC_UNSUPPORTED": return t("backendErrors.cookieBotEncryptedSyncUnsupported"); case "COOKIE_BOT_UNKNOWN_PLATFORM": return t("backendErrors.cookieBotUnknownPlatform"); case "COOKIE_BOT_UNSUPPORTED_PLATFORM": return t("backendErrors.cookieBotUnsupportedPlatform", { platform: parsed.params?.platform ?? "", }); case "COOKIE_BOT_REQUIRES_EXIT_NODE": // One condition, two names: the desktop refuses it locally as // REQUIRES_EXIT_NODE and the server refuses it as REQUIRES_PROXY. Both // resolve to the one sentence a user can act on. case "COOKIE_BOT_REQUIRES_PROXY": return t("backendErrors.cookieBotRequiresExitNode"); case "COOKIE_BOT_REQUIRES_REMOTE_EXIT_NODE": return t("backendErrors.cookieBotRequiresRemoteExitNode"); case "COOKIE_BOT_TOUCH_FINGERPRINT_UNSUPPORTED": return t("backendErrors.cookieBotTouchFingerprintUnsupported"); // The launch gate's block. The dialog renders the mismatch detail from // `params` itself; this string is the fallback for anywhere that only has // room for one sentence. case "FINGERPRINT_EXIT_MISMATCH": return t("backendErrors.fingerprintExitMismatch"); case "LAUNCH_CONSENT_EXPIRED": return t("backendErrors.launchConsentExpired"); case "VPN_WORKER_START_FAILED": return t("backendErrors.vpnWorkerStartFailed", { detail: parsed.params?.detail ?? "", }); case "EXIT_PROBE_FAILED": return t("backendErrors.exitProbeFailed"); case "CAMOUFOX_REMOVED": return t("backendErrors.camoufoxRemoved"); case "NO_E2E_PASSWORD_SET": return t("backendErrors.noE2ePasswordSet"); case "INTERNAL_ERROR": return t("backendErrors.internal", { detail: parsed.params?.detail ?? "", }); default: // The payload parsed as a structured error but carries a code this build // does not know: the server can add codes faster than the desktop ships. // Returning the raw message here would render the literal JSON to the // user, so show a translated line that still names the code for support. return t("backendErrors.unknownCode", { code: String(parsed.code) }); } } export function formatLockoutDuration(t: TFunction, seconds: number): string { if (seconds < 60) return t("backendErrors.lockedOutDuration.seconds", { seconds }); const minutes = Math.ceil(seconds / 60); if (minutes < 60) return t("backendErrors.lockedOutDuration.minutes", { minutes }); const hours = Math.ceil(minutes / 60); return t("backendErrors.lockedOutDuration.hours", { hours }); } /** * Extract the lockout countdown in seconds from a backend error, or null. */ export function extractLockoutSeconds(err: unknown): number | null { const parsed = parseBackendError(err); if (parsed?.code !== "LOCKED_OUT") return null; const secs = Number.parseInt(parsed.params?.seconds ?? "0", 10); return Number.isFinite(secs) && secs > 0 ? secs : null; } /** * True if the error is a known structured backend error code. */ export function isBackendErrorCode( err: unknown, code: BackendErrorCode, ): boolean { return parseBackendError(err)?.code === code; }