From 8588a44fb57f7f78efe3e9af7b54ef4eeb16e854 Mon Sep 17 00:00:00 2001 From: zhom <2717306+zhom@users.noreply.github.com> Date: Wed, 24 Jun 2026 02:00:44 +0400 Subject: [PATCH] refactor: cleanup --- .github/workflows/release.yml | 8 +- scripts/publish-repo.sh | 7 +- src-tauri/src/app_auto_updater.rs | 8 +- src-tauri/src/group_manager.rs | 32 ++++-- src-tauri/src/platform_browser.rs | 20 ++-- src/app/page.tsx | 110 ++++++++++++++------- src/components/create-group-dialog.tsx | 4 +- src/components/delete-group-dialog.tsx | 4 +- src/components/edit-group-dialog.tsx | 4 +- src/components/group-management-dialog.tsx | 16 +-- src/components/profile-data-table.tsx | 20 ++-- src/components/proxy-form-dialog.tsx | 9 +- src/i18n/locales/en.json | 1 + src/i18n/locales/es.json | 1 + src/i18n/locales/fr.json | 1 + src/i18n/locales/ja.json | 1 + src/i18n/locales/ko.json | 1 + src/i18n/locales/pt.json | 1 + src/i18n/locales/ru.json | 1 + src/i18n/locales/vi.json | 1 + src/i18n/locales/zh.json | 1 + src/lib/backend-errors.ts | 3 + 22 files changed, 172 insertions(+), 82 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3557c75..3792c26 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -148,12 +148,12 @@ jobs: - name: Verify frontend dist exists shell: bash run: | - if [ ! -d "dist" ]; then - echo "Error: dist directory not found after build" - ls -la + if [ ! -f "dist/index.html" ]; then + echo "Error: dist/index.html not found after build (static export incomplete)" + ls -la dist 2>/dev/null || ls -la exit 1 fi - echo "Frontend dist directory verified at $(pwd)/dist" + echo "Frontend dist verified at $(pwd)/dist (index.html present)" echo "Checking from src-tauri perspective:" ls -la src-tauri/../dist || echo "Warning: dist not accessible from src-tauri" diff --git a/scripts/publish-repo.sh b/scripts/publish-repo.sh index c7b5434..6078e2b 100755 --- a/scripts/publish-repo.sh +++ b/scripts/publish-repo.sh @@ -113,8 +113,11 @@ for arch in amd64 arm64; do BINARY_DIR="$DEB_DIR/dists/stable/main/binary-${arch}" # dpkg-scanpackages needs to run from the repo root - # and needs paths relative to that root - (cd "$DEB_DIR" && dpkg-scanpackages --arch "$arch" pool/main) \ + # and needs paths relative to that root. + # -m / --multiversion keeps every version present in the pool in the index + # (without it only the newest is listed, making older releases uninstallable + # via apt — createrepo_c already keeps all versions for the RPM repo). + (cd "$DEB_DIR" && dpkg-scanpackages -m --arch "$arch" pool/main) \ > "$BINARY_DIR/Packages" gzip -9c "$BINARY_DIR/Packages" > "$BINARY_DIR/Packages.gz" diff --git a/src-tauri/src/app_auto_updater.rs b/src-tauri/src/app_auto_updater.rs index 965a635..f66463e 100644 --- a/src-tauri/src/app_auto_updater.rs +++ b/src-tauri/src/app_auto_updater.rs @@ -1492,7 +1492,7 @@ impl AppAutoUpdater { // Create the restart script content let script_content = format!( - r#"#!/bin/bash + r#"#!/bin/sh # Wait for the current process to exit while kill -0 {} 2>/dev/null; do sleep 0.5 @@ -1521,7 +1521,7 @@ rm "{}" .output(); // Execute the restart script in the background - let mut cmd = Command::new("bash"); + let mut cmd = Command::new("sh"); cmd.arg(script_path.to_str().unwrap()); // Detach the process completely @@ -1668,7 +1668,7 @@ rm "{}" // Create the restart script content let script_content = format!( - r#"#!/bin/bash + r#"#!/bin/sh # Wait for the current process to exit while kill -0 {} 2>/dev/null; do sleep 0.5 @@ -1697,7 +1697,7 @@ rm "{}" .output(); // Execute the restart script in the background - let mut cmd = Command::new("bash"); + let mut cmd = Command::new("sh"); cmd.arg(script_path.to_str().unwrap()); // Detach the process completely diff --git a/src-tauri/src/group_manager.rs b/src-tauri/src/group_manager.rs index 3b8100a..da5debb 100644 --- a/src-tauri/src/group_manager.rs +++ b/src-tauri/src/group_manager.rs @@ -85,7 +85,11 @@ impl GroupManager { // Check if group with this name already exists if groups_data.groups.iter().any(|g| g.name == name) { - return Err(format!("Group with name '{name}' already exists").into()); + return Err( + serde_json::json!({ "code": "GROUP_ALREADY_EXISTS" }) + .to_string() + .into(), + ); } let sync_enabled = crate::sync::is_sync_configured(); @@ -131,14 +135,18 @@ impl GroupManager { .iter() .any(|g| g.name == name && g.id != id) { - return Err(format!("Group with name '{name}' already exists").into()); + return Err( + serde_json::json!({ "code": "GROUP_ALREADY_EXISTS" }) + .to_string() + .into(), + ); } let group = groups_data .groups .iter_mut() .find(|g| g.id == id) - .ok_or_else(|| format!("Group with id '{id}' not found"))?; + .ok_or_else(|| serde_json::json!({ "code": "GROUP_NOT_FOUND" }).to_string())?; group.name = name; group.updated_at = Some(crate::proxy_manager::now_secs()); @@ -204,7 +212,11 @@ impl GroupManager { let initial_len = groups_data.groups.len(); groups_data.groups.retain(|g| g.id != id); if groups_data.groups.len() == initial_len { - return Err(format!("Group with id '{id}' not found").into()); + return Err( + serde_json::json!({ "code": "GROUP_NOT_FOUND" }) + .to_string() + .into(), + ); } self.save_groups_data(&groups_data)?; Ok(()) @@ -229,7 +241,11 @@ impl GroupManager { groups_data.groups.retain(|g| g.id != id); if groups_data.groups.len() == initial_len { - return Err(format!("Group with id '{id}' not found").into()); + return Err( + serde_json::json!({ "code": "GROUP_NOT_FOUND" }) + .to_string() + .into(), + ); } self.save_groups_data(&groups_data)?; @@ -334,7 +350,7 @@ pub async fn create_profile_group( let group_manager = GROUP_MANAGER.lock().unwrap(); group_manager .create_group(&app_handle, name) - .map_err(|e| format!("Failed to create group: {e}")) + .map_err(|e| e.to_string()) } #[tauri::command] @@ -346,7 +362,7 @@ pub async fn update_profile_group( let group_manager = GROUP_MANAGER.lock().unwrap(); group_manager .update_group(&app_handle, group_id, name) - .map_err(|e| format!("Failed to update group: {e}")) + .map_err(|e| e.to_string()) } #[tauri::command] @@ -357,7 +373,7 @@ pub async fn delete_profile_group( let group_manager = GROUP_MANAGER.lock().unwrap(); group_manager .delete_group(&app_handle, group_id) - .map_err(|e| format!("Failed to delete group: {e}")) + .map_err(|e| e.to_string()) } #[tauri::command] diff --git a/src-tauri/src/platform_browser.rs b/src-tauri/src/platform_browser.rs index 12f88f9..14849b5 100644 --- a/src-tauri/src/platform_browser.rs +++ b/src-tauri/src/platform_browser.rs @@ -634,19 +634,25 @@ pub mod linux { } } - // Additional Linux-specific environment variables for better compatibility - cmd.env( - "DISPLAY", - std::env::var("DISPLAY").unwrap_or(":0".to_string()), - ); + // Propagate DISPLAY only when this session actually has an X11 display. + // Forcing DISPLAY=:0 breaks Wayland-only sessions (there is no X server on + // :0, so any X11 client launched with it set will fail to connect). When + // DISPLAY is set the child already inherits it from our environment, so + // setting it explicitly here is purely defensive; when it's unset we leave + // it unset and let the browser use Wayland. + if let Ok(display) = std::env::var("DISPLAY") { + cmd.env("DISPLAY", display); + } // Set MOZ_ENABLE_WAYLAND for better Wayland support if std::env::var("WAYLAND_DISPLAY").is_ok() { cmd.env("MOZ_ENABLE_WAYLAND", "1"); } - // Disable GPU acceleration if running in headless environments - if std::env::var("DISPLAY").is_err() || std::env::var("WAYLAND_DISPLAY").is_err() { + // Warn only when running truly headless — i.e. NEITHER X11 nor Wayland is + // available. Using OR here would fire on every normal Wayland-only session + // (DISPLAY unset) or X11-only session (WAYLAND_DISPLAY unset). + if std::env::var("DISPLAY").is_err() && std::env::var("WAYLAND_DISPLAY").is_err() { log::info!("No display detected, browser may fail to start"); } diff --git a/src/app/page.tsx b/src/app/page.tsx index 6538976..bb7240f 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -712,49 +712,67 @@ export default function Home() { ); const listenForUrlEvents = useCallback(async () => { + // Collect every listener we register so that — whether setup completes or + // throws partway through — we tear down exactly what was registered. + // Previously the Tauri unlisten handles were discarded (so re-runs stacked + // duplicate handlers and a single URL was handled N times), and a failing + // listen() call would leak the listeners that had already succeeded. + const unlisteners: Array<() => void> = []; + let handleLogoUrlEvent: ((event: CustomEvent) => void) | undefined; + const teardown = () => { + for (const unlisten of unlisteners) unlisten(); + if (handleLogoUrlEvent) { + window.removeEventListener( + "url-open-request", + handleLogoUrlEvent as EventListener, + ); + } + }; + try { // Listen for URL open events from the deep link handler (when app is already running) - await listen("url-open-request", (event) => { - console.log("Received URL open request:", event.payload); - handleUrlOpen(event.payload); - }); + unlisteners.push( + await listen("url-open-request", (event) => { + console.log("Received URL open request:", event.payload); + handleUrlOpen(event.payload); + }), + ); // Listen for show profile selector events - await listen("show-profile-selector", (event) => { - console.log("Received show profile selector request:", event.payload); - handleUrlOpen(event.payload); - }); + unlisteners.push( + await listen("show-profile-selector", (event) => { + console.log("Received show profile selector request:", event.payload); + handleUrlOpen(event.payload); + }), + ); // Listen for show create profile dialog events - await listen("show-create-profile-dialog", (event) => { - console.log( - "Received show create profile dialog request:", - event.payload, - ); - showErrorToast(t("errors.noProfilesForUrl")); - setCreateProfileDialogOpen(true); - }); + unlisteners.push( + await listen("show-create-profile-dialog", (event) => { + console.log( + "Received show create profile dialog request:", + event.payload, + ); + showErrorToast(t("errors.noProfilesForUrl")); + setCreateProfileDialogOpen(true); + }), + ); // Listen for custom logo click events - const handleLogoUrlEvent = (event: CustomEvent) => { + handleLogoUrlEvent = (event: CustomEvent) => { console.log("Received logo URL event:", event.detail); handleUrlOpen(event.detail); }; - window.addEventListener( "url-open-request", handleLogoUrlEvent as EventListener, ); - // Return cleanup function - return () => { - window.removeEventListener( - "url-open-request", - handleLogoUrlEvent as EventListener, - ); - }; + return teardown; } catch (error) { console.error("Failed to setup URL listener:", error); + // Tear down whatever did register before the failure so nothing leaks. + teardown(); } }, [handleUrlOpen, t]); @@ -1257,6 +1275,7 @@ export default function Home() { ); useEffect(() => { + let disposed = false; let unlistenStatus: (() => void) | undefined; let unlistenProgress: (() => void) | undefined; const profilesWithTransfer = new Set(); @@ -1333,25 +1352,35 @@ export default function Home() { ); } }); + // If the effect was torn down while we were awaiting the listeners, + // unlisten immediately — the cleanup below already ran and would have + // missed these handles. (Tauri unlisten is safe to call more than once.) + if (disposed) { + unlistenStatus?.(); + unlistenProgress?.(); + } } catch (error) { console.error("Failed to listen for sync events:", error); } })(); return () => { + disposed = true; if (unlistenStatus) unlistenStatus(); if (unlistenProgress) unlistenProgress(); }; }, [profiles, t]); useEffect(() => { - // Listen for URL open events and get cleanup function - const setupListeners = async () => { - const cleanup = await listenForUrlEvents(); - return cleanup; - }; - + // Listen for URL open events. Guard against the effect tearing down (or + // re-running) before the async listener setup resolves: if that happens, + // run the cleanup as soon as it's available so the listeners never leak. let cleanup: (() => void) | undefined; - void setupListeners().then((cleanupFn) => { + let disposed = false; + void listenForUrlEvents().then((cleanupFn) => { + if (disposed) { + cleanupFn?.(); + return; + } cleanup = cleanupFn; }); @@ -1379,10 +1408,9 @@ export default function Home() { } return () => { + disposed = true; clearInterval(updateInterval); - if (cleanup) { - cleanup(); - } + cleanup?.(); }; }, [ checkForUpdates, @@ -1396,6 +1424,7 @@ export default function Home() { // E2E encryption listeners — surface password-required prompts and rollover // progress so the user isn't left guessing whether sealing finished. useEffect(() => { + let disposed = false; let unlistenRequired: (() => void) | undefined; let unlistenStarted: (() => void) | undefined; let unlistenProgress: (() => void) | undefined; @@ -1472,9 +1501,20 @@ export default function Home() { duration: 15000, }); }); + + // If the effect was torn down mid-setup, the cleanup below already ran + // before these handles existed — unlisten them now so nothing leaks. + if (disposed) { + unlistenRequired?.(); + unlistenStarted?.(); + unlistenProgress?.(); + unlistenCompleted?.(); + unlistenWayfernBlocked?.(); + } })(); return () => { + disposed = true; unlistenRequired?.(); unlistenStarted?.(); unlistenProgress?.(); diff --git a/src/components/create-group-dialog.tsx b/src/components/create-group-dialog.tsx index 96d3784..51ae003 100644 --- a/src/components/create-group-dialog.tsx +++ b/src/components/create-group-dialog.tsx @@ -15,6 +15,7 @@ import { } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; +import { translateBackendError } from "@/lib/backend-errors"; import type { ProfileGroup } from "@/types"; import { RippleButton } from "./ui/ripple"; @@ -50,8 +51,7 @@ export function CreateGroupDialog({ onClose(); } catch (err) { console.error("Failed to create group:", err); - const errorMessage = - err instanceof Error ? err.message : t("groups.createFailed"); + const errorMessage = translateBackendError(t, err); setError(errorMessage); toast.error(errorMessage); } finally { diff --git a/src/components/delete-group-dialog.tsx b/src/components/delete-group-dialog.tsx index 741a9fc..0142a0d 100644 --- a/src/components/delete-group-dialog.tsx +++ b/src/components/delete-group-dialog.tsx @@ -16,6 +16,7 @@ import { import { Label } from "@/components/ui/label"; import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; import { ScrollArea } from "@/components/ui/scroll-area"; +import { translateBackendError } from "@/lib/backend-errors"; import type { BrowserProfile, ProfileGroup } from "@/types"; import { RippleButton } from "./ui/ripple"; @@ -97,8 +98,7 @@ export function DeleteGroupDialog({ onClose(); } catch (err) { console.error("Failed to delete group:", err); - const errorMessage = - err instanceof Error ? err.message : t("groups.deleteFailed"); + const errorMessage = translateBackendError(t, err); setError(errorMessage); toast.error(errorMessage); } finally { diff --git a/src/components/edit-group-dialog.tsx b/src/components/edit-group-dialog.tsx index 663abb3..fe8517d 100644 --- a/src/components/edit-group-dialog.tsx +++ b/src/components/edit-group-dialog.tsx @@ -15,6 +15,7 @@ import { } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; +import { translateBackendError } from "@/lib/backend-errors"; import type { ProfileGroup } from "@/types"; import { RippleButton } from "./ui/ripple"; @@ -61,8 +62,7 @@ export function EditGroupDialog({ onClose(); } catch (err) { console.error("Failed to update group:", err); - const errorMessage = - err instanceof Error ? err.message : t("groups.updateFailed"); + const errorMessage = translateBackendError(t, err); setError(errorMessage); toast.error(errorMessage); } finally { diff --git a/src/components/group-management-dialog.tsx b/src/components/group-management-dialog.tsx index 4186962..f006906 100644 --- a/src/components/group-management-dialog.tsx +++ b/src/components/group-management-dialog.tsx @@ -495,9 +495,15 @@ export function GroupManagementDialog({ const results = await Promise.allSettled( ids.map((groupId) => invoke("delete_profile_group", { groupId })), ); - const failed = results.filter((r) => r.status === "rejected"); - if (failed.length > 0) { - showErrorToast(t("groups.deleteFailed")); + const firstRejection = results.find((r) => r.status === "rejected") as + | PromiseRejectedResult + | undefined; + if (firstRejection) { + showErrorToast( + parseBackendError(firstRejection.reason) + ? translateBackendError(t, firstRejection.reason) + : t("groups.deleteFailed"), + ); } else { showSuccessToast(t("groups.deleteSuccess")); } @@ -507,9 +513,7 @@ export function GroupManagementDialog({ onGroupManagementComplete(); } catch (err) { console.error("Bulk group delete failed:", err); - showErrorToast( - err instanceof Error ? err.message : t("groups.deleteFailed"), - ); + showErrorToast(translateBackendError(t, err)); } finally { setIsBulkDeleting(false); } diff --git a/src/components/profile-data-table.tsx b/src/components/profile-data-table.tsx index 2ff9e06..782ace5 100644 --- a/src/components/profile-data-table.tsx +++ b/src/components/profile-data-table.tsx @@ -1250,14 +1250,16 @@ export function ProfilesDataTable({ (id) => newSelection[id], ); - // Only update external state if selection actually changed - const prevIds = Object.keys(prevSelection).filter( - (id) => prevSelection[id], + // Only update external state if selection actually changed. + // A Set gives O(1) membership; Array.includes() inside .every() would + // be O(n*m) over large selections. + const prevIdSet = new Set( + Object.keys(prevSelection).filter((id) => prevSelection[id]), ); if ( - selectedIds.length !== prevIds.length || - !selectedIds.every((id) => prevIds.includes(id)) + selectedIds.length !== prevIdSet.size || + !selectedIds.every((id) => prevIdSet.has(id)) ) { onSelectedProfilesChange(selectedIds); } @@ -1559,10 +1561,13 @@ export function ProfilesDataTable({ "get_all_traffic_snapshots", ); const newSnapshots: Record = {}; + // O(1) membership; runningProfileIds.includes() in this loop would be + // O(snapshots * runningProfiles). + const runningSet = new Set(runningProfileIds); for (const snapshot of allSnapshots) { if (snapshot.profile_id) { // Only keep snapshots for profiles that are currently running - if (runningProfileIds.includes(snapshot.profile_id)) { + if (runningSet.has(snapshot.profile_id)) { const existing = newSnapshots[snapshot.profile_id]; if (!existing || snapshot.last_update > existing.last_update) { newSnapshots[snapshot.profile_id] = snapshot; @@ -1591,9 +1596,10 @@ export function ProfilesDataTable({ setTrafficSnapshots((prev) => { const cleaned: Record = {}; + const runningSet = new Set(runningProfileIds); for (const [profileId, snapshot] of Object.entries(prev)) { // Only keep snapshots for profiles that are currently running - if (runningProfileIds.includes(profileId)) { + if (runningSet.has(profileId)) { cleaned[profileId] = snapshot; } } diff --git a/src/components/proxy-form-dialog.tsx b/src/components/proxy-form-dialog.tsx index b980422..678748e 100644 --- a/src/components/proxy-form-dialog.tsx +++ b/src/components/proxy-form-dialog.tsx @@ -21,6 +21,7 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import { translateBackendError } from "@/lib/backend-errors"; import type { StoredProxy } from "@/types"; import { RippleButton } from "./ui/ripple"; @@ -127,9 +128,11 @@ export function ProxyFormDialog({ onClose(); } catch (error) { console.error("Failed to save proxy:", error); - const errorMessage = - error instanceof Error ? error.message : String(error); - toast.error(t("proxies.form.saveFailed", { error: errorMessage })); + toast.error( + t("proxies.form.saveFailed", { + error: translateBackendError(t, error), + }), + ); } finally { setIsSubmitting(false); } diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 98e4be2..3626411 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1844,6 +1844,7 @@ "passwordTooShort": "Password must be at least {{min}} characters", "proxyNotFound": "Proxy not found", "groupNotFound": "Group not found", + "groupAlreadyExists": "A group with this name already exists", "vpnNotFound": "VPN not found", "extensionNotFound": "Extension not found", "extensionGroupNotFound": "Extension group not found", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 069383c..8d32965 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -1844,6 +1844,7 @@ "passwordTooShort": "La contraseña debe tener al menos {{min}} caracteres", "proxyNotFound": "Proxy no encontrado", "groupNotFound": "Grupo no encontrado", + "groupAlreadyExists": "Ya existe un grupo con este nombre", "vpnNotFound": "VPN no encontrada", "extensionNotFound": "Extensión no encontrada", "extensionGroupNotFound": "Grupo de extensiones no encontrado", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 20ec11b..0ef72ba 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -1844,6 +1844,7 @@ "passwordTooShort": "Le mot de passe doit comporter au moins {{min}} caractères", "proxyNotFound": "Proxy introuvable", "groupNotFound": "Groupe introuvable", + "groupAlreadyExists": "Un groupe portant ce nom existe déjà", "vpnNotFound": "VPN introuvable", "extensionNotFound": "Extension introuvable", "extensionGroupNotFound": "Groupe d'extensions introuvable", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index f887e49..8b030eb 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -1844,6 +1844,7 @@ "passwordTooShort": "パスワードは {{min}} 文字以上必要です", "proxyNotFound": "プロキシが見つかりません", "groupNotFound": "グループが見つかりません", + "groupAlreadyExists": "この名前のグループは既に存在します", "vpnNotFound": "VPNが見つかりません", "extensionNotFound": "拡張機能が見つかりません", "extensionGroupNotFound": "拡張機能グループが見つかりません", diff --git a/src/i18n/locales/ko.json b/src/i18n/locales/ko.json index 505195f..1bd789c 100644 --- a/src/i18n/locales/ko.json +++ b/src/i18n/locales/ko.json @@ -1844,6 +1844,7 @@ "passwordTooShort": "비밀번호는 {{min}}자 이상이어야 합니다", "proxyNotFound": "프록시를 찾을 수 없습니다", "groupNotFound": "그룹을 찾을 수 없습니다", + "groupAlreadyExists": "이 이름의 그룹이 이미 존재합니다", "vpnNotFound": "VPN을 찾을 수 없습니다", "extensionNotFound": "확장 프로그램을 찾을 수 없습니다", "extensionGroupNotFound": "확장 프로그램 그룹을 찾을 수 없습니다", diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index c73b9fa..0f7486a 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -1844,6 +1844,7 @@ "passwordTooShort": "A senha deve ter pelo menos {{min}} caracteres", "proxyNotFound": "Proxy não encontrado", "groupNotFound": "Grupo não encontrado", + "groupAlreadyExists": "Já existe um grupo com este nome", "vpnNotFound": "VPN não encontrada", "extensionNotFound": "Extensão não encontrada", "extensionGroupNotFound": "Grupo de extensões não encontrado", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index ae8f596..306ebe6 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -1844,6 +1844,7 @@ "passwordTooShort": "Пароль должен быть не короче {{min}} символов", "proxyNotFound": "Прокси не найден", "groupNotFound": "Группа не найдена", + "groupAlreadyExists": "Группа с таким именем уже существует", "vpnNotFound": "VPN не найден", "extensionNotFound": "Расширение не найдено", "extensionGroupNotFound": "Группа расширений не найдена", diff --git a/src/i18n/locales/vi.json b/src/i18n/locales/vi.json index 2d6bf2c..8ea21c6 100644 --- a/src/i18n/locales/vi.json +++ b/src/i18n/locales/vi.json @@ -1844,6 +1844,7 @@ "passwordTooShort": "Mật khẩu phải có ít nhất {{min}} ký tự", "proxyNotFound": "Không tìm thấy proxy", "groupNotFound": "Không tìm thấy nhóm", + "groupAlreadyExists": "Đã tồn tại một nhóm với tên này", "vpnNotFound": "Không tìm thấy VPN", "extensionNotFound": "Không tìm thấy tiện ích", "extensionGroupNotFound": "Không tìm thấy nhóm tiện ích", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 3e4352d..93f3037 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -1844,6 +1844,7 @@ "passwordTooShort": "密码至少需要 {{min}} 个字符", "proxyNotFound": "未找到代理", "groupNotFound": "未找到分组", + "groupAlreadyExists": "已存在同名分组", "vpnNotFound": "未找到 VPN", "extensionNotFound": "未找到扩展", "extensionGroupNotFound": "未找到扩展分组", diff --git a/src/lib/backend-errors.ts b/src/lib/backend-errors.ts index fc5c2a0..db3820a 100644 --- a/src/lib/backend-errors.ts +++ b/src/lib/backend-errors.ts @@ -22,6 +22,7 @@ export type BackendErrorCode = | "SELF_HOSTED_REQUIRES_LOGOUT" | "PROXY_NOT_FOUND" | "GROUP_NOT_FOUND" + | "GROUP_ALREADY_EXISTS" | "VPN_NOT_FOUND" | "EXTENSION_NOT_FOUND" | "EXTENSION_GROUP_NOT_FOUND" @@ -113,6 +114,8 @@ export function translateBackendError(t: TFunction, err: unknown): string { return t("backendErrors.proxyNotFound"); case "GROUP_NOT_FOUND": return t("backendErrors.groupNotFound"); + case "GROUP_ALREADY_EXISTS": + return t("backendErrors.groupAlreadyExists"); case "VPN_NOT_FOUND": return t("backendErrors.vpnNotFound"); case "EXTENSION_NOT_FOUND":