refactor: cleanup

This commit is contained in:
zhom
2026-06-24 02:00:44 +04:00
parent fe3ae13928
commit 8588a44fb5
22 changed files with 172 additions and 82 deletions
+4 -4
View File
@@ -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"
+5 -2
View File
@@ -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"
+4 -4
View File
@@ -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
+24 -8
View File
@@ -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]
+13 -7
View File
@@ -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");
}
+75 -35
View File
@@ -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?.();
+2 -2
View File
@@ -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 {
+2 -2
View File
@@ -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 {
+2 -2
View File
@@ -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 {
+10 -6
View File
@@ -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);
}
+13 -7
View File
@@ -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;
}
}
+6 -3
View File
@@ -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);
}
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -1844,6 +1844,7 @@
"passwordTooShort": "パスワードは {{min}} 文字以上必要です",
"proxyNotFound": "プロキシが見つかりません",
"groupNotFound": "グループが見つかりません",
"groupAlreadyExists": "この名前のグループは既に存在します",
"vpnNotFound": "VPNが見つかりません",
"extensionNotFound": "拡張機能が見つかりません",
"extensionGroupNotFound": "拡張機能グループが見つかりません",
+1
View File
@@ -1844,6 +1844,7 @@
"passwordTooShort": "비밀번호는 {{min}}자 이상이어야 합니다",
"proxyNotFound": "프록시를 찾을 수 없습니다",
"groupNotFound": "그룹을 찾을 수 없습니다",
"groupAlreadyExists": "이 이름의 그룹이 이미 존재합니다",
"vpnNotFound": "VPN을 찾을 수 없습니다",
"extensionNotFound": "확장 프로그램을 찾을 수 없습니다",
"extensionGroupNotFound": "확장 프로그램 그룹을 찾을 수 없습니다",
+1
View File
@@ -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",
+1
View File
@@ -1844,6 +1844,7 @@
"passwordTooShort": "Пароль должен быть не короче {{min}} символов",
"proxyNotFound": "Прокси не найден",
"groupNotFound": "Группа не найдена",
"groupAlreadyExists": "Группа с таким именем уже существует",
"vpnNotFound": "VPN не найден",
"extensionNotFound": "Расширение не найдено",
"extensionGroupNotFound": "Группа расширений не найдена",
+1
View File
@@ -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",
+1
View File
@@ -1844,6 +1844,7 @@
"passwordTooShort": "密码至少需要 {{min}} 个字符",
"proxyNotFound": "未找到代理",
"groupNotFound": "未找到分组",
"groupAlreadyExists": "已存在同名分组",
"vpnNotFound": "未找到 VPN",
"extensionNotFound": "未找到扩展",
"extensionGroupNotFound": "未找到扩展分组",
+3
View File
@@ -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":