mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-06-26 00:19:57 +02:00
refactor: cleanup
This commit is contained in:
+75
-35
@@ -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<string>("url-open-request", (event) => {
|
||||
console.log("Received URL open request:", event.payload);
|
||||
handleUrlOpen(event.payload);
|
||||
});
|
||||
unlisteners.push(
|
||||
await listen<string>("url-open-request", (event) => {
|
||||
console.log("Received URL open request:", event.payload);
|
||||
handleUrlOpen(event.payload);
|
||||
}),
|
||||
);
|
||||
|
||||
// Listen for show profile selector events
|
||||
await listen<string>("show-profile-selector", (event) => {
|
||||
console.log("Received show profile selector request:", event.payload);
|
||||
handleUrlOpen(event.payload);
|
||||
});
|
||||
unlisteners.push(
|
||||
await listen<string>("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<string>("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<string>("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<string>();
|
||||
@@ -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?.();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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<string, TrafficSnapshot> = {};
|
||||
// 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<string, TrafficSnapshot> = {};
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1844,6 +1844,7 @@
|
||||
"passwordTooShort": "パスワードは {{min}} 文字以上必要です",
|
||||
"proxyNotFound": "プロキシが見つかりません",
|
||||
"groupNotFound": "グループが見つかりません",
|
||||
"groupAlreadyExists": "この名前のグループは既に存在します",
|
||||
"vpnNotFound": "VPNが見つかりません",
|
||||
"extensionNotFound": "拡張機能が見つかりません",
|
||||
"extensionGroupNotFound": "拡張機能グループが見つかりません",
|
||||
|
||||
@@ -1844,6 +1844,7 @@
|
||||
"passwordTooShort": "비밀번호는 {{min}}자 이상이어야 합니다",
|
||||
"proxyNotFound": "프록시를 찾을 수 없습니다",
|
||||
"groupNotFound": "그룹을 찾을 수 없습니다",
|
||||
"groupAlreadyExists": "이 이름의 그룹이 이미 존재합니다",
|
||||
"vpnNotFound": "VPN을 찾을 수 없습니다",
|
||||
"extensionNotFound": "확장 프로그램을 찾을 수 없습니다",
|
||||
"extensionGroupNotFound": "확장 프로그램 그룹을 찾을 수 없습니다",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1844,6 +1844,7 @@
|
||||
"passwordTooShort": "Пароль должен быть не короче {{min}} символов",
|
||||
"proxyNotFound": "Прокси не найден",
|
||||
"groupNotFound": "Группа не найдена",
|
||||
"groupAlreadyExists": "Группа с таким именем уже существует",
|
||||
"vpnNotFound": "VPN не найден",
|
||||
"extensionNotFound": "Расширение не найдено",
|
||||
"extensionGroupNotFound": "Группа расширений не найдена",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1844,6 +1844,7 @@
|
||||
"passwordTooShort": "密码至少需要 {{min}} 个字符",
|
||||
"proxyNotFound": "未找到代理",
|
||||
"groupNotFound": "未找到分组",
|
||||
"groupAlreadyExists": "已存在同名分组",
|
||||
"vpnNotFound": "未找到 VPN",
|
||||
"extensionNotFound": "未找到扩展",
|
||||
"extensionGroupNotFound": "未找到扩展分组",
|
||||
|
||||
@@ -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":
|
||||
|
||||
Reference in New Issue
Block a user