mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-08-29 14:10:53 +02:00
2568 lines
90 KiB
TypeScript
2568 lines
90 KiB
TypeScript
"use client";
|
|
|
|
import { invoke } from "@tauri-apps/api/core";
|
|
import { listen } from "@tauri-apps/api/event";
|
|
import { getCurrent } from "@tauri-apps/plugin-deep-link";
|
|
import { motion } from "motion/react";
|
|
import { useOnborda } from "onborda";
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { AboutDialog } from "@/components/about-dialog";
|
|
import { AccountPage } from "@/components/account-page";
|
|
import { CloneProfileDialog } from "@/components/clone-profile-dialog";
|
|
import { CloseConfirmDialog } from "@/components/close-confirm-dialog";
|
|
import { CommandPalette } from "@/components/command-palette";
|
|
import { CommercialTrialModal } from "@/components/commercial-trial-modal";
|
|
import { CookieBotPage, type CookieBotTab } from "@/components/cookie-bot-page";
|
|
import { CookieCopyDialog } from "@/components/cookie-copy-dialog";
|
|
import { CookieManagementDialog } from "@/components/cookie-management-dialog";
|
|
import { CreateProfileDialog } from "@/components/create-profile-dialog";
|
|
import { DeleteConfirmationDialog } from "@/components/delete-confirmation-dialog";
|
|
import { DeviceCodeVerifyDialog } from "@/components/device-code-verify-dialog";
|
|
import { ExtensionGroupAssignmentDialog } from "@/components/extension-group-assignment-dialog";
|
|
import { ExtensionManagementDialog } from "@/components/extension-management-dialog";
|
|
import { GroupAssignmentDialog } from "@/components/group-assignment-dialog";
|
|
import { GroupManagementDialog } from "@/components/group-management-dialog";
|
|
import HomeHeader from "@/components/home-header";
|
|
import { ImportProfileDialog } from "@/components/import-profile-dialog";
|
|
import { IntegrationsDialog } from "@/components/integrations-dialog";
|
|
import { ONBOARDING_TOUR } from "@/components/onboarding-provider";
|
|
import { PermissionDialog } from "@/components/permission-dialog";
|
|
import {
|
|
type GateDecision,
|
|
type GateFindings,
|
|
PreLaunchGateDialog,
|
|
} from "@/components/pre-launch-gate-dialog";
|
|
import { ProfilesDataTable } from "@/components/profile-data-table";
|
|
import {
|
|
type PasswordDialogMode,
|
|
ProfilePasswordDialog,
|
|
} from "@/components/profile-password-dialog";
|
|
import { ProfileSelectorDialog } from "@/components/profile-selector-dialog";
|
|
import { ProfileSyncDialog } from "@/components/profile-sync-dialog";
|
|
import { ProxyAssignmentDialog } from "@/components/proxy-assignment-dialog";
|
|
import { ProxyManagementDialog } from "@/components/proxy-management-dialog";
|
|
import { type AppPage, RailNav } from "@/components/rail-nav";
|
|
import { SettingsDialog } from "@/components/settings-dialog";
|
|
import { ShortcutsPage } from "@/components/shortcuts-page";
|
|
import { SyncAllDialog } from "@/components/sync-all-dialog";
|
|
import { SyncConfigDialog } from "@/components/sync-config-dialog";
|
|
import { SyncFollowerDialog } from "@/components/sync-follower-dialog";
|
|
import { ThankYouDialog } from "@/components/thank-you-dialog";
|
|
import { WayfernConfigDialog } from "@/components/wayfern-config-dialog";
|
|
import { WayfernTermsDialog } from "@/components/wayfern-terms-dialog";
|
|
import { WelcomeDialog } from "@/components/welcome-dialog";
|
|
import { WindowResizeWarningDialog } from "@/components/window-resize-warning-dialog";
|
|
import { useAppUpdateNotifications } from "@/hooks/use-app-update-notifications";
|
|
import { useCloudAuth } from "@/hooks/use-cloud-auth";
|
|
import { useCommercialTrial } from "@/hooks/use-commercial-trial";
|
|
import { cookieBotScopeFor, useCookieBot } from "@/hooks/use-cookie-bot";
|
|
import { useGroupEvents } from "@/hooks/use-group-events";
|
|
import type { PermissionType } from "@/hooks/use-permissions";
|
|
import { usePermissions } from "@/hooks/use-permissions";
|
|
import { useProfileEvents } from "@/hooks/use-profile-events";
|
|
import { useProxyEvents } from "@/hooks/use-proxy-events";
|
|
import { useSyncSessions } from "@/hooks/use-sync-session";
|
|
import { useUpdateNotifications } from "@/hooks/use-update-notifications";
|
|
import { useVersionUpdater } from "@/hooks/use-version-updater";
|
|
import { useVpnEvents } from "@/hooks/use-vpn-events";
|
|
import { useWayfernTerms } from "@/hooks/use-wayfern-terms";
|
|
import { parseBackendError, translateBackendError } from "@/lib/backend-errors";
|
|
import { canUseCookieBot, getEntitlements } from "@/lib/entitlements";
|
|
import { MOTION_EASE_OUT } from "@/lib/motion";
|
|
import {
|
|
ONBOARDING_TOUR_CLOSED_EVENT,
|
|
ONBOARDING_TOUR_FINISHED_EVENT,
|
|
setOnboardingActive,
|
|
} from "@/lib/onboarding-signal";
|
|
import {
|
|
matchesProfile,
|
|
type ProfileSearchContext,
|
|
parseProfileSearch,
|
|
} from "@/lib/profile-search";
|
|
import {
|
|
matchesGroupDigit,
|
|
matchesShortcut,
|
|
SHORTCUTS,
|
|
type ShortcutId,
|
|
} from "@/lib/shortcuts";
|
|
import {
|
|
dismissToast,
|
|
showErrorToast,
|
|
showSuccessToast,
|
|
showSyncProgressToast,
|
|
showToast,
|
|
} from "@/lib/toast-utils";
|
|
import type {
|
|
BrowserProfile,
|
|
ConsistencyResult,
|
|
ExtensionGroup,
|
|
PreLaunchChecks,
|
|
SyncSettings,
|
|
WayfernConfig,
|
|
} from "@/types";
|
|
|
|
type GateRequest = {
|
|
profile: BrowserProfile;
|
|
findings: GateFindings;
|
|
};
|
|
|
|
type LaunchResult = {
|
|
status: "launched" | "cancelled" | "blocked";
|
|
};
|
|
|
|
/**
|
|
* Rebuild the mismatch detail the gate dialog renders from a
|
|
* FINGERPRINT_EXIT_MISMATCH error's params. Every param is a string, because
|
|
* backend error params always are.
|
|
*/
|
|
function consistencyFromErrorParams(
|
|
params?: Record<string, string>,
|
|
): ConsistencyResult {
|
|
return {
|
|
consistent: false,
|
|
checked: true,
|
|
exit_ip: params?.exitIp || null,
|
|
exit_country_code: params?.exitCountry || null,
|
|
exit_timezone: params?.exitTimezone || null,
|
|
fingerprint_timezone: params?.fingerprintTimezone || null,
|
|
fingerprint_language: params?.fingerprintLanguage || null,
|
|
mismatches: (params?.mismatches ?? "").split(",").filter(Boolean),
|
|
};
|
|
}
|
|
|
|
type BrowserTypeString = "wayfern";
|
|
|
|
interface PendingUrl {
|
|
id: string;
|
|
url: string;
|
|
}
|
|
|
|
export default function Home() {
|
|
const { t } = useTranslation();
|
|
// Mount global version update listener/toasts
|
|
useVersionUpdater();
|
|
|
|
// Use the new profile events hook for centralized profile management
|
|
const {
|
|
profiles,
|
|
runningProfiles,
|
|
isLoading: profilesLoading,
|
|
error: profilesError,
|
|
} = useProfileEvents();
|
|
|
|
// First-run onboarding tour (Onborda).
|
|
const { startOnborda, setCurrentStep, isOnbordaVisible, currentStep } =
|
|
useOnborda();
|
|
const onboardingHandledRef = useRef(false);
|
|
const [welcomeOpen, setWelcomeOpen] = useState(false);
|
|
const [thankYouOpen, setThankYouOpen] = useState(false);
|
|
// null = onboarding decision pending; false = not a first-run session (run
|
|
// normal permission checks); true = first-run session, so "Not now" really
|
|
// defers the standalone permission dialog until a later launch.
|
|
const [firstRunOnboarding, setFirstRunOnboarding] = useState<boolean | null>(
|
|
null,
|
|
);
|
|
|
|
const persistOnboardingComplete = useCallback(() => {
|
|
void invoke("complete_onboarding").catch((err: unknown) => {
|
|
console.error("Failed to persist onboarding completion:", err);
|
|
});
|
|
}, []);
|
|
|
|
// Welcome flow finished. Existing-profile users are done after the welcome +
|
|
// commercial-use steps; users with no profile yet continue into the in-app
|
|
// product tour that walks them through creating their first profile.
|
|
const handleWelcomeComplete = useCallback(() => {
|
|
setWelcomeOpen(false);
|
|
if (profiles.length === 0) {
|
|
startOnborda(ONBOARDING_TOUR);
|
|
} else {
|
|
persistOnboardingComplete();
|
|
}
|
|
}, [persistOnboardingComplete, profiles.length, startOnborda]);
|
|
|
|
// Finishing or explicitly skipping the product tour completes the one-shot
|
|
// onboarding. Only reaching the end triggers the celebration.
|
|
useEffect(() => {
|
|
const handleClosed = () => persistOnboardingComplete();
|
|
const handleFinished = () => setThankYouOpen(true);
|
|
window.addEventListener(ONBOARDING_TOUR_CLOSED_EVENT, handleClosed);
|
|
window.addEventListener(ONBOARDING_TOUR_FINISHED_EVENT, handleFinished);
|
|
return () => {
|
|
window.removeEventListener(ONBOARDING_TOUR_CLOSED_EVENT, handleClosed);
|
|
window.removeEventListener(
|
|
ONBOARDING_TOUR_FINISHED_EVENT,
|
|
handleFinished,
|
|
);
|
|
};
|
|
}, [persistOnboardingComplete]);
|
|
|
|
// Suppress the global browser-download toasts while onboarding (welcome or
|
|
// tour) is active — the welcome dialog shows setup progress itself.
|
|
useEffect(() => {
|
|
setOnboardingActive(welcomeOpen || isOnbordaVisible);
|
|
}, [welcomeOpen, isOnbordaVisible]);
|
|
|
|
// While the tour is visible, keep the body pinned to the left. Onborda calls
|
|
// scrollIntoView({ inline: "center" }) on the highlighted element; because the
|
|
// body is overflow-hidden it can still be scrolled programmatically, which
|
|
// would shove the whole app (rail and all) sideways with no way to scroll
|
|
// back. The profile table keeps its own scroll container, untouched here.
|
|
useEffect(() => {
|
|
if (!isOnbordaVisible) return;
|
|
const pin = () => {
|
|
if (document.body.scrollLeft !== 0) document.body.scrollLeft = 0;
|
|
if (document.documentElement.scrollLeft !== 0)
|
|
document.documentElement.scrollLeft = 0;
|
|
};
|
|
pin();
|
|
window.addEventListener("scroll", pin, true);
|
|
return () => window.removeEventListener("scroll", pin, true);
|
|
}, [isOnbordaVisible]);
|
|
|
|
// On the very first launch, always show the welcome + commercial-use steps.
|
|
// The completion flag is only persisted after the user finishes or skips the
|
|
// full flow, so an interrupted setup can recover on the next launch.
|
|
// The welcome dialog itself decides whether to continue into the browser
|
|
// download + profile-creation flow — only when the user has no profile yet.
|
|
useEffect(() => {
|
|
if (profilesLoading || onboardingHandledRef.current) return;
|
|
onboardingHandledRef.current = true;
|
|
void (async () => {
|
|
try {
|
|
const completed = await invoke<boolean>("get_onboarding_completed");
|
|
if (completed) {
|
|
setFirstRunOnboarding(false);
|
|
return;
|
|
}
|
|
setFirstRunOnboarding(true);
|
|
setWelcomeOpen(true);
|
|
} catch (err) {
|
|
console.error("Onboarding init failed:", err);
|
|
setFirstRunOnboarding(false);
|
|
}
|
|
})();
|
|
}, [profilesLoading]);
|
|
|
|
// Advance from the "create a profile" step to the "DNS blocking" step as soon
|
|
// as the user's first profile exists (its DNS dropdown is now in the DOM).
|
|
useEffect(() => {
|
|
if (isOnbordaVisible && currentStep === 0 && profiles.length > 0) {
|
|
// Small delay so the new profile row (and its DNS dropdown target) has
|
|
// mounted before Onborda re-points at it. Owning the timeout here lets
|
|
// cleanup cancel it if the tour closes, instead of reopening a dismissed
|
|
// tour when Onborda's delayed setter eventually fires.
|
|
const timeout = window.setTimeout(() => setCurrentStep(1), 300);
|
|
return () => window.clearTimeout(timeout);
|
|
}
|
|
}, [isOnbordaVisible, currentStep, profiles.length, setCurrentStep]);
|
|
|
|
const {
|
|
groups: groupsData,
|
|
isLoading: groupsLoading,
|
|
error: groupsError,
|
|
} = useGroupEvents();
|
|
|
|
const {
|
|
storedProxies,
|
|
isLoading: proxiesLoading,
|
|
error: proxiesError,
|
|
} = useProxyEvents();
|
|
|
|
const { vpnConfigs } = useVpnEvents();
|
|
|
|
// Extension groups feed both the table's Ext column and the search filter's
|
|
// `ext:` lookup, so the list is loaded here and handed down rather than
|
|
// fetched twice. Refreshed when the backend emits 'extensions-changed'
|
|
// (group rename/create/delete).
|
|
const [extensionGroups, setExtensionGroups] = useState<ExtensionGroup[]>([]);
|
|
|
|
useEffect(() => {
|
|
let mounted = true;
|
|
let unlisten: (() => void) | undefined;
|
|
const load = async () => {
|
|
try {
|
|
const data = await invoke<ExtensionGroup[]>("list_extension_groups");
|
|
if (mounted) setExtensionGroups(data);
|
|
} catch (e) {
|
|
console.error("Failed to load extension groups:", e);
|
|
}
|
|
};
|
|
void load();
|
|
void listen("extensions-changed", () => {
|
|
void load();
|
|
}).then((u) => {
|
|
if (mounted) unlisten = u;
|
|
else u();
|
|
});
|
|
return () => {
|
|
mounted = false;
|
|
unlisten?.();
|
|
};
|
|
}, []);
|
|
|
|
// Synchronizer sessions
|
|
const { getProfileSyncInfo } = useSyncSessions();
|
|
const [syncLeaderProfile, setSyncLeaderProfile] =
|
|
useState<BrowserProfile | null>(null);
|
|
|
|
// Wayfern terms and commercial trial hooks
|
|
const {
|
|
termsAccepted,
|
|
isLoading: termsLoading,
|
|
checkTerms,
|
|
} = useWayfernTerms();
|
|
const {
|
|
trialStatus,
|
|
hasAcknowledged: trialAcknowledged,
|
|
checkTrialStatus,
|
|
} = useCommercialTrial();
|
|
|
|
// Cloud auth for cross-OS unlock
|
|
const { user: cloudUser } = useCloudAuth();
|
|
const crossOsUnlocked = getEntitlements(cloudUser).crossOsFingerprints;
|
|
// Bulk run/stop is a paid (browser automation) feature, matching the
|
|
// /v1/profiles/batch/run API gate. Free/solo users see the bulk Run/Stop
|
|
// actions disabled with a Pro badge.
|
|
const automationUnlocked = getEntitlements(cloudUser).browserAutomation;
|
|
// The rail needs to show a live run from every page, so the shell subscribes
|
|
// to the shared cookie-bot store too. It is a module singleton, so this costs
|
|
// one more listener and no extra request. This is also what starts the event
|
|
// stream for a user who signs in without restarting the app.
|
|
const { liveSessions: cookieBotLiveSessions } = useCookieBot(
|
|
canUseCookieBot(cloudUser),
|
|
cookieBotScopeFor(cloudUser),
|
|
);
|
|
|
|
const [selfHostedSyncConfigured, setSelfHostedSyncConfigured] =
|
|
useState(false);
|
|
|
|
const checkSelfHostedSync = useCallback(async () => {
|
|
try {
|
|
const settings = await invoke<SyncSettings>("get_sync_settings");
|
|
const hasConfig = Boolean(
|
|
settings.sync_server_url && settings.sync_token,
|
|
);
|
|
setSelfHostedSyncConfigured(hasConfig && !cloudUser);
|
|
} catch {
|
|
setSelfHostedSyncConfigured(false);
|
|
}
|
|
}, [cloudUser]);
|
|
|
|
// Cloud sync follows `cloudBackup`, NOT `crossOsFingerprints`. They agreed on
|
|
// every plan until Solo, which buys 20 cloud backups and deliberately has no
|
|
// fingerprint editing — so deriving sync from the fingerprint capability put a
|
|
// Pro badge on the one feature a Solo customer is paying for.
|
|
const cloudBackupUnlocked = getEntitlements(cloudUser).cloudBackup;
|
|
const syncUnlocked = cloudBackupUnlocked || selfHostedSyncConfigured;
|
|
|
|
const [currentPage, setCurrentPage] = useState<AppPage>("profiles");
|
|
const [accountDialogOpen, setAccountDialogOpen] = useState(false);
|
|
// Tracks which tab inside the shared proxy-management page should be active.
|
|
// The VPN rail item routes to the same page but pre-selects the VPN tab.
|
|
const [proxyManagementInitialTab, setProxyManagementInitialTab] = useState<
|
|
"proxies" | "vpns"
|
|
>("proxies");
|
|
const [extensionManagementInitialTab, setExtensionManagementInitialTab] =
|
|
useState<"extensions" | "groups">("extensions");
|
|
const [integrationsInitialTab, setIntegrationsInitialTab] = useState<
|
|
"api" | "mcp"
|
|
>("api");
|
|
const [cookieBotDialogOpen, setCookieBotDialogOpen] = useState(false);
|
|
const [cookieBotInitialTab, setCookieBotInitialTab] =
|
|
useState<CookieBotTab>("overview");
|
|
const [createProfileDialogOpen, setCreateProfileDialogOpen] = useState(false);
|
|
const [settingsDialogOpen, setSettingsDialogOpen] = useState(false);
|
|
const [integrationsDialogOpen, setIntegrationsDialogOpen] = useState(false);
|
|
const [importProfileDialogOpen, setImportProfileDialogOpen] = useState(false);
|
|
const [proxyManagementDialogOpen, setProxyManagementDialogOpen] =
|
|
useState(false);
|
|
const [wayfernConfigDialogOpen, setWayfernConfigDialogOpen] = useState(false);
|
|
const [groupManagementDialogOpen, setGroupManagementDialogOpen] =
|
|
useState(false);
|
|
const [extensionManagementDialogOpen, setExtensionManagementDialogOpen] =
|
|
useState(false);
|
|
const [groupAssignmentDialogOpen, setGroupAssignmentDialogOpen] =
|
|
useState(false);
|
|
const [
|
|
extensionGroupAssignmentDialogOpen,
|
|
setExtensionGroupAssignmentDialogOpen,
|
|
] = useState(false);
|
|
const [
|
|
selectedProfilesForExtensionGroup,
|
|
setSelectedProfilesForExtensionGroup,
|
|
] = useState<string[]>([]);
|
|
const [proxyAssignmentDialogOpen, setProxyAssignmentDialogOpen] =
|
|
useState(false);
|
|
const [cookieCopyDialogOpen, setCookieCopyDialogOpen] = useState(false);
|
|
const [cookieManagementDialogOpen, setCookieManagementDialogOpen] =
|
|
useState(false);
|
|
const [
|
|
currentProfileForCookieManagement,
|
|
setCurrentProfileForCookieManagement,
|
|
] = useState<BrowserProfile | null>(null);
|
|
const [selectedProfilesForCookies, setSelectedProfilesForCookies] = useState<
|
|
string[]
|
|
>([]);
|
|
const [selectedGroupId, setSelectedGroupId] = useState<string>("__all__");
|
|
const [selectedProfilesForGroup, setSelectedProfilesForGroup] = useState<
|
|
string[]
|
|
>([]);
|
|
const [selectedProfilesForProxy, setSelectedProfilesForProxy] = useState<
|
|
string[]
|
|
>([]);
|
|
const [selectedProfiles, setSelectedProfiles] = useState<string[]>([]);
|
|
const [searchQuery, setSearchQuery] = useState<string>("");
|
|
const [pendingUrls, setPendingUrls] = useState<PendingUrl[]>([]);
|
|
const [currentProfileForWayfernConfig, setCurrentProfileForWayfernConfig] =
|
|
useState<BrowserProfile | null>(null);
|
|
const [cloneProfile, setCloneProfile] = useState<BrowserProfile | null>(null);
|
|
const [passwordDialogProfile, setPasswordDialogProfile] =
|
|
useState<BrowserProfile | null>(null);
|
|
const [passwordDialogMode, setPasswordDialogMode] =
|
|
useState<PasswordDialogMode>("set");
|
|
const pendingLaunchAfterUnlockRef = useRef<BrowserProfile | null>(null);
|
|
const [windowResizeWarningOpen, setWindowResizeWarningOpen] = useState(false);
|
|
const windowResizeWarningResolver = useRef<
|
|
((proceed: boolean) => void) | null
|
|
>(null);
|
|
const [permissionDialogOpen, setPermissionDialogOpen] = useState(false);
|
|
const [currentPermissionType, setCurrentPermissionType] =
|
|
useState<PermissionType>("microphone");
|
|
const [showBulkDeleteConfirmation, setShowBulkDeleteConfirmation] =
|
|
useState(false);
|
|
const [isBulkDeleting, setIsBulkDeleting] = useState(false);
|
|
const [syncConfigDialogOpen, setSyncConfigDialogOpen] = useState(false);
|
|
const [deviceCodeDialogOpen, setDeviceCodeDialogOpen] = useState(false);
|
|
const [syncAllDialogOpen, setSyncAllDialogOpen] = useState(false);
|
|
const [profileSyncDialogOpen, setProfileSyncDialogOpen] = useState(false);
|
|
const [currentProfileForSync, setCurrentProfileForSync] =
|
|
useState<BrowserProfile | null>(null);
|
|
const [commandPaletteOpen, setCommandPaletteOpen] = useState(false);
|
|
const [aboutDialogOpen, setAboutDialogOpen] = useState(false);
|
|
// Pre-launch gate. Requests queue instead of overwriting a single resolver:
|
|
// a bulk run enqueues one per profile, and every waiter must settle or the
|
|
// Promise.allSettled below it never resolves and the bulk spinner sticks.
|
|
const gateQueueRef = useRef<
|
|
Array<{
|
|
id: number;
|
|
req: GateRequest;
|
|
/// The bulk run this request belongs to, or undefined for a single
|
|
/// launch. Carried per entry so a blanket "apply to the rest" can only
|
|
/// ever claim the run its own dialog came from.
|
|
runId: number | undefined;
|
|
resolve: (decision: GateDecision) => void;
|
|
}>
|
|
>([]);
|
|
const gateRequestSeqRef = useRef(0);
|
|
const [gateState, setGateState] = useState<{
|
|
id: number;
|
|
req: GateRequest;
|
|
remaining: number;
|
|
} | null>(null);
|
|
// Set when the user ticks "apply to the remaining profiles" during a bulk
|
|
// run, so the rest are answered without prompting again. Scoped to one bulk
|
|
// run and to the severity it was given for.
|
|
const blanketGateDecisionRef = useRef<{
|
|
decision: GateDecision;
|
|
/// Only auto-answers gates no more severe than the one the user saw. A
|
|
/// choice made on an extension warning must never silently bypass a hard
|
|
/// block on a later profile.
|
|
coversBlocking: boolean;
|
|
/// Identifies the bulk run, so a single launch started while a bulk run is
|
|
/// in flight still gets its own dialog.
|
|
runId: number;
|
|
} | null>(null);
|
|
const bulkRunIdRef = useRef(0);
|
|
// Owned by page.tsx so the command palette can request opening the profile
|
|
// info dialog. ProfilesDataTable consumes it through controlled props.
|
|
const [profileInfoDialog, setProfileInfoDialog] =
|
|
useState<BrowserProfile | null>(null);
|
|
const { isMicrophoneAccessGranted, isCameraAccessGranted, isInitialized } =
|
|
usePermissions();
|
|
|
|
const handleSelectGroup = useCallback((groupId: string) => {
|
|
setSelectedGroupId(groupId);
|
|
setSelectedProfiles([]);
|
|
}, []);
|
|
|
|
const handleRailNavigate = useCallback((page: AppPage) => {
|
|
// Always reset every sub-page-able dialog before opening the next one,
|
|
// so navigating from one rail item to another doesn't stack two
|
|
// sub-pages on top of each other.
|
|
setSettingsDialogOpen(false);
|
|
setProxyManagementDialogOpen(false);
|
|
setExtensionManagementDialogOpen(false);
|
|
setGroupManagementDialogOpen(false);
|
|
setIntegrationsDialogOpen(false);
|
|
setImportProfileDialogOpen(false);
|
|
setAccountDialogOpen(false);
|
|
setCookieBotDialogOpen(false);
|
|
|
|
setCurrentPage(page);
|
|
switch (page) {
|
|
case "profiles":
|
|
break;
|
|
case "settings":
|
|
setSettingsDialogOpen(true);
|
|
break;
|
|
case "proxies":
|
|
setProxyManagementInitialTab("proxies");
|
|
setProxyManagementDialogOpen(true);
|
|
break;
|
|
case "extensions":
|
|
setExtensionManagementDialogOpen(true);
|
|
break;
|
|
case "groups":
|
|
setGroupManagementDialogOpen(true);
|
|
break;
|
|
case "cookieBot":
|
|
setCookieBotDialogOpen(true);
|
|
break;
|
|
case "integrations":
|
|
setIntegrationsDialogOpen(true);
|
|
break;
|
|
case "import":
|
|
setImportProfileDialogOpen(true);
|
|
break;
|
|
case "vpns":
|
|
// VPNs share the proxy management page; pre-select the VPN tab so
|
|
// the user lands directly on the right list.
|
|
setProxyManagementInitialTab("vpns");
|
|
setProxyManagementDialogOpen(true);
|
|
break;
|
|
case "account":
|
|
setAccountDialogOpen(true);
|
|
break;
|
|
case "shortcuts":
|
|
// Plain page render — nothing else to open.
|
|
break;
|
|
}
|
|
}, []);
|
|
|
|
const runShortcut = useCallback(
|
|
(id: ShortcutId) => {
|
|
switch (id) {
|
|
case "openPalette":
|
|
setCommandPaletteOpen(true);
|
|
break;
|
|
case "openShortcuts":
|
|
handleRailNavigate("shortcuts");
|
|
break;
|
|
case "importProfile":
|
|
handleRailNavigate("import");
|
|
break;
|
|
case "goProfiles":
|
|
handleRailNavigate("profiles");
|
|
break;
|
|
case "goProxies": {
|
|
// Mod+N: navigate first time; flip proxies↔vpns on subsequent presses.
|
|
// handleRailNavigate("proxies"|"vpns") already updates the dialog's
|
|
// initialTab, so we just pick the right destination.
|
|
if (currentPage === "proxies") {
|
|
handleRailNavigate("vpns");
|
|
} else if (currentPage === "vpns") {
|
|
handleRailNavigate("proxies");
|
|
} else {
|
|
handleRailNavigate(
|
|
proxyManagementInitialTab === "vpns" ? "vpns" : "proxies",
|
|
);
|
|
}
|
|
break;
|
|
}
|
|
case "goExtensions": {
|
|
// Mod+E: flip extensions↔groups tab inside the dialog when already there.
|
|
if (currentPage === "extensions") {
|
|
setExtensionManagementInitialTab((cur) =>
|
|
cur === "extensions" ? "groups" : "extensions",
|
|
);
|
|
} else {
|
|
handleRailNavigate("extensions");
|
|
}
|
|
break;
|
|
}
|
|
case "goGroups":
|
|
handleRailNavigate("groups");
|
|
break;
|
|
case "goCookieBot": {
|
|
// Mod+B: navigate first time; flip overview↔activity while already
|
|
// there, matching how Mod+I flips the integrations tabs.
|
|
if (currentPage === "cookieBot") {
|
|
setCookieBotInitialTab((cur) =>
|
|
cur === "overview" ? "activity" : "overview",
|
|
);
|
|
} else {
|
|
setCookieBotInitialTab("overview");
|
|
handleRailNavigate("cookieBot");
|
|
}
|
|
break;
|
|
}
|
|
case "goIntegrations": {
|
|
// Mod+I: flip api↔mcp tab when already on integrations.
|
|
if (currentPage === "integrations") {
|
|
setIntegrationsInitialTab((cur) => (cur === "api" ? "mcp" : "api"));
|
|
} else {
|
|
handleRailNavigate("integrations");
|
|
}
|
|
break;
|
|
}
|
|
case "goAccount":
|
|
handleRailNavigate("account");
|
|
break;
|
|
case "goSettings":
|
|
handleRailNavigate("settings");
|
|
break;
|
|
}
|
|
},
|
|
[handleRailNavigate, currentPage, proxyManagementInitialTab],
|
|
);
|
|
|
|
// Ordered list the digit shortcuts and palette consume. "__all__" is index 1
|
|
// so Mod+1 always lands on the unfiltered view; the user's groups follow.
|
|
const orderedGroupTargets = useMemo(
|
|
() => [
|
|
{ id: "__all__", name: t("rail.profiles") },
|
|
...groupsData.map((g) => ({ id: g.id, name: g.name })),
|
|
],
|
|
[groupsData, t],
|
|
);
|
|
|
|
const selectGroupByDigit = useCallback(
|
|
(digit: number) => {
|
|
const target = orderedGroupTargets[digit - 1];
|
|
if (!target) return;
|
|
handleRailNavigate("profiles");
|
|
handleSelectGroup(target.id);
|
|
},
|
|
[orderedGroupTargets, handleRailNavigate, handleSelectGroup],
|
|
);
|
|
|
|
useEffect(() => {
|
|
// Global keydown — handles Mod+1..9 group jumps first, then falls back to
|
|
// the static SHORTCUTS table. Skipped while typing in an input, EXCEPT
|
|
// ⌘K and ⌘/ which are meta-level shortcuts and should always be reachable.
|
|
const onKeyDown = (e: KeyboardEvent) => {
|
|
const target = e.target as HTMLElement | null;
|
|
const tag = target?.tagName;
|
|
const isTyping =
|
|
tag === "INPUT" ||
|
|
tag === "TEXTAREA" ||
|
|
tag === "SELECT" ||
|
|
target?.isContentEditable === true;
|
|
|
|
const digit = matchesGroupDigit(e);
|
|
if (digit !== null) {
|
|
if (isTyping) return;
|
|
if (digit - 1 >= orderedGroupTargets.length) return;
|
|
e.preventDefault();
|
|
selectGroupByDigit(digit);
|
|
return;
|
|
}
|
|
|
|
for (const s of SHORTCUTS) {
|
|
if (!matchesShortcut(s, e)) continue;
|
|
if (isTyping && s.id !== "openPalette" && s.id !== "openShortcuts") {
|
|
return;
|
|
}
|
|
e.preventDefault();
|
|
runShortcut(s.id);
|
|
return;
|
|
}
|
|
};
|
|
window.addEventListener("keydown", onKeyDown);
|
|
return () => {
|
|
window.removeEventListener("keydown", onKeyDown);
|
|
};
|
|
}, [runShortcut, selectGroupByDigit, orderedGroupTargets.length]);
|
|
|
|
// Check for missing binaries and offer to download them
|
|
const checkMissingBinaries = useCallback(async () => {
|
|
try {
|
|
const missingBinaries = await invoke<[string, string, string][]>(
|
|
"check_missing_binaries",
|
|
);
|
|
|
|
// Also check for missing GeoIP database
|
|
const missingGeoIP = await invoke<boolean>(
|
|
"check_missing_geoip_database",
|
|
);
|
|
|
|
if (missingBinaries.length > 0 || missingGeoIP) {
|
|
if (missingBinaries.length > 0) {
|
|
console.log("Found missing binaries:", missingBinaries);
|
|
}
|
|
if (missingGeoIP) {
|
|
console.log("Found missing GeoIP database");
|
|
}
|
|
|
|
// Group missing binaries by browser type to avoid concurrent downloads
|
|
const browserMap = new Map<string, string[]>();
|
|
for (const [profileName, browser, version] of missingBinaries) {
|
|
if (!browserMap.has(browser)) {
|
|
browserMap.set(browser, []);
|
|
}
|
|
const versions = browserMap.get(browser);
|
|
if (versions) {
|
|
versions.push(`${version} (for ${profileName})`);
|
|
}
|
|
}
|
|
|
|
// Show a toast notification about missing binaries and auto-download them
|
|
let missingList = Array.from(browserMap.entries())
|
|
.map(([browser, versions]) => `${browser}: ${versions.join(", ")}`)
|
|
.join(", ");
|
|
|
|
if (missingGeoIP) {
|
|
if (missingList) {
|
|
missingList += ", GeoIP database";
|
|
} else {
|
|
missingList = "GeoIP database";
|
|
}
|
|
}
|
|
|
|
console.log(`Downloading missing components: ${missingList}`);
|
|
|
|
try {
|
|
// Download missing binaries and GeoIP database sequentially to prevent conflicts
|
|
const downloaded = await invoke<string[]>(
|
|
"ensure_all_binaries_exist",
|
|
);
|
|
if (downloaded.length > 0) {
|
|
console.log(
|
|
"Successfully downloaded missing components:",
|
|
downloaded,
|
|
);
|
|
}
|
|
} catch (downloadError) {
|
|
console.error(
|
|
"Failed to download missing components:",
|
|
downloadError,
|
|
);
|
|
}
|
|
}
|
|
} catch (err: unknown) {
|
|
console.error("Failed to check missing components:", err);
|
|
}
|
|
}, []);
|
|
|
|
const [processingUrls, setProcessingUrls] = useState<Set<string>>(new Set());
|
|
|
|
const handleUrlOpen = useCallback(
|
|
(url: string) => {
|
|
// Prevent duplicate processing of the same URL
|
|
if (processingUrls.has(url)) {
|
|
console.log("URL already being processed:", url);
|
|
return;
|
|
}
|
|
|
|
setProcessingUrls((prev) => new Set(prev).add(url));
|
|
|
|
try {
|
|
console.log("URL received for opening:", url);
|
|
|
|
// Always show profile selector for manual selection - never auto-open
|
|
// Replace any existing pending URL with the new one
|
|
setPendingUrls([{ id: Date.now().toString(), url }]);
|
|
} finally {
|
|
// Remove URL from processing set after a short delay to prevent rapid duplicates
|
|
setTimeout(() => {
|
|
setProcessingUrls((prev) => {
|
|
const next = new Set(prev);
|
|
next.delete(url);
|
|
return next;
|
|
});
|
|
}, 1000);
|
|
}
|
|
},
|
|
[processingUrls],
|
|
);
|
|
|
|
// Auto-update functionality - use the existing hook for compatibility
|
|
const updateNotifications = useUpdateNotifications();
|
|
const { checkForUpdates, isUpdating } = updateNotifications;
|
|
|
|
useAppUpdateNotifications();
|
|
|
|
// Check for startup URLs but only process them once
|
|
const [hasCheckedStartupUrl, setHasCheckedStartupUrl] = useState(false);
|
|
const checkCurrentUrl = useCallback(async () => {
|
|
if (hasCheckedStartupUrl) return;
|
|
|
|
try {
|
|
const currentUrl = await getCurrent();
|
|
if (currentUrl && currentUrl.length > 0) {
|
|
console.log("Startup URL detected:", currentUrl[0]);
|
|
handleUrlOpen(currentUrl[0]);
|
|
}
|
|
} catch (error) {
|
|
console.error("Failed to check current URL:", error);
|
|
} finally {
|
|
setHasCheckedStartupUrl(true);
|
|
}
|
|
}, [handleUrlOpen, hasCheckedStartupUrl]);
|
|
|
|
// Handle profile errors from useProfileEvents hook
|
|
useEffect(() => {
|
|
if (profilesError) {
|
|
showErrorToast(profilesError);
|
|
}
|
|
}, [profilesError]);
|
|
|
|
// Handle group errors from useGroupEvents hook
|
|
useEffect(() => {
|
|
if (groupsError) {
|
|
showErrorToast(groupsError);
|
|
}
|
|
}, [groupsError]);
|
|
|
|
// Handle proxy errors from useProxyEvents hook
|
|
useEffect(() => {
|
|
if (proxiesError) {
|
|
showErrorToast(proxiesError);
|
|
}
|
|
}, [proxiesError]);
|
|
|
|
const checkAllPermissions = useCallback(() => {
|
|
try {
|
|
// Wait for permissions to be initialized before checking
|
|
if (!isInitialized) {
|
|
return;
|
|
}
|
|
|
|
// Check if any permissions are not granted - prioritize missing permissions
|
|
if (!isMicrophoneAccessGranted) {
|
|
setCurrentPermissionType("microphone");
|
|
setPermissionDialogOpen(true);
|
|
} else if (!isCameraAccessGranted) {
|
|
setCurrentPermissionType("camera");
|
|
setPermissionDialogOpen(true);
|
|
}
|
|
} catch (error) {
|
|
console.error("Failed to check permissions:", error);
|
|
}
|
|
}, [isMicrophoneAccessGranted, isCameraAccessGranted, isInitialized]);
|
|
|
|
const checkNextPermission = useCallback(
|
|
(justGranted?: PermissionType) => {
|
|
try {
|
|
// Treat the just-granted permission as already granted even if our
|
|
// own usePermissions instance hasn't observed it yet — it polls on a
|
|
// 5 s cadence and would otherwise leave the dialog stuck on the
|
|
// permission the user just successfully granted.
|
|
const micGranted =
|
|
isMicrophoneAccessGranted || justGranted === "microphone";
|
|
const camGranted = isCameraAccessGranted || justGranted === "camera";
|
|
|
|
if (!micGranted) {
|
|
setCurrentPermissionType("microphone");
|
|
setPermissionDialogOpen(true);
|
|
} else if (!camGranted) {
|
|
setCurrentPermissionType("camera");
|
|
setPermissionDialogOpen(true);
|
|
} else {
|
|
setPermissionDialogOpen(false);
|
|
}
|
|
} catch (error) {
|
|
console.error("Failed to check next permission:", error);
|
|
}
|
|
},
|
|
[isMicrophoneAccessGranted, isCameraAccessGranted],
|
|
);
|
|
|
|
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)
|
|
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
|
|
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
|
|
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
|
|
handleLogoUrlEvent = (event: CustomEvent) => {
|
|
console.log("Received logo URL event:", event.detail);
|
|
handleUrlOpen(event.detail);
|
|
};
|
|
window.addEventListener(
|
|
"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]);
|
|
|
|
const handleConfigureWayfern = useCallback((profile: BrowserProfile) => {
|
|
setCurrentProfileForWayfernConfig(profile);
|
|
setWayfernConfigDialogOpen(true);
|
|
}, []);
|
|
|
|
const handleSaveWayfernConfig = useCallback(
|
|
async (profile: BrowserProfile, config: WayfernConfig) => {
|
|
try {
|
|
await invoke("update_wayfern_config", {
|
|
profileId: profile.id,
|
|
config,
|
|
});
|
|
// No need to manually reload - useProfileEvents will handle the update
|
|
setWayfernConfigDialogOpen(false);
|
|
} catch (err: unknown) {
|
|
console.error("Failed to update wayfern config:", err);
|
|
showErrorToast(
|
|
t("errors.updateWayfernConfigFailed", { error: JSON.stringify(err) }),
|
|
);
|
|
throw err;
|
|
}
|
|
},
|
|
[t],
|
|
);
|
|
|
|
const handleCreateProfile = useCallback(
|
|
async (profileData: {
|
|
name: string;
|
|
browserStr: BrowserTypeString;
|
|
version: string;
|
|
releaseType: string;
|
|
proxyId?: string;
|
|
vpnId?: string;
|
|
wayfernConfig?: WayfernConfig;
|
|
groupId?: string;
|
|
extensionGroupId?: string;
|
|
ephemeral?: boolean;
|
|
dnsBlocklist?: string;
|
|
launchHook?: string;
|
|
password?: string;
|
|
}) => {
|
|
try {
|
|
const profile = await invoke<BrowserProfile>(
|
|
"create_browser_profile_new",
|
|
{
|
|
name: profileData.name,
|
|
browserStr: profileData.browserStr,
|
|
version: profileData.version,
|
|
releaseType: profileData.releaseType,
|
|
proxyId: profileData.proxyId,
|
|
vpnId: profileData.vpnId,
|
|
wayfernConfig: profileData.wayfernConfig,
|
|
groupId:
|
|
profileData.groupId ??
|
|
(selectedGroupId && selectedGroupId !== "__all__"
|
|
? selectedGroupId
|
|
: undefined),
|
|
ephemeral: profileData.ephemeral,
|
|
dnsBlocklist: profileData.dnsBlocklist,
|
|
launchHook: profileData.launchHook,
|
|
},
|
|
);
|
|
|
|
if (profileData.extensionGroupId) {
|
|
try {
|
|
await invoke("assign_extension_group_to_profile", {
|
|
profileId: profile.id,
|
|
extensionGroupId: profileData.extensionGroupId,
|
|
});
|
|
} catch (err) {
|
|
console.error("Failed to assign extension group:", err);
|
|
}
|
|
}
|
|
|
|
if (profileData.password && !profileData.ephemeral) {
|
|
try {
|
|
await invoke("set_profile_password", {
|
|
profileId: profile.id,
|
|
password: profileData.password,
|
|
});
|
|
} catch (err) {
|
|
showErrorToast(
|
|
t("errors.setProfilePasswordFailed", {
|
|
error: translateBackendError(t, err),
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
|
|
// No need to manually reload - useProfileEvents will handle the update
|
|
} catch (error) {
|
|
showErrorToast(
|
|
t("errors.createProfileFailed", {
|
|
error: translateBackendError(t, error),
|
|
}),
|
|
);
|
|
// Rethrow so the create dialog keeps itself open (its own handler
|
|
// skips closing on error), letting the user fix the proxy/VPN and retry.
|
|
throw error;
|
|
}
|
|
},
|
|
[selectedGroupId, t],
|
|
);
|
|
|
|
// Unattended launches — REST and MCP automation — are the only ones the
|
|
// backend gate lets past a measured mismatch, because there is no dialog for
|
|
// them to answer. Without a listener that finding was emitted into the void.
|
|
useEffect(() => {
|
|
const unlisten = listen<ConsistencyResult>(
|
|
"fingerprint-consistency-warning",
|
|
(event) => {
|
|
const { exit_timezone, fingerprint_timezone } = event.payload;
|
|
showErrorToast(t("backendErrors.fingerprintExitMismatch"), {
|
|
description:
|
|
exit_timezone && fingerprint_timezone
|
|
? t("consistencyWarning.timezoneDetail", {
|
|
exit: exit_timezone,
|
|
fingerprint: fingerprint_timezone,
|
|
})
|
|
: undefined,
|
|
id: `fingerprint-mismatch-${exit_timezone ?? "unknown"}`,
|
|
});
|
|
},
|
|
);
|
|
return () => {
|
|
void unlisten.then((fn) => {
|
|
fn();
|
|
});
|
|
};
|
|
}, [t]);
|
|
|
|
// Show the queue's head, and how many are waiting behind it.
|
|
const syncGateUi = useCallback(() => {
|
|
const queue = gateQueueRef.current;
|
|
setGateState(
|
|
queue.length > 0
|
|
? { id: queue[0].id, req: queue[0].req, remaining: queue.length - 1 }
|
|
: null,
|
|
);
|
|
}, []);
|
|
|
|
const requestGateDecision = useCallback(
|
|
(req: GateRequest, runId?: number): Promise<GateDecision> => {
|
|
const blanket = blanketGateDecisionRef.current;
|
|
const isBlocking = req.findings.fingerprint !== null;
|
|
if (
|
|
blanket &&
|
|
blanket.runId === runId &&
|
|
(blanket.coversBlocking || !isBlocking)
|
|
) {
|
|
// A blanket answer covers only whether to launch. The acknowledgements
|
|
// it carried were about the first profile's specific mismatch and
|
|
// extensions, and must not be persisted against profiles the user
|
|
// never saw.
|
|
return Promise.resolve({
|
|
...blanket.decision,
|
|
ackFingerprint: false,
|
|
ackExtensionKeys: [],
|
|
});
|
|
}
|
|
return new Promise<GateDecision>((resolve) => {
|
|
gateRequestSeqRef.current += 1;
|
|
gateQueueRef.current.push({
|
|
id: gateRequestSeqRef.current,
|
|
req,
|
|
runId,
|
|
resolve,
|
|
});
|
|
syncGateUi();
|
|
});
|
|
},
|
|
[syncGateUi],
|
|
);
|
|
|
|
const settleGate = useCallback(
|
|
(decision: GateDecision) => {
|
|
const entry = gateQueueRef.current.shift();
|
|
if (!entry) {
|
|
return;
|
|
}
|
|
entry.resolve(decision);
|
|
if (decision.applyToRemaining) {
|
|
const coversBlocking = entry.req.findings.fingerprint !== null;
|
|
// Only a bulk run gets a standing blanket, and it claims the run the
|
|
// answered dialog belonged to — never whichever run happens to be in
|
|
// flight when the dialog is settled. Outside a run there is nothing to
|
|
// scope one to, and a session-wide blanket would silently answer
|
|
// unrelated launches later. The queue is still drained either way,
|
|
// which is what the checkbox actually promises.
|
|
if (entry.runId !== undefined) {
|
|
blanketGateDecisionRef.current = {
|
|
decision,
|
|
coversBlocking,
|
|
runId: entry.runId,
|
|
};
|
|
}
|
|
// Drain the queue rather than leaving promises pending forever — but
|
|
// only those the blanket actually covers. A hard block still deserves
|
|
// its own dialog even after the user blanket-approved a warning, and a
|
|
// launch started outside this run was never part of the answer.
|
|
const remaining = gateQueueRef.current.splice(0);
|
|
const kept = [];
|
|
for (const queued of remaining) {
|
|
const covered =
|
|
queued.runId === entry.runId &&
|
|
(coversBlocking || queued.req.findings.fingerprint === null);
|
|
if (!covered) {
|
|
kept.push(queued);
|
|
continue;
|
|
}
|
|
queued.resolve({
|
|
...decision,
|
|
ackFingerprint: false,
|
|
ackExtensionKeys: [],
|
|
});
|
|
}
|
|
gateQueueRef.current = kept;
|
|
}
|
|
syncGateUi();
|
|
},
|
|
[syncGateUi],
|
|
);
|
|
|
|
const persistGateAcks = useCallback(
|
|
async (profileId: string, decision: GateDecision) => {
|
|
// Only on proceed. Cancel is the autofocused default action, so a stray
|
|
// Enter would otherwise permanently disarm the gate for this profile.
|
|
if (!decision.proceed) {
|
|
return;
|
|
}
|
|
if (!decision.ackFingerprint && decision.ackExtensionKeys.length === 0) {
|
|
return;
|
|
}
|
|
try {
|
|
await invoke("ack_launch_gate", {
|
|
profileId,
|
|
ackFingerprint: decision.ackFingerprint,
|
|
ackExtensionKeys: decision.ackExtensionKeys,
|
|
});
|
|
} catch (err) {
|
|
console.warn("Failed to persist launch gate acknowledgement:", err);
|
|
}
|
|
},
|
|
[],
|
|
);
|
|
|
|
const launchProfile = useCallback(
|
|
async (
|
|
profile: BrowserProfile,
|
|
opts?: { bulkRunId?: number },
|
|
): Promise<LaunchResult> => {
|
|
console.log("Starting launch for profile:", profile.name);
|
|
|
|
// Password-protected: must be unlocked before launch
|
|
if (profile.password_protected) {
|
|
try {
|
|
const isLocked = await invoke<boolean>("is_profile_locked", {
|
|
profileId: profile.id,
|
|
});
|
|
if (isLocked) {
|
|
pendingLaunchAfterUnlockRef.current = profile;
|
|
setPasswordDialogMode("unlock");
|
|
setPasswordDialogProfile(profile);
|
|
return { status: "cancelled" };
|
|
}
|
|
} catch (err) {
|
|
console.error("Failed to check profile lock state:", err);
|
|
}
|
|
}
|
|
|
|
// Show one-time warning about window resizing for fingerprinted browsers
|
|
if (profile.browser === "wayfern") {
|
|
try {
|
|
const dismissed = await invoke<boolean>(
|
|
"get_window_resize_warning_dismissed",
|
|
);
|
|
if (!dismissed) {
|
|
const proceed = await new Promise<boolean>((resolve) => {
|
|
windowResizeWarningResolver.current = resolve;
|
|
setWindowResizeWarningOpen(true);
|
|
});
|
|
if (!proceed) {
|
|
return { status: "cancelled" };
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error("Failed to check window resize warning:", error);
|
|
}
|
|
}
|
|
|
|
// Tier 1: purely local checks — an extension scan and a cached exit
|
|
// verdict. No network, no worker started, so a profile whose exit is
|
|
// already known blocks before the launch touches anything.
|
|
let consentToken: string | null = null;
|
|
// Kept for the tier-2 dialog below: the extensions are the same ones,
|
|
// and a mismatch measured mid-launch is exactly when knowing that one of
|
|
// them can change the proxy matters most. Minus anything the user just
|
|
// acknowledged, so a box they ticked seconds ago is not shown again.
|
|
let localChecks: PreLaunchChecks | null = null;
|
|
let ackedExtensionKeys: string[] = [];
|
|
try {
|
|
// One-shot migration of the old per-profile "don't warn again" flag,
|
|
// so a user who already dismissed this profile isn't hard-blocked by
|
|
// the new gate. Granted against the profile's current exit, which is
|
|
// the mismatch they were looking at when they dismissed it.
|
|
const legacySkipKey = `consistency-warn-skip-${profile.id}`;
|
|
if (localStorage.getItem(legacySkipKey) === "1") {
|
|
await invoke("ack_launch_gate", {
|
|
profileId: profile.id,
|
|
ackFingerprint: true,
|
|
ackExtensionKeys: [],
|
|
}).catch((err: unknown) => {
|
|
console.warn("Failed to migrate consistency skip flag:", err);
|
|
});
|
|
localStorage.removeItem(legacySkipKey);
|
|
}
|
|
|
|
const checks = await invoke<PreLaunchChecks>(
|
|
"get_profile_pre_launch_checks",
|
|
{ profileId: profile.id },
|
|
);
|
|
localChecks = checks;
|
|
const blocked =
|
|
checks.consistency.checked && !checks.consistency.consistent;
|
|
if (blocked || checks.vpn_extensions.length > 0) {
|
|
const decision = await requestGateDecision(
|
|
{
|
|
profile,
|
|
findings: {
|
|
vpnExtensions: checks.vpn_extensions,
|
|
scanState: checks.scan_state,
|
|
fingerprint: blocked ? checks.consistency : null,
|
|
measurementUnreliable: checks.exit_measurement_unreliable,
|
|
probePending: checks.exit_probe_pending,
|
|
},
|
|
},
|
|
opts?.bulkRunId,
|
|
);
|
|
await persistGateAcks(profile.id, decision);
|
|
if (!decision.proceed) {
|
|
return { status: "cancelled" };
|
|
}
|
|
ackedExtensionKeys = decision.ackExtensionKeys;
|
|
consentToken = checks.consent_token;
|
|
}
|
|
} catch (err) {
|
|
// Same posture as the password and window-resize gates: a check that
|
|
// cannot run must not make profiles unlaunchable.
|
|
console.warn("Pre-launch checks failed, launching anyway:", err);
|
|
}
|
|
|
|
try {
|
|
const result = await invoke<BrowserProfile>("launch_browser_profile", {
|
|
profile,
|
|
consentToken,
|
|
});
|
|
console.log("Successfully launched profile:", result.name);
|
|
return { status: "launched" };
|
|
} catch (err: unknown) {
|
|
// Tier 2: the enforcing gate measured the exit mid-launch and stopped
|
|
// before spawning the browser. Offer the same decision, then retry
|
|
// exactly once with the token it minted — bounded, so a gate loop is
|
|
// structurally impossible.
|
|
const parsed = parseBackendError(err);
|
|
if (parsed?.code === "FINGERPRINT_EXIT_MISMATCH") {
|
|
const decision = await requestGateDecision(
|
|
{
|
|
profile,
|
|
findings: {
|
|
vpnExtensions: (localChecks?.vpn_extensions ?? []).filter(
|
|
(ext) => !ackedExtensionKeys.includes(ext.key),
|
|
),
|
|
scanState: localChecks?.scan_state ?? "scanned",
|
|
fingerprint: consistencyFromErrorParams(parsed.params),
|
|
measurementUnreliable:
|
|
localChecks?.exit_measurement_unreliable ?? false,
|
|
probePending: false,
|
|
},
|
|
},
|
|
opts?.bulkRunId,
|
|
);
|
|
await persistGateAcks(profile.id, decision);
|
|
if (!decision.proceed) {
|
|
return { status: "cancelled" };
|
|
}
|
|
try {
|
|
await invoke<BrowserProfile>("launch_browser_profile", {
|
|
profile,
|
|
consentToken: parsed.params?.token ?? null,
|
|
});
|
|
return { status: "launched" };
|
|
} catch (retryErr: unknown) {
|
|
showErrorToast(
|
|
t("errors.launchBrowserFailed", {
|
|
error: translateBackendError(t, retryErr),
|
|
}),
|
|
);
|
|
return { status: "blocked" };
|
|
}
|
|
}
|
|
|
|
console.error("Failed to launch browser:", err);
|
|
const errorMessage = translateBackendError(t, err);
|
|
showErrorToast(
|
|
t("errors.launchBrowserFailed", { error: errorMessage }),
|
|
);
|
|
throw err;
|
|
}
|
|
},
|
|
[persistGateAcks, requestGateDecision, t],
|
|
);
|
|
|
|
const handleCloneProfile = useCallback((profile: BrowserProfile) => {
|
|
setCloneProfile(profile);
|
|
}, []);
|
|
|
|
const handleSetPassword = useCallback((profile: BrowserProfile) => {
|
|
pendingLaunchAfterUnlockRef.current = null;
|
|
setPasswordDialogMode("set");
|
|
setPasswordDialogProfile(profile);
|
|
}, []);
|
|
|
|
const handleChangePassword = useCallback((profile: BrowserProfile) => {
|
|
pendingLaunchAfterUnlockRef.current = null;
|
|
setPasswordDialogMode("change");
|
|
setPasswordDialogProfile(profile);
|
|
}, []);
|
|
|
|
const handleRemovePassword = useCallback((profile: BrowserProfile) => {
|
|
pendingLaunchAfterUnlockRef.current = null;
|
|
setPasswordDialogMode("remove");
|
|
setPasswordDialogProfile(profile);
|
|
}, []);
|
|
|
|
const handleDeleteProfile = useCallback(
|
|
async (profile: BrowserProfile) => {
|
|
console.log("Attempting to delete profile:", profile.name);
|
|
|
|
try {
|
|
// First check if the browser is running for this profile
|
|
const isRunning = await invoke<boolean>("check_browser_status", {
|
|
profile,
|
|
});
|
|
|
|
if (isRunning) {
|
|
showErrorToast(t("errors.cannotDeleteRunningProfile"));
|
|
return;
|
|
}
|
|
|
|
// Attempt to delete the profile
|
|
await invoke("delete_profile", { profileId: profile.id });
|
|
console.log("Profile deletion command completed successfully");
|
|
|
|
// No need to manually reload - useProfileEvents will handle the update
|
|
console.log("Profile deleted successfully");
|
|
} catch (err: unknown) {
|
|
console.error("Failed to delete profile:", err);
|
|
const errorMessage = err instanceof Error ? err.message : String(err);
|
|
showErrorToast(
|
|
t("errors.deleteProfileFailed", { error: errorMessage }),
|
|
);
|
|
}
|
|
},
|
|
[t],
|
|
);
|
|
|
|
const handleRenameProfile = useCallback(
|
|
async (profileId: string, newName: string) => {
|
|
try {
|
|
await invoke("rename_profile", { profileId, newName });
|
|
// No need to manually reload - useProfileEvents will handle the update
|
|
} catch (err: unknown) {
|
|
console.error("Failed to rename profile:", err);
|
|
showErrorToast(
|
|
t("errors.renameProfileFailed", { error: JSON.stringify(err) }),
|
|
);
|
|
throw err;
|
|
}
|
|
},
|
|
[t],
|
|
);
|
|
|
|
const handleKillProfile = useCallback(
|
|
async (profile: BrowserProfile) => {
|
|
console.log("Starting kill for profile:", profile.name);
|
|
|
|
try {
|
|
await invoke("kill_browser_profile", { profile });
|
|
console.log("Successfully killed profile:", profile.name);
|
|
// No need to manually reload - useProfileEvents will handle the update
|
|
} catch (err: unknown) {
|
|
console.error("Failed to kill browser:", err);
|
|
const errorMessage = err instanceof Error ? err.message : String(err);
|
|
showErrorToast(t("errors.killBrowserFailed", { error: errorMessage }));
|
|
// Re-throw the error so the table component can handle loading state cleanup
|
|
throw err;
|
|
}
|
|
},
|
|
[t],
|
|
);
|
|
|
|
const handleDeleteSelectedProfiles = useCallback(
|
|
async (profileIds: string[]) => {
|
|
try {
|
|
await invoke("delete_selected_profiles", { profileIds });
|
|
// No need to manually reload - useProfileEvents will handle the update
|
|
} catch (err: unknown) {
|
|
console.error("Failed to delete selected profiles:", err);
|
|
showErrorToast(
|
|
t("errors.deleteSelectedProfilesFailed", {
|
|
error: JSON.stringify(err),
|
|
}),
|
|
);
|
|
}
|
|
},
|
|
[t],
|
|
);
|
|
|
|
const handleAssignProfilesToGroup = useCallback((profileIds: string[]) => {
|
|
setSelectedProfilesForGroup(profileIds);
|
|
setGroupAssignmentDialogOpen(true);
|
|
}, []);
|
|
|
|
const handleBulkDelete = useCallback(() => {
|
|
if (selectedProfiles.length === 0) return;
|
|
setShowBulkDeleteConfirmation(true);
|
|
}, [selectedProfiles]);
|
|
|
|
const confirmBulkDelete = useCallback(async () => {
|
|
if (selectedProfiles.length === 0) return;
|
|
|
|
setIsBulkDeleting(true);
|
|
try {
|
|
await invoke("delete_selected_profiles", {
|
|
profileIds: selectedProfiles,
|
|
});
|
|
// No need to manually reload - useProfileEvents will handle the update
|
|
setSelectedProfiles([]);
|
|
setShowBulkDeleteConfirmation(false);
|
|
} catch (error) {
|
|
console.error("Failed to delete selected profiles:", error);
|
|
showErrorToast(
|
|
t("errors.deleteSelectedProfilesFailed", {
|
|
error: JSON.stringify(error),
|
|
}),
|
|
);
|
|
} finally {
|
|
setIsBulkDeleting(false);
|
|
}
|
|
}, [selectedProfiles, t]);
|
|
|
|
const handleBulkGroupAssignment = useCallback(() => {
|
|
if (selectedProfiles.length === 0) return;
|
|
handleAssignProfilesToGroup(selectedProfiles);
|
|
setSelectedProfiles([]);
|
|
}, [selectedProfiles, handleAssignProfilesToGroup]);
|
|
|
|
const handleAssignExtensionGroup = useCallback((profileIds: string[]) => {
|
|
setSelectedProfilesForExtensionGroup(profileIds);
|
|
setExtensionGroupAssignmentDialogOpen(true);
|
|
}, []);
|
|
|
|
const handleBulkExtensionGroupAssignment = useCallback(() => {
|
|
if (selectedProfiles.length === 0) return;
|
|
handleAssignExtensionGroup(selectedProfiles);
|
|
setSelectedProfiles([]);
|
|
}, [selectedProfiles, handleAssignExtensionGroup]);
|
|
|
|
const handleExtensionGroupAssignmentComplete = useCallback(() => {
|
|
setExtensionGroupAssignmentDialogOpen(false);
|
|
setSelectedProfilesForExtensionGroup([]);
|
|
}, []);
|
|
|
|
const handleAssignProfilesToProxy = useCallback((profileIds: string[]) => {
|
|
setSelectedProfilesForProxy(profileIds);
|
|
setProxyAssignmentDialogOpen(true);
|
|
}, []);
|
|
|
|
const handleBulkProxyAssignment = useCallback(() => {
|
|
if (selectedProfiles.length === 0) return;
|
|
handleAssignProfilesToProxy(selectedProfiles);
|
|
setSelectedProfiles([]);
|
|
}, [selectedProfiles, handleAssignProfilesToProxy]);
|
|
|
|
const handleBulkCopyCookies = useCallback(() => {
|
|
if (selectedProfiles.length === 0) return;
|
|
const eligibleProfiles = profiles.filter(
|
|
(p) => selectedProfiles.includes(p.id) && p.browser === "wayfern",
|
|
);
|
|
if (eligibleProfiles.length === 0) {
|
|
showErrorToast(t("errors.cookieCopyUnsupportedBrowser"));
|
|
return;
|
|
}
|
|
setSelectedProfilesForCookies(eligibleProfiles.map((p) => p.id));
|
|
setCookieCopyDialogOpen(true);
|
|
}, [selectedProfiles, profiles, t]);
|
|
|
|
const [pendingBulkAction, setPendingBulkAction] = useState<{
|
|
action: "run" | "stop";
|
|
profiles: BrowserProfile[];
|
|
} | null>(null);
|
|
const [isBulkActing, setIsBulkActing] = useState(false);
|
|
|
|
const executeBulkRun = useCallback(
|
|
async (targets: BrowserProfile[]) => {
|
|
setIsBulkActing(true);
|
|
blanketGateDecisionRef.current = null;
|
|
bulkRunIdRef.current += 1;
|
|
const runId = bulkRunIdRef.current;
|
|
try {
|
|
const results = await Promise.allSettled(
|
|
targets.map((p) => launchProfile(p, { bulkRunId: runId })),
|
|
);
|
|
const stopped = results.filter(
|
|
(r) => r.status === "fulfilled" && r.value.status !== "launched",
|
|
).length;
|
|
if (stopped > 0) {
|
|
// Previously a declined launch resolved to undefined, so allSettled
|
|
// reported success and the user was told nothing.
|
|
showErrorToast(
|
|
t("prelaunchGate.cancelledSummary", {
|
|
cancelled: stopped,
|
|
total: targets.length,
|
|
}),
|
|
);
|
|
}
|
|
setSelectedProfiles([]);
|
|
} finally {
|
|
blanketGateDecisionRef.current = null;
|
|
setIsBulkActing(false);
|
|
setPendingBulkAction(null);
|
|
}
|
|
},
|
|
[launchProfile, t],
|
|
);
|
|
|
|
const executeBulkStop = useCallback(
|
|
async (targets: BrowserProfile[]) => {
|
|
setIsBulkActing(true);
|
|
try {
|
|
await Promise.allSettled(targets.map((p) => handleKillProfile(p)));
|
|
setSelectedProfiles([]);
|
|
} finally {
|
|
setIsBulkActing(false);
|
|
setPendingBulkAction(null);
|
|
}
|
|
},
|
|
[handleKillProfile],
|
|
);
|
|
|
|
// Bulk run/stop only touch eligible profiles (run: not already running;
|
|
// stop: currently running). An empty result shows a toast instead of a silent
|
|
// no-op (guard), and 10+ targets require confirmation before launching/stopping.
|
|
const handleBulkRun = useCallback(() => {
|
|
if (selectedProfiles.length === 0) return;
|
|
const targets = profiles.filter(
|
|
(p) => selectedProfiles.includes(p.id) && !runningProfiles.has(p.id),
|
|
);
|
|
if (targets.length === 0) {
|
|
showErrorToast(t("profiles.bulkRun.noneToRun"));
|
|
return;
|
|
}
|
|
if (targets.length >= 10) {
|
|
setPendingBulkAction({ action: "run", profiles: targets });
|
|
return;
|
|
}
|
|
void executeBulkRun(targets);
|
|
}, [selectedProfiles, profiles, runningProfiles, executeBulkRun, t]);
|
|
|
|
const handleBulkStop = useCallback(() => {
|
|
if (selectedProfiles.length === 0) return;
|
|
const targets = profiles.filter(
|
|
(p) => selectedProfiles.includes(p.id) && runningProfiles.has(p.id),
|
|
);
|
|
if (targets.length === 0) {
|
|
showErrorToast(t("profiles.bulkStop.noneToStop"));
|
|
return;
|
|
}
|
|
if (targets.length >= 10) {
|
|
setPendingBulkAction({ action: "stop", profiles: targets });
|
|
return;
|
|
}
|
|
void executeBulkStop(targets);
|
|
}, [selectedProfiles, profiles, runningProfiles, executeBulkStop, t]);
|
|
|
|
const handleCopyCookiesToProfile = useCallback((profile: BrowserProfile) => {
|
|
setSelectedProfilesForCookies([profile.id]);
|
|
setCookieCopyDialogOpen(true);
|
|
}, []);
|
|
|
|
const handleOpenCookieManagement = useCallback((profile: BrowserProfile) => {
|
|
setCurrentProfileForCookieManagement(profile);
|
|
setCookieManagementDialogOpen(true);
|
|
}, []);
|
|
|
|
const handleGroupAssignmentComplete = useCallback(() => {
|
|
// No need to manually reload - useProfileEvents will handle the update
|
|
setGroupAssignmentDialogOpen(false);
|
|
setSelectedProfilesForGroup([]);
|
|
}, []);
|
|
|
|
const handleProxyAssignmentComplete = useCallback(() => {
|
|
// No need to manually reload - useProfileEvents will handle the update
|
|
setProxyAssignmentDialogOpen(false);
|
|
setSelectedProfilesForProxy([]);
|
|
}, []);
|
|
|
|
const handleGroupManagementComplete = useCallback(async () => {
|
|
// No need to manually reload - useProfileEvents will handle the update
|
|
}, []);
|
|
|
|
const handleOpenProfileSyncDialog = useCallback((profile: BrowserProfile) => {
|
|
setCurrentProfileForSync(profile);
|
|
setProfileSyncDialogOpen(true);
|
|
}, []);
|
|
|
|
const handleToggleProfileSync = useCallback(
|
|
async (profile: BrowserProfile) => {
|
|
try {
|
|
const enabling = !profile.sync_mode || profile.sync_mode === "Disabled";
|
|
await invoke("set_profile_sync_mode", {
|
|
profileId: profile.id,
|
|
syncMode: enabling ? "Regular" : "Disabled",
|
|
});
|
|
showSuccessToast(
|
|
t(enabling ? "sync.enabledToast" : "sync.disabledToast"),
|
|
{
|
|
description: t(
|
|
enabling ? "sync.enabledDescription" : "sync.disabledDescription",
|
|
),
|
|
},
|
|
);
|
|
} catch (error) {
|
|
console.error("Failed to toggle sync:", error);
|
|
showErrorToast(t("errors.updateSyncSettingsFailed"));
|
|
}
|
|
},
|
|
[t],
|
|
);
|
|
|
|
useEffect(() => {
|
|
let disposed = false;
|
|
let unlistenStatus: (() => void) | undefined;
|
|
let unlistenProgress: (() => void) | undefined;
|
|
const profilesWithTransfer = new Set<string>();
|
|
void (async () => {
|
|
try {
|
|
unlistenStatus = await listen<{
|
|
profile_id: string;
|
|
status: string;
|
|
error?: string;
|
|
profile_name?: string;
|
|
}>("profile-sync-status", (event) => {
|
|
const { profile_id, status, error, profile_name } = event.payload;
|
|
const toastId = `sync-${profile_id}`;
|
|
const profile = profiles.find((p) => p.id === profile_id);
|
|
const name =
|
|
profile_name || profile?.name || t("common.labels.unknownProfile");
|
|
|
|
if (status === "synced") {
|
|
dismissToast(toastId);
|
|
if (profilesWithTransfer.has(profile_id)) {
|
|
profilesWithTransfer.delete(profile_id);
|
|
showSuccessToast(t("sync.toast.profileSynced", { name }));
|
|
}
|
|
} else if (status === "error") {
|
|
dismissToast(toastId);
|
|
profilesWithTransfer.delete(profile_id);
|
|
showErrorToast(
|
|
error
|
|
? t("sync.toast.profileSyncFailedWithError", { name, error })
|
|
: t("sync.toast.profileSyncFailed", { name }),
|
|
);
|
|
}
|
|
});
|
|
|
|
unlistenProgress = await listen<{
|
|
profile_id: string;
|
|
phase: string;
|
|
total_files?: number;
|
|
total_bytes?: number;
|
|
completed_files?: number;
|
|
completed_bytes?: number;
|
|
speed_bytes_per_sec?: number;
|
|
eta_seconds?: number;
|
|
failed_count?: number;
|
|
profile_name?: string;
|
|
}>("profile-sync-progress", (event) => {
|
|
const payload = event.payload;
|
|
const toastId = `sync-${payload.profile_id}`;
|
|
const profile = profiles.find((p) => p.id === payload.profile_id);
|
|
const name =
|
|
payload.profile_name ||
|
|
profile?.name ||
|
|
t("common.labels.unknownProfile");
|
|
|
|
if (
|
|
payload.phase === "started" ||
|
|
payload.phase === "uploading" ||
|
|
payload.phase === "downloading"
|
|
) {
|
|
profilesWithTransfer.add(payload.profile_id);
|
|
showSyncProgressToast(
|
|
name,
|
|
{
|
|
completed_files: payload.completed_files ?? 0,
|
|
total_files: payload.total_files ?? 0,
|
|
completed_bytes: payload.completed_bytes ?? 0,
|
|
total_bytes: payload.total_bytes ?? 0,
|
|
speed_bytes_per_sec: payload.speed_bytes_per_sec ?? 0,
|
|
eta_seconds: payload.eta_seconds ?? 0,
|
|
failed_count: payload.failed_count ?? 0,
|
|
phase: payload.phase,
|
|
},
|
|
{ id: toastId, profileId: payload.profile_id },
|
|
);
|
|
}
|
|
});
|
|
// 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. 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;
|
|
let disposed = false;
|
|
void listenForUrlEvents().then((cleanupFn) => {
|
|
if (disposed) {
|
|
cleanupFn?.();
|
|
return;
|
|
}
|
|
cleanup = cleanupFn;
|
|
});
|
|
|
|
// Check for startup URLs (when app was launched as default browser)
|
|
void checkCurrentUrl();
|
|
|
|
// Set up periodic update checks (every 30 minutes)
|
|
const updateInterval = setInterval(
|
|
() => {
|
|
void checkForUpdates();
|
|
},
|
|
30 * 60 * 1000,
|
|
);
|
|
|
|
// Check for missing binaries after initial profile load
|
|
if (!profilesLoading && profiles.length > 0) {
|
|
void checkMissingBinaries();
|
|
}
|
|
|
|
// Proactively download Wayfern if not already available
|
|
if (!profilesLoading) {
|
|
void invoke("ensure_active_browsers_downloaded").catch((err: unknown) => {
|
|
console.error("Failed to auto-download browsers:", err);
|
|
});
|
|
}
|
|
|
|
return () => {
|
|
disposed = true;
|
|
clearInterval(updateInterval);
|
|
cleanup?.();
|
|
};
|
|
}, [
|
|
checkForUpdates,
|
|
listenForUrlEvents,
|
|
checkCurrentUrl,
|
|
checkMissingBinaries,
|
|
profilesLoading,
|
|
profiles.length,
|
|
]);
|
|
|
|
// 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;
|
|
let unlistenCompleted: (() => void) | undefined;
|
|
let unlistenWayfernBlocked: (() => void) | undefined;
|
|
|
|
void (async () => {
|
|
unlistenRequired = await listen(
|
|
"profile-sync-e2e-password-required",
|
|
() => {
|
|
showToast({
|
|
id: "e2e-password-required",
|
|
type: "error",
|
|
title: t("encryption.required.title"),
|
|
description: t("encryption.required.description"),
|
|
duration: 12000,
|
|
action: {
|
|
label: t("encryption.required.openSettings"),
|
|
onClick: () => {
|
|
setSettingsDialogOpen(true);
|
|
setCurrentPage("settings");
|
|
},
|
|
},
|
|
});
|
|
},
|
|
);
|
|
|
|
unlistenStarted = await listen("e2e-rollover-started", () => {
|
|
showToast({
|
|
id: "e2e-rollover",
|
|
type: "loading",
|
|
title: t("encryption.rollover.startedTitle"),
|
|
description: t("encryption.rollover.startedDescription"),
|
|
duration: Number.POSITIVE_INFINITY,
|
|
});
|
|
});
|
|
|
|
unlistenProgress = await listen<{
|
|
stage: string;
|
|
done: number;
|
|
total: number;
|
|
}>("e2e-rollover-progress", (event) => {
|
|
const { stage, done, total } = event.payload;
|
|
showToast({
|
|
id: "e2e-rollover",
|
|
type: "loading",
|
|
title: t("encryption.rollover.progressTitle", {
|
|
stage: t(`encryption.rollover.stage.${stage}`),
|
|
}),
|
|
description: t("encryption.rollover.progressDescription", {
|
|
done,
|
|
total,
|
|
}),
|
|
duration: Number.POSITIVE_INFINITY,
|
|
});
|
|
});
|
|
|
|
unlistenCompleted = await listen("e2e-rollover-completed", () => {
|
|
showToast({
|
|
id: "e2e-rollover",
|
|
type: "success",
|
|
title: t("encryption.rollover.completedTitle"),
|
|
description: t("encryption.rollover.completedDescription"),
|
|
duration: 5000,
|
|
});
|
|
});
|
|
|
|
unlistenWayfernBlocked = await listen("wayfern-paid-blocked", () => {
|
|
showToast({
|
|
id: "wayfern-paid-blocked",
|
|
type: "error",
|
|
title: t("wayfernBlocked.title"),
|
|
description: t("wayfernBlocked.description"),
|
|
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?.();
|
|
unlistenCompleted?.();
|
|
unlistenWayfernBlocked?.();
|
|
};
|
|
}, [t]);
|
|
|
|
// Re-check Wayfern terms when a browser download completes
|
|
useEffect(() => {
|
|
let unlisten: (() => void) | null = null;
|
|
const setup = async () => {
|
|
unlisten = await listen<{ stage: string }>(
|
|
"download-progress",
|
|
(event) => {
|
|
if (event.payload.stage === "completed") {
|
|
void checkTerms();
|
|
}
|
|
},
|
|
);
|
|
};
|
|
void setup();
|
|
return () => {
|
|
if (unlisten) unlisten();
|
|
};
|
|
}, [checkTerms]);
|
|
|
|
// Check permissions when they are initialized. During first-run onboarding
|
|
// the welcome flow requests permissions, so the standalone dialog is deferred
|
|
// until we know this isn't a first-run onboarding.
|
|
useEffect(() => {
|
|
if (isInitialized && firstRunOnboarding === false) {
|
|
checkAllPermissions();
|
|
}
|
|
}, [isInitialized, firstRunOnboarding, checkAllPermissions]);
|
|
|
|
// Check self-hosted sync config on mount and when cloud user changes
|
|
useEffect(() => {
|
|
void checkSelfHostedSync();
|
|
}, [checkSelfHostedSync]);
|
|
|
|
// A profile stores ids, and the query asks about names, so the matcher is
|
|
// handed the resolution up front. Built off the entity lists rather than off
|
|
// `profiles`, because the alternative — a .find() per row per term — is
|
|
// O(profiles x entities) on every single keystroke.
|
|
const searchContext = useMemo<ProfileSearchContext>(
|
|
() => ({
|
|
groupNames: new Map(groupsData.map((g) => [g.id, g.name])),
|
|
proxyNames: new Map(storedProxies.map((p) => [p.id, p.name])),
|
|
vpnNames: new Map(vpnConfigs.map((v) => [v.id, v.name])),
|
|
extensionGroupNames: new Map(extensionGroups.map((e) => [e.id, e.name])),
|
|
runningProfiles,
|
|
}),
|
|
[groupsData, storedProxies, vpnConfigs, extensionGroups, runningProfiles],
|
|
);
|
|
|
|
// Filter data by selected group and search query. The two are independent
|
|
// controls and both apply: the rail narrows to a group, the query narrows
|
|
// within whatever the rail left.
|
|
const filteredProfiles = useMemo(() => {
|
|
// "__all__" is a virtual filter that shows every profile (including
|
|
// ungrouped ones). Any other value is a real group id; ungrouped profiles
|
|
// only show through "All".
|
|
const inGroup =
|
|
!selectedGroupId || selectedGroupId === "__all__"
|
|
? profiles
|
|
: profiles.filter((profile) => profile.group_id === selectedGroupId);
|
|
|
|
const parsed = parseProfileSearch(searchQuery);
|
|
if (parsed.isEmpty) return inGroup;
|
|
return inGroup.filter((profile) =>
|
|
matchesProfile(profile, parsed, searchContext),
|
|
);
|
|
}, [profiles, selectedGroupId, searchQuery, searchContext]);
|
|
|
|
// Update loading states
|
|
const isLoading = profilesLoading || groupsLoading || proxiesLoading;
|
|
|
|
const subPageTitle =
|
|
currentPage === "profiles"
|
|
? undefined
|
|
: currentPage === "import"
|
|
? t("pageTitle.import")
|
|
: t(`pageTitle.${currentPage}`);
|
|
|
|
return (
|
|
<div className="flex h-dvh flex-col bg-background font-(family-name:--font-geist-sans)">
|
|
<CloseConfirmDialog />
|
|
<HomeHeader
|
|
onCreateProfileDialogOpen={setCreateProfileDialogOpen}
|
|
searchQuery={searchQuery}
|
|
onSearchQueryChange={setSearchQuery}
|
|
groups={groupsData}
|
|
totalProfiles={profiles.length}
|
|
selectedGroupId={selectedGroupId}
|
|
onGroupSelect={handleSelectGroup}
|
|
pageTitle={subPageTitle}
|
|
/>
|
|
<div className="flex min-h-0 flex-1">
|
|
<RailNav
|
|
currentPage={currentPage}
|
|
onNavigate={handleRailNavigate}
|
|
onOpenAbout={() => {
|
|
setAboutDialogOpen(true);
|
|
}}
|
|
cookieBotRunning={Object.keys(cookieBotLiveSessions).length > 0}
|
|
/>
|
|
<main className="flex min-w-0 flex-1 flex-col overflow-hidden">
|
|
{currentPage === "profiles" && (
|
|
<motion.div
|
|
initial={{ opacity: 0, y: 8 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ duration: 0.25, ease: MOTION_EASE_OUT }}
|
|
className="flex min-h-0 flex-1 flex-col px-3 pt-2.5"
|
|
>
|
|
<ProfilesDataTable
|
|
isLoading={isLoading && profiles.length === 0}
|
|
showOnboardingEmptyState={profiles.length === 0}
|
|
profiles={filteredProfiles}
|
|
infoDialogProfile={profileInfoDialog}
|
|
onInfoDialogProfileChange={setProfileInfoDialog}
|
|
onLaunchProfile={launchProfile}
|
|
onKillProfile={handleKillProfile}
|
|
onCloneProfile={handleCloneProfile}
|
|
onSetPassword={handleSetPassword}
|
|
onChangePassword={handleChangePassword}
|
|
onRemovePassword={handleRemovePassword}
|
|
onDeleteProfile={handleDeleteProfile}
|
|
onRenameProfile={handleRenameProfile}
|
|
onConfigureWayfern={handleConfigureWayfern}
|
|
onCopyCookiesToProfile={handleCopyCookiesToProfile}
|
|
onOpenCookieManagement={handleOpenCookieManagement}
|
|
runningProfiles={runningProfiles}
|
|
extensionGroups={extensionGroups}
|
|
isUpdating={isUpdating}
|
|
onDeleteSelectedProfiles={handleDeleteSelectedProfiles}
|
|
onAssignProfilesToGroup={handleAssignProfilesToGroup}
|
|
onAssignProfilesToProxy={handleAssignProfilesToProxy}
|
|
selectedGroupId={selectedGroupId}
|
|
selectedProfiles={selectedProfiles}
|
|
onSelectedProfilesChange={setSelectedProfiles}
|
|
onBulkDelete={handleBulkDelete}
|
|
onBulkGroupAssignment={handleBulkGroupAssignment}
|
|
onBulkProxyAssignment={handleBulkProxyAssignment}
|
|
onBulkCopyCookies={handleBulkCopyCookies}
|
|
onBulkRun={handleBulkRun}
|
|
onBulkStop={handleBulkStop}
|
|
bulkActionsUnlocked={automationUnlocked}
|
|
onBulkExtensionGroupAssignment={
|
|
handleBulkExtensionGroupAssignment
|
|
}
|
|
onAssignExtensionGroup={handleAssignExtensionGroup}
|
|
onOpenProfileSyncDialog={handleOpenProfileSyncDialog}
|
|
onToggleProfileSync={handleToggleProfileSync}
|
|
crossOsUnlocked={crossOsUnlocked}
|
|
syncUnlocked={syncUnlocked}
|
|
getProfileSyncInfo={getProfileSyncInfo}
|
|
onLaunchWithSync={(profile) => {
|
|
setSyncLeaderProfile(profile);
|
|
}}
|
|
onCreateProfile={() => {
|
|
setCreateProfileDialogOpen(true);
|
|
}}
|
|
onImportProfiles={() => {
|
|
handleRailNavigate("import");
|
|
}}
|
|
/>
|
|
</motion.div>
|
|
)}
|
|
|
|
{currentPage === "shortcuts" && (
|
|
<motion.div
|
|
initial={{ opacity: 0, y: 8 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ duration: 0.25, ease: MOTION_EASE_OUT }}
|
|
className="flex min-h-0 flex-1 flex-col"
|
|
>
|
|
<ShortcutsPage groupTargets={orderedGroupTargets} />
|
|
</motion.div>
|
|
)}
|
|
|
|
{settingsDialogOpen && (
|
|
<SettingsDialog
|
|
isOpen={settingsDialogOpen}
|
|
onClose={() => {
|
|
setSettingsDialogOpen(false);
|
|
setCurrentPage("profiles");
|
|
}}
|
|
onIntegrationsOpen={() => {
|
|
setSettingsDialogOpen(false);
|
|
setIntegrationsDialogOpen(true);
|
|
setCurrentPage("integrations");
|
|
}}
|
|
subPage={currentPage === "settings"}
|
|
/>
|
|
)}
|
|
|
|
{integrationsDialogOpen && (
|
|
<IntegrationsDialog
|
|
isOpen={integrationsDialogOpen}
|
|
onClose={() => {
|
|
setIntegrationsDialogOpen(false);
|
|
setCurrentPage("profiles");
|
|
}}
|
|
subPage={currentPage === "integrations"}
|
|
initialTab={integrationsInitialTab}
|
|
/>
|
|
)}
|
|
|
|
{proxyManagementDialogOpen && (
|
|
<ProxyManagementDialog
|
|
isOpen={proxyManagementDialogOpen}
|
|
onClose={() => {
|
|
setProxyManagementDialogOpen(false);
|
|
setCurrentPage("profiles");
|
|
}}
|
|
subPage={currentPage === "proxies" || currentPage === "vpns"}
|
|
initialTab={proxyManagementInitialTab}
|
|
/>
|
|
)}
|
|
|
|
{groupManagementDialogOpen && (
|
|
<GroupManagementDialog
|
|
isOpen={groupManagementDialogOpen}
|
|
onClose={() => {
|
|
setGroupManagementDialogOpen(false);
|
|
setCurrentPage("profiles");
|
|
}}
|
|
onGroupManagementComplete={handleGroupManagementComplete}
|
|
subPage={currentPage === "groups"}
|
|
/>
|
|
)}
|
|
|
|
{extensionManagementDialogOpen && (
|
|
<ExtensionManagementDialog
|
|
isOpen={extensionManagementDialogOpen}
|
|
onClose={() => {
|
|
setExtensionManagementDialogOpen(false);
|
|
setCurrentPage("profiles");
|
|
}}
|
|
limitedMode={false}
|
|
subPage={currentPage === "extensions"}
|
|
initialTab={extensionManagementInitialTab}
|
|
/>
|
|
)}
|
|
|
|
{importProfileDialogOpen && (
|
|
<ImportProfileDialog
|
|
isOpen={importProfileDialogOpen}
|
|
onClose={() => {
|
|
setImportProfileDialogOpen(false);
|
|
setCurrentPage("profiles");
|
|
}}
|
|
crossOsUnlocked={crossOsUnlocked}
|
|
subPage={currentPage === "import"}
|
|
/>
|
|
)}
|
|
|
|
{cookieBotDialogOpen && (
|
|
<CookieBotPage
|
|
isOpen={cookieBotDialogOpen}
|
|
onClose={() => {
|
|
setCookieBotDialogOpen(false);
|
|
setCurrentPage("profiles");
|
|
}}
|
|
subPage={currentPage === "cookieBot"}
|
|
initialTab={cookieBotInitialTab}
|
|
profiles={profiles}
|
|
cloudUser={cloudUser}
|
|
onOpenProfileSync={handleOpenProfileSyncDialog}
|
|
onAssignProxy={handleAssignProfilesToProxy}
|
|
/>
|
|
)}
|
|
|
|
{accountDialogOpen && (
|
|
<AccountPage
|
|
isOpen={accountDialogOpen}
|
|
onClose={() => {
|
|
setAccountDialogOpen(false);
|
|
setCurrentPage("profiles");
|
|
}}
|
|
subPage={currentPage === "account"}
|
|
onOpenSignIn={() => {
|
|
setAccountDialogOpen(false);
|
|
setCurrentPage("profiles");
|
|
setDeviceCodeDialogOpen(true);
|
|
}}
|
|
/>
|
|
)}
|
|
</main>
|
|
</div>
|
|
|
|
<CreateProfileDialog
|
|
isOpen={createProfileDialogOpen}
|
|
onClose={() => {
|
|
setCreateProfileDialogOpen(false);
|
|
}}
|
|
onCreateProfile={handleCreateProfile}
|
|
selectedGroupId={selectedGroupId}
|
|
crossOsUnlocked={crossOsUnlocked}
|
|
/>
|
|
|
|
<CommandPalette
|
|
open={commandPaletteOpen}
|
|
onOpenChange={setCommandPaletteOpen}
|
|
onAction={runShortcut}
|
|
groupTargets={orderedGroupTargets}
|
|
onSelectGroup={(id) => {
|
|
handleRailNavigate("profiles");
|
|
handleSelectGroup(id);
|
|
}}
|
|
profiles={profiles}
|
|
runningProfileIds={runningProfiles}
|
|
onLaunchProfile={(profile) => {
|
|
void launchProfile(profile);
|
|
}}
|
|
onKillProfile={(profile) => {
|
|
void handleKillProfile(profile);
|
|
}}
|
|
onShowProfileInfo={(profile) => {
|
|
handleRailNavigate("profiles");
|
|
setProfileInfoDialog(profile);
|
|
}}
|
|
onCreateProfile={() => {
|
|
setCreateProfileDialogOpen(true);
|
|
}}
|
|
onOpenAbout={() => {
|
|
setAboutDialogOpen(true);
|
|
}}
|
|
/>
|
|
|
|
<AboutDialog
|
|
isOpen={aboutDialogOpen}
|
|
onClose={() => {
|
|
setAboutDialogOpen(false);
|
|
}}
|
|
/>
|
|
|
|
<PreLaunchGateDialog
|
|
isOpen={gateState !== null}
|
|
profileName={gateState?.req.profile.name ?? ""}
|
|
profileId={gateState?.req.profile.id ?? ""}
|
|
requestId={gateState?.id ?? 0}
|
|
findings={gateState?.req.findings ?? null}
|
|
remainingCount={gateState?.remaining ?? 0}
|
|
onResult={settleGate}
|
|
/>
|
|
|
|
{pendingUrls.map((pendingUrl) => (
|
|
<ProfileSelectorDialog
|
|
key={pendingUrl.id}
|
|
isOpen={true}
|
|
onClose={() => {
|
|
setPendingUrls((prev) =>
|
|
prev.filter((u) => u.id !== pendingUrl.id),
|
|
);
|
|
}}
|
|
url={pendingUrl.url}
|
|
isUpdating={isUpdating}
|
|
runningProfiles={runningProfiles}
|
|
/>
|
|
))}
|
|
|
|
<PermissionDialog
|
|
isOpen={permissionDialogOpen}
|
|
onClose={() => {
|
|
setPermissionDialogOpen(false);
|
|
}}
|
|
permissionType={currentPermissionType}
|
|
onPermissionGranted={checkNextPermission}
|
|
/>
|
|
|
|
<WelcomeDialog
|
|
isOpen={welcomeOpen}
|
|
needsSetup={profiles.length === 0}
|
|
onComplete={handleWelcomeComplete}
|
|
/>
|
|
<ThankYouDialog
|
|
isOpen={thankYouOpen}
|
|
onClose={() => setThankYouOpen(false)}
|
|
/>
|
|
|
|
<CloneProfileDialog
|
|
isOpen={!!cloneProfile}
|
|
onClose={() => {
|
|
setCloneProfile(null);
|
|
}}
|
|
profile={cloneProfile}
|
|
/>
|
|
|
|
<ProfilePasswordDialog
|
|
isOpen={!!passwordDialogProfile}
|
|
onClose={() => {
|
|
pendingLaunchAfterUnlockRef.current = null;
|
|
setPasswordDialogProfile(null);
|
|
}}
|
|
profile={passwordDialogProfile}
|
|
mode={passwordDialogMode}
|
|
onSuccess={(p) => {
|
|
// Resume pending launch after unlock.
|
|
if (
|
|
passwordDialogMode === "unlock" &&
|
|
pendingLaunchAfterUnlockRef.current?.id === p.id
|
|
) {
|
|
const target = pendingLaunchAfterUnlockRef.current;
|
|
pendingLaunchAfterUnlockRef.current = null;
|
|
void launchProfile(target);
|
|
}
|
|
// On set/change/remove, the profile's encryption state changed.
|
|
// Push that state to the sync server immediately so other devices
|
|
// see the new envelope before they next pull. Skip if the profile
|
|
// is currently running — its files would be in flux.
|
|
if (
|
|
(passwordDialogMode === "set" ||
|
|
passwordDialogMode === "change" ||
|
|
passwordDialogMode === "remove") &&
|
|
!runningProfiles.has(p.id) &&
|
|
p.sync_mode !== "Disabled"
|
|
) {
|
|
void invoke("request_profile_sync", { profileId: p.id }).catch(
|
|
(err: unknown) => {
|
|
console.error("post-password sync failed", err);
|
|
},
|
|
);
|
|
}
|
|
}}
|
|
/>
|
|
|
|
<WayfernConfigDialog
|
|
isOpen={wayfernConfigDialogOpen}
|
|
onClose={() => {
|
|
setWayfernConfigDialogOpen(false);
|
|
}}
|
|
profile={currentProfileForWayfernConfig}
|
|
onSave={handleSaveWayfernConfig}
|
|
isRunning={
|
|
currentProfileForWayfernConfig
|
|
? runningProfiles.has(currentProfileForWayfernConfig.id)
|
|
: false
|
|
}
|
|
crossOsUnlocked={crossOsUnlocked}
|
|
/>
|
|
|
|
<GroupAssignmentDialog
|
|
isOpen={groupAssignmentDialogOpen}
|
|
onClose={() => {
|
|
setGroupAssignmentDialogOpen(false);
|
|
}}
|
|
selectedProfiles={selectedProfilesForGroup}
|
|
onAssignmentComplete={handleGroupAssignmentComplete}
|
|
profiles={profiles}
|
|
/>
|
|
|
|
<ExtensionGroupAssignmentDialog
|
|
isOpen={extensionGroupAssignmentDialogOpen}
|
|
onClose={() => {
|
|
setExtensionGroupAssignmentDialogOpen(false);
|
|
}}
|
|
selectedProfiles={selectedProfilesForExtensionGroup}
|
|
onAssignmentComplete={handleExtensionGroupAssignmentComplete}
|
|
profiles={profiles}
|
|
/>
|
|
|
|
<ProxyAssignmentDialog
|
|
isOpen={proxyAssignmentDialogOpen}
|
|
onClose={() => {
|
|
setProxyAssignmentDialogOpen(false);
|
|
}}
|
|
selectedProfiles={selectedProfilesForProxy}
|
|
onAssignmentComplete={handleProxyAssignmentComplete}
|
|
profiles={profiles}
|
|
storedProxies={storedProxies}
|
|
vpnConfigs={vpnConfigs}
|
|
/>
|
|
|
|
<CookieCopyDialog
|
|
isOpen={cookieCopyDialogOpen}
|
|
onClose={() => {
|
|
setCookieCopyDialogOpen(false);
|
|
setSelectedProfilesForCookies([]);
|
|
}}
|
|
selectedProfiles={selectedProfilesForCookies}
|
|
profiles={profiles}
|
|
runningProfiles={runningProfiles}
|
|
onCopyComplete={() => {
|
|
setSelectedProfilesForCookies([]);
|
|
}}
|
|
/>
|
|
|
|
<CookieManagementDialog
|
|
isOpen={cookieManagementDialogOpen}
|
|
onClose={() => {
|
|
setCookieManagementDialogOpen(false);
|
|
setCurrentProfileForCookieManagement(null);
|
|
}}
|
|
profile={currentProfileForCookieManagement}
|
|
/>
|
|
|
|
<DeleteConfirmationDialog
|
|
isOpen={pendingBulkAction !== null}
|
|
onClose={() => {
|
|
setPendingBulkAction(null);
|
|
}}
|
|
onConfirm={() => {
|
|
if (!pendingBulkAction) return;
|
|
if (pendingBulkAction.action === "run") {
|
|
void executeBulkRun(pendingBulkAction.profiles);
|
|
} else {
|
|
void executeBulkStop(pendingBulkAction.profiles);
|
|
}
|
|
}}
|
|
title={
|
|
pendingBulkAction?.action === "stop"
|
|
? t("profiles.bulkStop.confirmTitle", {
|
|
count: pendingBulkAction?.profiles.length ?? 0,
|
|
})
|
|
: t("profiles.bulkRun.confirmTitle", {
|
|
count: pendingBulkAction?.profiles.length ?? 0,
|
|
})
|
|
}
|
|
description={
|
|
pendingBulkAction?.action === "stop"
|
|
? t("profiles.bulkStop.confirmDescription", {
|
|
count: pendingBulkAction?.profiles.length ?? 0,
|
|
})
|
|
: t("profiles.bulkRun.confirmDescription", {
|
|
count: pendingBulkAction?.profiles.length ?? 0,
|
|
})
|
|
}
|
|
confirmButtonText={
|
|
pendingBulkAction?.action === "stop"
|
|
? t("profiles.bulkStop.confirmButton", {
|
|
count: pendingBulkAction?.profiles.length ?? 0,
|
|
})
|
|
: t("profiles.bulkRun.confirmButton", {
|
|
count: pendingBulkAction?.profiles.length ?? 0,
|
|
})
|
|
}
|
|
confirmButtonVariant="default"
|
|
isLoading={isBulkActing}
|
|
/>
|
|
<DeleteConfirmationDialog
|
|
isOpen={showBulkDeleteConfirmation}
|
|
onClose={() => {
|
|
setShowBulkDeleteConfirmation(false);
|
|
}}
|
|
onConfirm={confirmBulkDelete}
|
|
title={t("profiles.bulkDelete.title")}
|
|
description={t("profiles.bulkDelete.description", {
|
|
count: selectedProfiles.length,
|
|
})}
|
|
confirmButtonText={t("profiles.bulkDelete.confirmButton", {
|
|
count: selectedProfiles.length,
|
|
})}
|
|
isLoading={isBulkDeleting}
|
|
profileIds={selectedProfiles}
|
|
profiles={profiles.map((p) => ({ id: p.id, name: p.name }))}
|
|
/>
|
|
|
|
<SyncConfigDialog
|
|
isOpen={syncConfigDialogOpen}
|
|
onClose={(loginOccurred) => {
|
|
setSyncConfigDialogOpen(false);
|
|
void checkSelfHostedSync();
|
|
if (loginOccurred) {
|
|
setSyncAllDialogOpen(true);
|
|
}
|
|
}}
|
|
onLoginStarted={() => {
|
|
// Hand the verify step off to its own dialog. We close this one
|
|
// first so the verify dialog isn't stacked on top of it (and
|
|
// can't end up stacked on top of the profile selector either).
|
|
setSyncConfigDialogOpen(false);
|
|
setDeviceCodeDialogOpen(true);
|
|
}}
|
|
/>
|
|
|
|
{/* Only render while no profile-selector flow is in progress, so the
|
|
verify dialog never lands on top of a deep-link-triggered selector. */}
|
|
{pendingUrls.length === 0 && (
|
|
<DeviceCodeVerifyDialog
|
|
isOpen={deviceCodeDialogOpen}
|
|
onClose={(loginOccurred) => {
|
|
setDeviceCodeDialogOpen(false);
|
|
if (loginOccurred) {
|
|
setSyncAllDialogOpen(true);
|
|
}
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
<SyncAllDialog
|
|
isOpen={syncAllDialogOpen}
|
|
onClose={() => {
|
|
setSyncAllDialogOpen(false);
|
|
}}
|
|
/>
|
|
|
|
<ProfileSyncDialog
|
|
isOpen={profileSyncDialogOpen}
|
|
onClose={() => {
|
|
setProfileSyncDialogOpen(false);
|
|
setCurrentProfileForSync(null);
|
|
}}
|
|
profile={currentProfileForSync}
|
|
onSyncConfigOpen={() => {
|
|
setSyncConfigDialogOpen(true);
|
|
}}
|
|
/>
|
|
|
|
{/* Wayfern Terms and Conditions Dialog - shown if terms not accepted */}
|
|
<WayfernTermsDialog
|
|
isOpen={!termsLoading && termsAccepted === false}
|
|
onAccepted={checkTerms}
|
|
/>
|
|
|
|
{/* Commercial Trial Modal - shown once when trial expires (skip for paid users) */}
|
|
<CommercialTrialModal
|
|
isOpen={
|
|
!termsLoading &&
|
|
termsAccepted === true &&
|
|
trialStatus?.type === "Expired" &&
|
|
!trialAcknowledged &&
|
|
!crossOsUnlocked
|
|
}
|
|
onClose={checkTrialStatus}
|
|
/>
|
|
|
|
<WindowResizeWarningDialog
|
|
isOpen={windowResizeWarningOpen}
|
|
onResult={(proceed) => {
|
|
setWindowResizeWarningOpen(false);
|
|
windowResizeWarningResolver.current?.(proceed);
|
|
windowResizeWarningResolver.current = null;
|
|
}}
|
|
/>
|
|
|
|
<SyncFollowerDialog
|
|
isOpen={syncLeaderProfile !== null}
|
|
onClose={() => {
|
|
setSyncLeaderProfile(null);
|
|
}}
|
|
leaderProfile={syncLeaderProfile}
|
|
allProfiles={profiles}
|
|
runningProfiles={runningProfiles}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|