This commit is contained in:
zhom
2025-05-29 10:17:16 +04:00
commit 08678dcacc
154 changed files with 29456 additions and 0 deletions
+35
View File
@@ -0,0 +1,35 @@
"use client";
import { Geist, Geist_Mono } from "next/font/google";
import "@/styles/globals.css";
import { CustomThemeProvider } from "@/components/theme-provider";
import { Toaster } from "@/components/ui/sonner";
import { TooltipProvider } from "@/components/ui/tooltip";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en" suppressHydrationWarning>
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
<CustomThemeProvider>
<TooltipProvider>{children}</TooltipProvider>
<Toaster />
</CustomThemeProvider>
</body>
</html>
);
}
+563
View File
@@ -0,0 +1,563 @@
"use client";
import { ChangeVersionDialog } from "@/components/change-version-dialog";
import { CreateProfileDialog } from "@/components/create-profile-dialog";
import { ProfilesDataTable } from "@/components/profile-data-table";
import { ProfileSelectorDialog } from "@/components/profile-selector-dialog";
import { ProxySettingsDialog } from "@/components/proxy-settings-dialog";
import { SettingsDialog } from "@/components/settings-dialog";
import { useUpdateNotifications } from "@/components/update-notification";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import type { BrowserProfile, ProxySettings } from "@/types";
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { useCallback, useEffect, useRef, useState } from "react";
import { GoGear, GoPlus } from "react-icons/go";
import { showErrorToast } from "@/components/custom-toast";
type BrowserTypeString =
| "mullvad-browser"
| "firefox"
| "firefox-developer"
| "chromium"
| "brave"
| "zen"
| "tor-browser";
interface PendingUrl {
id: string;
url: string;
}
export default function Home() {
const [profiles, setProfiles] = useState<BrowserProfile[]>([]);
const [error, setError] = useState<string | null>(null);
const [proxyDialogOpen, setProxyDialogOpen] = useState(false);
const [createProfileDialogOpen, setCreateProfileDialogOpen] = useState(false);
const [changeVersionDialogOpen, setChangeVersionDialogOpen] = useState(false);
const [settingsDialogOpen, setSettingsDialogOpen] = useState(false);
const [pendingUrls, setPendingUrls] = useState<PendingUrl[]>([]);
const [currentProfileForProxy, setCurrentProfileForProxy] =
useState<BrowserProfile | null>(null);
const [currentProfileForVersionChange, setCurrentProfileForVersionChange] =
useState<BrowserProfile | null>(null);
const [isClient, setIsClient] = useState(false);
// Auto-update functionality - only initialize on client
const updateNotifications = useUpdateNotifications();
const { checkForUpdates, isUpdating } = updateNotifications;
// Ensure we're on the client side to prevent hydration mismatches
useEffect(() => {
setIsClient(true);
}, []);
const loadProfiles = useCallback(async () => {
if (!isClient) return; // Only run on client side
try {
const profileList = await invoke<BrowserProfile[]>(
"list_browser_profiles"
);
setProfiles(profileList);
// Check for updates after loading profiles
await checkForUpdates();
} catch (err: unknown) {
console.error("Failed to load profiles:", err);
setError(`Failed to load profiles: ${JSON.stringify(err)}`);
}
}, [checkForUpdates, isClient]);
useEffect(() => {
if (!isClient) return; // Only run on client side
void loadProfiles();
// Check for startup default browser prompt
void checkStartupPrompt();
// Listen for URL open events
void listenForUrlEvents();
// Check for startup URLs (when app was launched as default browser)
void checkStartupUrls();
// Set up periodic update checks (every 30 minutes)
const updateInterval = setInterval(() => {
void checkForUpdates();
}, 30 * 60 * 1000);
return () => {
clearInterval(updateInterval);
};
}, [loadProfiles, checkForUpdates, isClient]);
const checkStartupPrompt = async () => {
if (!isClient) return; // Only run on client side
try {
const shouldShow = await invoke<boolean>(
"should_show_settings_on_startup"
);
if (shouldShow) {
setSettingsDialogOpen(true);
}
} catch (error) {
console.error("Failed to check startup prompt:", error);
}
};
const checkStartupUrls = async () => {
if (!isClient) return; // Only run on client side
try {
const hasStartupUrl = await invoke<boolean>(
"check_and_handle_startup_url"
);
if (hasStartupUrl) {
console.log("Handled startup URL successfully");
}
} catch (error) {
console.error("Failed to check startup URLs:", error);
}
};
const listenForUrlEvents = async () => {
if (!isClient) return; // Only run on client side
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);
void 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);
setPendingUrls((prev) => [
...prev,
{ id: Date.now().toString(), url: 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
);
setError(
"No profiles available. Please create a profile first before opening URLs."
);
setCreateProfileDialogOpen(true);
});
} catch (error) {
console.error("Failed to setup URL listener:", error);
}
};
const handleUrlOpen = async (url: string) => {
if (!isClient) return; // Only run on client side
try {
// Use smart profile selection
const result = await invoke<string>("smart_open_url", {
url,
});
console.log("Smart URL opening succeeded:", result);
// URL was handled successfully
} catch (error: any) {
console.log(
"Smart URL opening failed or requires profile selection:",
error
);
// Check if it's the special error cases
if (error === "show_selector") {
// Show profile selector
setPendingUrls((prev) => [...prev, { id: Date.now().toString(), url }]);
} else if (error === "no_profiles") {
// No profiles available, show error message
setError(
"No profiles available. Please create a profile first before opening URLs."
);
} else {
// Some other error occurred
console.error("Failed to open URL:", error);
setError(`Failed to open URL: ${error}`);
}
}
};
const openProxyDialog = useCallback((profile: BrowserProfile | null) => {
setCurrentProfileForProxy(profile);
setProxyDialogOpen(true);
}, []);
const openChangeVersionDialog = useCallback((profile: BrowserProfile) => {
setCurrentProfileForVersionChange(profile);
setChangeVersionDialogOpen(true);
}, []);
const handleSaveProxy = useCallback(
async (proxySettings: ProxySettings) => {
setProxyDialogOpen(false);
setError(null);
try {
if (currentProfileForProxy) {
await invoke("update_profile_proxy", {
profileName: currentProfileForProxy.name,
proxy: proxySettings,
});
}
await loadProfiles();
} catch (err: unknown) {
console.error("Failed to update proxy settings:", err);
setError(`Failed to update proxy settings: ${JSON.stringify(err)}`);
}
},
[currentProfileForProxy, loadProfiles]
);
const handleCreateProfile = useCallback(
async (profileData: {
name: string;
browserStr: BrowserTypeString;
version: string;
proxy?: ProxySettings;
}) => {
setError(null);
try {
const profile = await invoke<BrowserProfile>(
"create_browser_profile_new",
{
name: profileData.name,
browserStr: profileData.browserStr,
version: profileData.version,
}
);
// Update proxy if provided
if (profileData.proxy) {
await invoke("update_profile_proxy", {
profileName: profile.name,
proxy: profileData.proxy,
});
}
await loadProfiles();
} catch (error) {
setError(`Failed to create profile: ${error as any}`);
throw error;
}
},
[loadProfiles]
);
const [runningProfiles, setRunningProfiles] = useState<Set<string>>(
new Set()
);
const runningProfilesRef = useRef<Set<string>>(new Set());
const checkBrowserStatus = useCallback(
async (profile: BrowserProfile) => {
if (!isClient) return; // Only run on client side
try {
const isRunning = await invoke<boolean>("check_browser_status", {
profile,
});
const currentRunning = runningProfilesRef.current.has(profile.name);
if (isRunning !== currentRunning) {
setRunningProfiles((prev) => {
const next = new Set(prev);
if (isRunning) {
next.add(profile.name);
} else {
next.delete(profile.name);
}
runningProfilesRef.current = next;
return next;
});
}
} catch (err) {
console.error("Failed to check browser status:", err);
}
},
[isClient]
);
const launchProfile = useCallback(
async (profile: BrowserProfile) => {
if (!isClient) return; // Only run on client side
setError(null);
// Check if browser is disabled due to ongoing update
try {
const isDisabled = await invoke<boolean>(
"is_browser_disabled_for_update",
{
browser: profile.browser,
}
);
if (isDisabled || isUpdating(profile.browser)) {
setError(
`${profile.browser} is currently being updated. Please wait for the update to complete.`
);
return;
}
} catch (err) {
console.error("Failed to check browser update status:", err);
}
try {
const updatedProfile = await invoke<BrowserProfile>(
"launch_browser_profile",
{ profile }
);
await loadProfiles();
await checkBrowserStatus(updatedProfile);
} catch (err: unknown) {
console.error("Failed to launch browser:", err);
setError(`Failed to launch browser: ${JSON.stringify(err)}`);
}
},
[loadProfiles, checkBrowserStatus, isUpdating, isClient]
);
useEffect(() => {
if (profiles.length === 0 || !isClient) return;
const interval = setInterval(() => {
profiles.forEach((profile) => {
void checkBrowserStatus(profile);
});
}, 500);
return () => {
clearInterval(interval);
};
}, [profiles, checkBrowserStatus, isClient]);
useEffect(() => {
runningProfilesRef.current = runningProfiles;
}, [runningProfiles]);
useEffect(() => {
if (error) {
showErrorToast(error);
setError(null);
}
}, [error]);
const handleDeleteProfile = useCallback(
async (profile: BrowserProfile) => {
setError(null);
try {
await invoke("delete_profile", { profileName: profile.name });
await loadProfiles();
} catch (err: unknown) {
console.error("Failed to delete profile:", err);
setError(`Failed to delete profile: ${JSON.stringify(err)}`);
}
},
[loadProfiles]
);
const handleRenameProfile = useCallback(
async (oldName: string, newName: string) => {
setError(null);
try {
await invoke("rename_profile", { oldName, newName });
await loadProfiles();
} catch (err: unknown) {
console.error("Failed to rename profile:", err);
setError(`Failed to rename profile: ${JSON.stringify(err)}`);
throw err;
}
},
[loadProfiles]
);
const handleKillProfile = useCallback(
async (profile: BrowserProfile) => {
setError(null);
try {
await invoke("kill_browser_profile", { profile });
await loadProfiles();
} catch (err: unknown) {
console.error("Failed to kill browser:", err);
setError(`Failed to kill browser: ${JSON.stringify(err)}`);
}
},
[loadProfiles]
);
// Don't render anything until we're on the client side to prevent hydration issues
if (!isClient) {
return (
<div className="grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 gap-8 sm:p-12 font-[family-name:var(--font-geist-sans)]">
<main className="flex flex-col gap-8 row-start-2 items-center w-full max-w-3xl">
<Card className="w-full">
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>Profiles</CardTitle>
<div className="flex items-center gap-2">
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="outline"
disabled
className="flex items-center gap-2"
>
<GoGear className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Settings</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
disabled
className="flex items-center gap-2"
>
<GoPlus className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Create a new profile</TooltipContent>
</Tooltip>
</div>
</div>
</CardHeader>
<CardContent className="p-8 text-center">
<div className="animate-pulse">Loading...</div>
</CardContent>
</Card>
</main>
</div>
);
}
return (
<div className="grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 gap-8 sm:p-12 font-[family-name:var(--font-geist-sans)]">
<main className="flex flex-col gap-8 row-start-2 items-center w-full max-w-3xl">
<Card className="w-full">
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>Profiles</CardTitle>
<div className="flex items-center gap-2">
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="outline"
onClick={() => {
setSettingsDialogOpen(true);
}}
className="flex items-center gap-2"
>
<GoGear className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Settings</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
onClick={() => {
setCreateProfileDialogOpen(true);
}}
className="flex items-center gap-2"
>
<GoPlus className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Create a new profile</TooltipContent>
</Tooltip>
</div>
</div>
</CardHeader>
<CardContent className="space-y-6">
<ProfilesDataTable
data={profiles}
onLaunchProfile={launchProfile}
onKillProfile={handleKillProfile}
onProxySettings={openProxyDialog}
onDeleteProfile={handleDeleteProfile}
onRenameProfile={handleRenameProfile}
onChangeVersion={openChangeVersionDialog}
runningProfiles={runningProfiles}
isUpdating={isUpdating}
/>
</CardContent>
</Card>
</main>
<ProxySettingsDialog
isOpen={proxyDialogOpen}
onClose={() => {
setProxyDialogOpen(false);
}}
onSave={(proxy: ProxySettings) => void handleSaveProxy(proxy)}
initialSettings={currentProfileForProxy?.proxy}
browserType={currentProfileForProxy?.browser}
/>
<CreateProfileDialog
isOpen={createProfileDialogOpen}
onClose={() => {
setCreateProfileDialogOpen(false);
}}
onCreateProfile={handleCreateProfile}
/>
<SettingsDialog
isOpen={settingsDialogOpen}
onClose={() => {
setSettingsDialogOpen(false);
}}
/>
<ChangeVersionDialog
isOpen={changeVersionDialogOpen}
onClose={() => {
setChangeVersionDialogOpen(false);
}}
profile={currentProfileForVersionChange}
onVersionChanged={() => void loadProfiles()}
/>
{pendingUrls.map((pendingUrl) => (
<ProfileSelectorDialog
key={pendingUrl.id}
isOpen={true}
onClose={() => {
setPendingUrls((prev) =>
prev.filter((u) => u.id !== pendingUrl.id)
);
}}
url={pendingUrl.url}
runningProfiles={runningProfiles}
/>
))}
</div>
);
}
+196
View File
@@ -0,0 +1,196 @@
"use client";
import { LoadingButton } from "@/components/loading-button";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { VersionSelector } from "@/components/version-selector";
import { useBrowserDownload } from "@/hooks/use-browser-download";
import type { BrowserProfile } from "@/types";
import { invoke } from "@tauri-apps/api/core";
import { LuTriangleAlert } from "react-icons/lu";
import { useEffect, useState } from "react";
interface ChangeVersionDialogProps {
isOpen: boolean;
onClose: () => void;
profile: BrowserProfile | null;
onVersionChanged: () => void;
}
export function ChangeVersionDialog({
isOpen,
onClose,
profile,
onVersionChanged,
}: ChangeVersionDialogProps) {
const [selectedVersion, setSelectedVersion] = useState<string | null>(null);
const [isUpdating, setIsUpdating] = useState(false);
const [showDowngradeWarning, setShowDowngradeWarning] = useState(false);
const [acknowledgeDowngrade, setAcknowledgeDowngrade] = useState(false);
const {
availableVersions,
downloadedVersions,
isDownloading,
loadVersions,
loadDownloadedVersions,
downloadBrowser,
isVersionDownloaded,
} = useBrowserDownload();
useEffect(() => {
if (isOpen && profile) {
setSelectedVersion(profile.version);
setAcknowledgeDowngrade(false);
void loadVersions(profile.browser);
void loadDownloadedVersions(profile.browser);
}
}, [isOpen, profile, loadVersions, loadDownloadedVersions]);
useEffect(() => {
if (profile && selectedVersion) {
// Check if this is a downgrade
const currentVersionIndex = availableVersions.findIndex(
(v) => v.tag_name === profile.version
);
const selectedVersionIndex = availableVersions.findIndex(
(v) => v.tag_name === selectedVersion
);
// If selected version has a higher index, it's older (downgrade)
const isDowngrade =
currentVersionIndex !== -1 &&
selectedVersionIndex !== -1 &&
selectedVersionIndex > currentVersionIndex;
setShowDowngradeWarning(isDowngrade);
if (!isDowngrade) {
setAcknowledgeDowngrade(false);
}
}
}, [selectedVersion, profile, availableVersions]);
const handleDownload = async () => {
if (!profile || !selectedVersion) return;
await downloadBrowser(profile.browser, selectedVersion);
};
const handleVersionChange = async () => {
if (!profile || !selectedVersion) return;
setIsUpdating(true);
try {
await invoke("update_profile_version", {
profileName: profile.name,
version: selectedVersion,
});
onVersionChanged();
onClose();
} catch (error) {
console.error("Failed to update profile version:", error);
} finally {
setIsUpdating(false);
}
};
const canUpdate =
profile &&
selectedVersion &&
selectedVersion !== profile.version &&
selectedVersion &&
isVersionDownloaded(selectedVersion) &&
(!showDowngradeWarning || acknowledgeDowngrade);
if (!profile) return null;
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Change Browser Version</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="space-y-2">
<Label className="text-sm font-medium">Profile:</Label>
<div className="p-2 bg-muted rounded text-sm">{profile.name}</div>
</div>
<div className="space-y-2">
<Label className="text-sm font-medium">Current Version:</Label>
<div className="p-2 bg-muted rounded text-sm">
{profile.version}
</div>
</div>
{/* Version Selection */}
<div className="grid gap-2">
<Label>New Version</Label>
<VersionSelector
selectedVersion={selectedVersion}
onVersionSelect={setSelectedVersion}
availableVersions={availableVersions}
downloadedVersions={downloadedVersions}
isDownloading={isDownloading}
onDownload={() => {
void handleDownload();
}}
placeholder="Select version..."
/>
</div>
{/* Downgrade Warning */}
{showDowngradeWarning && (
<Alert className="border-orange-700">
<LuTriangleAlert className="h-4 w-4 text-orange-700" />
<AlertTitle className="text-orange-700">
Downgrade Warning
</AlertTitle>
<AlertDescription className="text-orange-700">
You are about to downgrade from version {profile.version} to{" "}
{selectedVersion}. This may lead to compatibility issues, data
loss, or unexpected behavior.
<div className="flex items-center space-x-2 mt-3">
<Checkbox
id="acknowledge-downgrade"
checked={acknowledgeDowngrade}
onCheckedChange={(checked) => {
setAcknowledgeDowngrade(checked as boolean);
}}
/>
<Label htmlFor="acknowledge-downgrade" className="text-sm">
I understand the risks and want to proceed
</Label>
</div>
</AlertDescription>
</Alert>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
Cancel
</Button>
<LoadingButton
isLoading={isUpdating}
onClick={() => {
void handleVersionChange();
}}
disabled={!canUpdate}
>
{isUpdating ? "Updating..." : "Update Version"}
</LoadingButton>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+401
View File
@@ -0,0 +1,401 @@
"use client";
import { LoadingButton } from "@/components/loading-button";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { VersionSelector } from "@/components/version-selector";
import { useBrowserDownload } from "@/hooks/use-browser-download";
import type { BrowserProfile, ProxySettings } from "@/types";
import { invoke } from "@tauri-apps/api/core";
import { useEffect, useState } from "react";
import { toast } from "sonner";
type BrowserTypeString =
| "mullvad-browser"
| "firefox"
| "firefox-developer"
| "chromium"
| "brave"
| "zen"
| "tor-browser";
interface CreateProfileDialogProps {
isOpen: boolean;
onClose: () => void;
onCreateProfile: (profileData: {
name: string;
browserStr: BrowserTypeString;
version: string;
proxy?: ProxySettings;
}) => Promise<void>;
}
export function CreateProfileDialog({
isOpen,
onClose,
onCreateProfile,
}: CreateProfileDialogProps) {
const [profileName, setProfileName] = useState("");
const [selectedBrowser, setSelectedBrowser] =
useState<BrowserTypeString | null>("mullvad-browser");
const [selectedVersion, setSelectedVersion] = useState<string | null>(null);
const [supportedBrowsers, setSupportedBrowsers] = useState<
BrowserTypeString[]
>([]);
const [isCreating, setIsCreating] = useState(false);
const [existingProfiles, setExistingProfiles] = useState<BrowserProfile[]>(
[]
);
// Proxy settings
const [proxyEnabled, setProxyEnabled] = useState(false);
const [proxyType, setProxyType] = useState("http");
const [proxyHost, setProxyHost] = useState("");
const [proxyPort, setProxyPort] = useState(8080);
const {
availableVersions,
downloadedVersions,
isDownloading,
loadVersions,
loadDownloadedVersions,
downloadBrowser,
isVersionDownloaded,
} = useBrowserDownload();
useEffect(() => {
if (isOpen) {
void loadSupportedBrowsers();
void loadExistingProfiles();
}
}, [isOpen]);
useEffect(() => {
if (isOpen && selectedBrowser) {
// Reset selected version when browser changes
setSelectedVersion(null);
void loadVersions(selectedBrowser);
void loadDownloadedVersions(selectedBrowser);
}
}, [isOpen, selectedBrowser, loadVersions, loadDownloadedVersions]);
// Set default version when versions are loaded and no version is selected
useEffect(() => {
if (availableVersions.length > 0 && selectedBrowser) {
// Always reset version when browser changes or versions are loaded
// Find the latest stable version (not alpha/beta)
const stableVersions = availableVersions.filter((v) => !v.is_alpha);
if (stableVersions.length > 0) {
// Select the first stable version (they're already sorted newest first)
setSelectedVersion(stableVersions[0].tag_name);
} else if (availableVersions.length > 0) {
// If no stable version found, select the first available version
setSelectedVersion(availableVersions[0].tag_name);
}
}
}, [availableVersions, selectedBrowser]);
const loadSupportedBrowsers = async () => {
try {
const browsers = await invoke<BrowserTypeString[]>(
"get_supported_browsers"
);
setSupportedBrowsers(browsers);
if (browsers.includes("mullvad-browser")) {
setSelectedBrowser("mullvad-browser");
} else if (browsers.length > 0) {
setSelectedBrowser(browsers[0]);
}
} catch (error) {
console.error("Failed to load supported browsers:", error);
}
};
const loadExistingProfiles = async () => {
try {
const profiles = await invoke<BrowserProfile[]>("list_browser_profiles");
setExistingProfiles(profiles);
} catch (error) {
console.error("Failed to load existing profiles:", error);
}
};
const handleDownload = async () => {
if (!selectedBrowser || !selectedVersion) return;
await downloadBrowser(selectedBrowser, selectedVersion);
};
const validateProfileName = (name: string): string | null => {
const trimmedName = name.trim();
if (!trimmedName) {
return "Profile name cannot be empty";
}
// Check for duplicate names (case insensitive)
const isDuplicate = existingProfiles.some(
(profile) => profile.name.toLowerCase() === trimmedName.toLowerCase()
);
if (isDuplicate) {
return "A profile with this name already exists";
}
return null;
};
// Helper to determine if proxy should be disabled for the selected browser
const isProxyDisabled = selectedBrowser === "tor-browser";
// Update proxy enabled state when browser changes to tor-browser
useEffect(() => {
if (selectedBrowser === "tor-browser" && proxyEnabled) {
setProxyEnabled(false);
}
}, [selectedBrowser, proxyEnabled]);
const handleCreate = async () => {
if (!profileName.trim() || !selectedBrowser || !selectedVersion) return;
// Validate profile name
const nameError = validateProfileName(profileName);
if (nameError) {
toast.error(nameError);
return;
}
setIsCreating(true);
try {
const proxy =
proxyEnabled && !isProxyDisabled
? {
enabled: true,
proxy_type: proxyType,
host: proxyHost,
port: proxyPort,
}
: undefined;
await onCreateProfile({
name: profileName.trim(),
browserStr: selectedBrowser,
version: selectedVersion,
proxy,
});
// Reset form
setProfileName("");
setSelectedVersion(null);
setProxyEnabled(false);
setProxyHost("");
setProxyPort(8080);
onClose();
} catch (error) {
console.error("Failed to create profile:", error);
} finally {
setIsCreating(false);
}
};
const nameError = profileName.trim()
? validateProfileName(profileName)
: null;
const canCreate =
profileName.trim() &&
selectedBrowser &&
selectedVersion &&
isVersionDownloaded(selectedVersion) &&
(!proxyEnabled || isProxyDisabled || (proxyHost && proxyPort)) &&
!nameError;
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Create New Profile</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-4">
{/* Profile Name */}
<div className="grid gap-2">
<Label htmlFor="profile-name">Profile Name</Label>
<Input
id="profile-name"
value={profileName}
onChange={(e) => {
setProfileName(e.target.value);
}}
placeholder="Enter profile name"
className={nameError ? "border-red-500" : ""}
/>
{nameError && <p className="text-sm text-red-600">{nameError}</p>}
</div>
{/* Browser Selection */}
<div className="grid gap-2">
<Label>Browser</Label>
<Select
value={selectedBrowser ?? undefined}
onValueChange={(value) => {
setSelectedBrowser(value as BrowserTypeString);
}}
>
<SelectTrigger>
<SelectValue placeholder="Select browser" />
</SelectTrigger>
<SelectContent>
{supportedBrowsers.map((browser) => (
<SelectItem key={browser} value={browser}>
{browser
.split("-")
.map(
(word) => word.charAt(0).toUpperCase() + word.slice(1)
)
.join(" ")}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Version Selection */}
<div className="grid gap-2">
<Label>Version</Label>
<VersionSelector
selectedVersion={selectedVersion}
onVersionSelect={setSelectedVersion}
availableVersions={availableVersions}
downloadedVersions={downloadedVersions}
isDownloading={isDownloading}
onDownload={() => {
void handleDownload();
}}
placeholder="Select version..."
/>
</div>
{/* Proxy Settings */}
<div className="grid gap-4 pt-4 border-t">
<div className="flex items-center space-x-2">
{isProxyDisabled ? (
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center space-x-2 opacity-50">
<Checkbox
id="proxy-enabled"
checked={false}
disabled={true}
/>
<Label htmlFor="proxy-enabled" className="text-gray-500">
Enable Proxy
</Label>
</div>
</TooltipTrigger>
<TooltipContent>
<p>
Tor Browser has its own built-in proxy system and
doesn&apos;t support additional proxy configuration
</p>
</TooltipContent>
</Tooltip>
) : (
<>
<Checkbox
id="proxy-enabled"
checked={proxyEnabled}
onCheckedChange={(checked) => {
setProxyEnabled(checked as boolean);
}}
/>
<Label htmlFor="proxy-enabled">Enable Proxy</Label>
</>
)}
</div>
{proxyEnabled && !isProxyDisabled && (
<>
<div className="grid gap-2">
<Label>Proxy Type</Label>
<Select value={proxyType} onValueChange={setProxyType}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{["http", "https", "socks4", "socks5"].map((type) => (
<SelectItem key={type} value={type}>
{type.toUpperCase()}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label htmlFor="proxy-host">Host</Label>
<Input
id="proxy-host"
value={proxyHost}
onChange={(e) => {
setProxyHost(e.target.value);
}}
placeholder="e.g. 127.0.0.1"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="proxy-port">Port</Label>
<Input
id="proxy-port"
type="number"
value={proxyPort}
onChange={(e) => {
setProxyPort(Number.parseInt(e.target.value, 10) || 0);
}}
placeholder="e.g. 8080"
min="1"
max="65535"
/>
</div>
</>
)}
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
Cancel
</Button>
<LoadingButton
isLoading={isCreating}
onClick={() => void handleCreate()}
disabled={!canCreate}
>
Create Profile
</LoadingButton>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+417
View File
@@ -0,0 +1,417 @@
/**
* Unified Toast System
*
* This module provides a comprehensive toast system that solves styling issues
* and provides a single, flexible toast component for all use cases.
*
* Features:
* - Proper background styling (no transparency issues)
* - Loading states with spinners
* - Progress bars for downloads/updates
* - Success/error states
* - Customizable icons and content
*
* Usage Examples:
*
* Simple loading toast:
* ```
* import { showToast } from "./custom-toast";
* showToast({
* type: "loading",
* title: "Loading...",
* description: "Please wait..."
* });
* ```
*
* Download progress toast:
* ```
* showToast({
* type: "download",
* title: "Downloading Firefox 123.0",
* progress: { percentage: 45, speed: "2.5", eta: "30s" }
* });
* ```
*
* Version update progress:
* ```
* showToast({
* type: "version-update",
* title: "Updating browser versions",
* progress: { current: 3, total: 5, found: 12 }
* });
* ```
*/
import React from "react";
import { toast as sonnerToast } from "sonner";
import {
LuCheckCheck,
LuTriangleAlert,
LuDownload,
LuRefreshCw,
} from "react-icons/lu";
interface BaseToastProps {
id?: string;
title: string;
description?: string;
duration?: number;
}
interface LoadingToastProps extends BaseToastProps {
type: "loading";
}
interface SuccessToastProps extends BaseToastProps {
type: "success";
}
interface ErrorToastProps extends BaseToastProps {
type: "error";
}
interface DownloadToastProps extends BaseToastProps {
type: "download";
stage?: "downloading" | "extracting" | "verifying" | "completed";
progress?: {
percentage: number;
speed?: string;
eta?: string;
};
}
interface VersionUpdateToastProps extends BaseToastProps {
type: "version-update";
progress?: {
current: number;
total: number;
found: number;
};
}
interface FetchingToastProps extends BaseToastProps {
type: "fetching";
browserName?: string;
}
type ToastProps =
| LoadingToastProps
| SuccessToastProps
| ErrorToastProps
| DownloadToastProps
| VersionUpdateToastProps
| FetchingToastProps;
function getToastIcon(type: ToastProps["type"], stage?: string) {
switch (type) {
case "success":
return <LuCheckCheck className="h-4 w-4 text-green-500 flex-shrink-0" />;
case "error":
return <LuTriangleAlert className="h-4 w-4 text-red-500 flex-shrink-0" />;
case "download":
if (stage === "completed") {
return (
<LuCheckCheck className="h-4 w-4 text-green-500 flex-shrink-0" />
);
}
return <LuDownload className="h-4 w-4 text-blue-500 flex-shrink-0" />;
case "version-update":
return (
<LuRefreshCw className="h-4 w-4 text-blue-500 animate-spin flex-shrink-0" />
);
case "fetching":
return (
<LuRefreshCw className="h-4 w-4 text-blue-500 animate-spin flex-shrink-0" />
);
case "loading":
default:
return (
<div className="animate-spin rounded-full h-4 w-4 border-2 border-blue-500 border-t-transparent flex-shrink-0" />
);
}
}
export function UnifiedToast(props: ToastProps) {
const { title, description, type } = props;
const stage = "stage" in props ? props.stage : undefined;
const progress = "progress" in props ? props.progress : undefined;
return (
<div className="flex items-start w-full bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-3 shadow-lg">
<div className="mr-3 mt-0.5">{getToastIcon(type, stage)}</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 dark:text-white leading-tight">
{title}
</p>
{/* Download progress */}
{type === "download" &&
progress &&
"percentage" in progress &&
stage === "downloading" && (
<div className="mt-2 space-y-1">
<div className="flex justify-between items-center">
<p className="text-xs text-gray-600 dark:text-gray-300 min-w-0 flex-1">
{progress.percentage.toFixed(1)}%
{progress.speed && `${progress.speed} MB/s`}
{progress.eta && `${progress.eta} remaining`}
</p>
</div>
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-1.5">
<div
className="bg-blue-500 h-1.5 rounded-full transition-all duration-300"
style={{ width: `${progress.percentage}%` }}
/>
</div>
</div>
)}
{/* Version update progress */}
{type === "version-update" && progress && "found" in progress && (
<div className="mt-2 space-y-1">
<p className="text-xs text-gray-600 dark:text-gray-300">
{progress.found} new versions found so far
</p>
<div className="flex items-center space-x-2">
<div className="flex-1 bg-gray-200 dark:bg-gray-700 rounded-full h-1.5 min-w-0">
<div
className="bg-blue-500 h-1.5 rounded-full transition-all duration-300"
style={{
width: `${(progress.current / progress.total) * 100}%`,
}}
/>
</div>
<span className="text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap shrink-0 w-8 text-right">
{progress.current}/{progress.total}
</span>
</div>
</div>
)}
{/* Description */}
{description && (
<p className="mt-1 text-xs text-gray-600 dark:text-gray-300 leading-tight">
{description}
</p>
)}
{/* Stage-specific descriptions for downloads */}
{type === "download" && !description && (
<>
{stage === "extracting" && (
<p className="mt-1 text-xs text-gray-600 dark:text-gray-300">
Extracting browser files...
</p>
)}
{stage === "verifying" && (
<p className="mt-1 text-xs text-gray-600 dark:text-gray-300">
Verifying installation...
</p>
)}
</>
)}
</div>
</div>
);
}
// Unified toast function
export function showToast(props: ToastProps & { id?: string }) {
const toastId = props.id ?? `toast-${props.type}-${Date.now()}`;
// Improved duration logic - make toasts disappear more quickly
let duration: number;
if (props.duration !== undefined) {
duration = props.duration;
} else {
switch (props.type) {
case "loading":
case "fetching":
duration = 10000; // 10 seconds instead of infinite
break;
case "download":
// Only keep infinite for active downloading, others get shorter durations
if ("stage" in props && props.stage === "downloading") {
duration = Number.POSITIVE_INFINITY;
} else if ("stage" in props && props.stage === "completed") {
duration = 3000; // Shorter duration for completed downloads
} else {
duration = 8000; // 8 seconds for extracting/verifying
}
break;
case "version-update":
duration = 15000; // 15 seconds instead of infinite
break;
case "success":
duration = 3000; // Shorter success duration
break;
case "error":
duration = 5000; // Reasonable error duration
break;
default:
duration = 4000;
}
}
if (props.type === "success") {
sonnerToast.success(<UnifiedToast {...props} />, {
id: toastId,
duration,
style: {
background: "transparent",
border: "none",
boxShadow: "none",
padding: 0,
},
});
} else if (props.type === "error") {
sonnerToast.error(<UnifiedToast {...props} />, {
id: toastId,
duration,
style: {
background: "transparent",
border: "none",
boxShadow: "none",
padding: 0,
},
});
} else {
sonnerToast.custom((id) => <UnifiedToast {...props} />, {
id: toastId,
duration,
style: {
background: "transparent",
border: "none",
boxShadow: "none",
padding: 0,
},
});
}
return toastId;
}
// Convenience functions for common use cases
export function showLoadingToast(
title: string,
options?: {
id?: string;
description?: string;
duration?: number;
}
) {
return showToast({
type: "loading",
title,
...options,
});
}
export function showDownloadToast(
browserName: string,
version: string,
stage: "downloading" | "extracting" | "verifying" | "completed",
progress?: { percentage: number; speed?: string; eta?: string },
options?: { suppressCompletionToast?: boolean }
) {
const title =
stage === "completed"
? `${browserName} ${version} downloaded successfully!`
: stage === "downloading"
? `Downloading ${browserName} ${version}`
: stage === "extracting"
? `Extracting ${browserName} ${version}`
: `Verifying ${browserName} ${version}`;
// Don't show completion toast if suppressed (for auto-update scenarios)
if (stage === "completed" && options?.suppressCompletionToast) {
dismissToast(`download-${browserName.toLowerCase()}-${version}`);
return;
}
return showToast({
type: "download",
title,
stage,
progress,
id: `download-${browserName.toLowerCase()}-${version}`,
});
}
export function showVersionUpdateToast(
title: string,
options?: {
id?: string;
description?: string;
progress?: {
current: number;
total: number;
found: number;
};
duration?: number;
}
) {
return showToast({
type: "version-update",
title,
...options,
});
}
export function showFetchingToast(
browserName: string,
options?: {
id?: string;
description?: string;
duration?: number;
}
) {
return showToast({
type: "fetching",
title: `Checking for new ${browserName} versions...`,
description:
options?.description ?? "Fetching latest release information...",
browserName,
...options,
});
}
export function showSuccessToast(
title: string,
options?: {
id?: string;
description?: string;
duration?: number;
}
) {
return showToast({
type: "success",
title,
...options,
});
}
export function showErrorToast(
title: string,
options?: {
id?: string;
description?: string;
duration?: number;
}
) {
return showToast({
type: "error",
title,
...options,
});
}
// Generic helper for dismissing toasts
export function dismissToast(id: string) {
sonnerToast.dismiss(id);
}
// Dismiss all toasts
export function dismissAllToasts() {
sonnerToast.dismiss();
}
+17
View File
@@ -0,0 +1,17 @@
import { LuLoaderCircle } from "react-icons/lu";
import { type ButtonProps, Button as UIButton } from "./ui/button";
type Props = ButtonProps & {
isLoading: boolean;
"aria-label"?: string;
};
export const LoadingButton = ({ isLoading, ...props }: Props) => {
return (
<UIButton className="grid place-items-center" {...props}>
{isLoading ? (
<LuLoaderCircle className="h-4 w-4 animate-spin" />
) : (
props.children
)}
</UIButton>
);
};
+509
View File
@@ -0,0 +1,509 @@
"use client";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import type { BrowserProfile } from "@/types";
import {
type ColumnDef,
type SortingState,
flexRender,
getCoreRowModel,
getSortedRowModel,
useReactTable,
} from "@tanstack/react-table";
import { LuChevronDown, LuChevronUp } from "react-icons/lu";
import { IoEllipsisHorizontal } from "react-icons/io5";
import * as React from "react";
import { CiCircleCheck } from "react-icons/ci";
import { useTableSorting } from "@/hooks/use-table-sorting";
import { Input } from "./ui/input";
import { Label } from "./ui/label";
import { getBrowserDisplayName, getBrowserIcon } from "@/lib/browser-utils";
interface ProfilesDataTableProps {
data: BrowserProfile[];
onLaunchProfile: (profile: BrowserProfile) => void | Promise<void>;
onKillProfile: (profile: BrowserProfile) => void | Promise<void>;
onProxySettings: (profile: BrowserProfile) => void;
onDeleteProfile: (profile: BrowserProfile) => void | Promise<void>;
onRenameProfile: (oldName: string, newName: string) => Promise<void>;
onChangeVersion: (profile: BrowserProfile) => void;
runningProfiles: Set<string>;
isUpdating?: (browser: string) => boolean;
}
export function ProfilesDataTable({
data,
onLaunchProfile,
onKillProfile,
onProxySettings,
onDeleteProfile,
onRenameProfile,
onChangeVersion,
runningProfiles,
isUpdating = () => false,
}: ProfilesDataTableProps) {
const { getTableSorting, updateSorting, isLoaded } = useTableSorting();
const [sorting, setSorting] = React.useState<SortingState>([]);
const [profileToRename, setProfileToRename] =
React.useState<BrowserProfile | null>(null);
const [newProfileName, setNewProfileName] = React.useState("");
const [renameError, setRenameError] = React.useState<string | null>(null);
const [isClient, setIsClient] = React.useState(false);
// Ensure we're on the client side to prevent hydration mismatches
React.useEffect(() => {
setIsClient(true);
}, []);
// Update local sorting state when settings are loaded
React.useEffect(() => {
if (isLoaded && isClient) {
setSorting(getTableSorting());
}
}, [isLoaded, getTableSorting, isClient]);
// Handle sorting changes
const handleSortingChange = React.useCallback(
(updater: React.SetStateAction<SortingState>) => {
if (!isClient) return;
const newSorting =
typeof updater === "function" ? updater(sorting) : updater;
setSorting(newSorting);
updateSorting(newSorting);
},
[sorting, updateSorting, isClient]
);
const handleRename = async () => {
if (!profileToRename || !newProfileName.trim()) return;
try {
await onRenameProfile(profileToRename.name, newProfileName.trim());
setProfileToRename(null);
setNewProfileName("");
setRenameError(null);
} catch (err) {
setRenameError(err as string);
}
};
const columns: ColumnDef<BrowserProfile>[] = React.useMemo(
() => [
{
id: "actions",
cell: ({ row }) => {
const profile = row.original;
const isRunning = isClient && runningProfiles.has(profile.name);
const isBrowserUpdating = isClient && isUpdating(profile.browser);
// Check if any TOR browser profile is running
const isTorBrowser = profile.browser === "tor-browser";
const anyTorRunning =
isClient &&
data.some(
(p) => p.browser === "tor-browser" && runningProfiles.has(p.name)
);
const shouldDisableTorStart =
isTorBrowser && !isRunning && anyTorRunning;
const isDisabled = shouldDisableTorStart || isBrowserUpdating;
return (
<div className="flex items-center gap-2">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={isRunning ? "destructive" : "default"}
size="sm"
disabled={!isClient || isDisabled}
onClick={() =>
void (isRunning
? onKillProfile(profile)
: onLaunchProfile(profile))
}
>
{isRunning ? "Stop" : "Launch"}
</Button>
</TooltipTrigger>
<TooltipContent>
{!isClient
? "Loading..."
: isRunning
? "Click to forcefully stop the browser"
: isBrowserUpdating
? `${profile.browser} is being updated. Please wait for the update to complete.`
: shouldDisableTorStart
? "Only one TOR browser instance can run at a time. Stop the running TOR browser first."
: "Click to launch the browser"}
</TooltipContent>
</Tooltip>
</div>
);
},
},
{
accessorKey: "name",
header: ({ column }) => {
const isSorted = column.getIsSorted();
return (
<Button
variant="ghost"
onClick={() => {
column.toggleSorting(column.getIsSorted() === "asc");
}}
className="h-auto p-0 font-semibold hover:bg-transparent"
>
Profile
{isSorted === "asc" && <LuChevronUp className="ml-2 h-4 w-4" />}
{isSorted === "desc" && (
<LuChevronDown className="ml-2 h-4 w-4" />
)}
{!isSorted && (
<LuChevronDown className="ml-2 h-4 w-4 opacity-50" />
)}
</Button>
);
},
enableSorting: true,
sortingFn: "alphanumeric",
},
{
accessorKey: "browser",
header: ({ column }) => {
const isSorted = column.getIsSorted();
return (
<Button
variant="ghost"
onClick={() => {
column.toggleSorting(column.getIsSorted() === "asc");
}}
className="h-auto p-0 font-semibold hover:bg-transparent"
>
Browser
{isSorted === "asc" && <LuChevronUp className="ml-2 h-4 w-4" />}
{isSorted === "desc" && (
<LuChevronDown className="ml-2 h-4 w-4" />
)}
{!isSorted && (
<LuChevronDown className="ml-2 h-4 w-4 opacity-50" />
)}
</Button>
);
},
cell: ({ row }) => {
const browser: string = row.getValue("browser");
const IconComponent = getBrowserIcon(browser);
return (
<div className="flex items-center gap-2">
{IconComponent && <IconComponent className="h-4 w-4" />}
<span>{getBrowserDisplayName(browser)}</span>
</div>
);
},
enableSorting: true,
sortingFn: (rowA, rowB, columnId) => {
const browserA = getBrowserDisplayName(rowA.getValue(columnId));
const browserB = getBrowserDisplayName(rowB.getValue(columnId));
return browserA.localeCompare(browserB);
},
},
{
accessorKey: "version",
header: "Version",
},
{
id: "status",
header: ({ column }) => {
const isSorted = column.getIsSorted();
return (
<Button
variant="ghost"
onClick={() => {
column.toggleSorting(column.getIsSorted() === "asc");
}}
className="h-auto p-0 font-semibold hover:bg-transparent"
>
Status
{isSorted === "asc" && <LuChevronUp className="ml-2 h-4 w-4" />}
{isSorted === "desc" && (
<LuChevronDown className="ml-2 h-4 w-4" />
)}
{!isSorted && (
<LuChevronDown className="ml-2 h-4 w-4 opacity-50" />
)}
</Button>
);
},
cell: ({ row }) => {
const profile = row.original;
const isRunning = isClient && runningProfiles.has(profile.name);
return (
<div className="flex flex-col gap-1">
<Badge
variant={isRunning ? "default" : "secondary"}
className="text-xs w-fit"
>
{isClient ? (isRunning ? "Running" : "Stopped") : "Loading..."}
</Badge>
{isClient && isRunning && profile.process_id && (
<span className="text-xs text-muted-foreground">
PID: {profile.process_id}
</span>
)}
</div>
);
},
enableSorting: true,
sortingFn: (rowA, rowB) => {
// If not on client, sort by name only to ensure consistency
if (!isClient) {
return rowA.original.name.localeCompare(rowB.original.name);
}
const isRunningA = runningProfiles.has(rowA.original.name);
const isRunningB = runningProfiles.has(rowB.original.name);
// Running profiles come first, then stopped ones
// Secondary sort by profile name
if (isRunningA === isRunningB) {
return rowA.original.name.localeCompare(rowB.original.name);
}
return isRunningA ? -1 : 1;
},
},
{
id: "proxy",
header: "Proxy",
cell: ({ row }) => {
const profile = row.original;
const hasProxy = profile.proxy?.enabled;
return (
<Tooltip>
<TooltipTrigger>
<div className="flex items-center gap-2">
{hasProxy && (
<CiCircleCheck className="h-4 w-4 text-green-500" />
)}
<span className="text-sm text-muted-foreground">
{hasProxy ? profile.proxy?.proxy_type : "Disabled"}
</span>
</div>
</TooltipTrigger>
<TooltipContent>
{hasProxy
? `${profile.proxy?.proxy_type.toUpperCase()} proxy enabled (${
profile.proxy?.host
}:${profile.proxy?.port})`
: "No proxy configured"}
</TooltipContent>
</Tooltip>
);
},
},
// Update the settings column to use the confirmation dialog
{
id: "settings",
cell: ({ row }) => {
const profile = row.original;
const isRunning = isClient && runningProfiles.has(profile.name);
const isBrowserUpdating = isClient && isUpdating(profile.browser);
return (
<div className="flex items-center justify-end">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="h-8 w-8 p-0"
disabled={!isClient}
>
<span className="sr-only">Open menu</span>
<IoEllipsisHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => {
onProxySettings(profile);
}}
disabled={!isClient || isBrowserUpdating}
>
Configure proxy
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
onChangeVersion(profile);
}}
disabled={!isClient || isRunning || isBrowserUpdating}
>
Change version
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setProfileToRename(profile);
setNewProfileName(profile.name);
}}
disabled={!isClient || isRunning || isBrowserUpdating}
>
Rename profile
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => void onDeleteProfile(profile)}
className="text-red-600"
disabled={!isClient || isRunning || isBrowserUpdating}
>
Delete profile
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
},
},
],
[isClient, runningProfiles, isUpdating, data]
);
const table = useReactTable({
data,
columns,
state: {
sorting,
},
onSortingChange: handleSortingChange,
getSortedRowModel: getSortedRowModel(),
getCoreRowModel: getCoreRowModel(),
});
return (
<>
<div className="rounded-md border">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
);
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center"
>
No profiles found.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<Dialog
open={profileToRename !== null}
onOpenChange={(open) => {
if (!open) {
setProfileToRename(null);
setNewProfileName("");
setRenameError(null);
}
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Rename Profile</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="name" className="text-right">
Name
</Label>
<Input
id="name"
value={newProfileName}
onChange={(e) => {
setNewProfileName(e.target.value);
}}
className="col-span-3"
/>
</div>
{renameError && (
<p className="text-sm text-red-600">{renameError}</p>
)}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => {
setProfileToRename(null);
}}
>
Cancel
</Button>
<Button onClick={() => void handleRename()}>Save</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
+366
View File
@@ -0,0 +1,366 @@
"use client";
import { LoadingButton } from "@/components/loading-button";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import type { BrowserProfile } from "@/types";
import { invoke } from "@tauri-apps/api/core";
import { LuCopy } from "react-icons/lu";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { getBrowserDisplayName, getBrowserIcon } from "@/lib/browser-utils";
interface ProfileSelectorDialogProps {
isOpen: boolean;
onClose: () => void;
url?: string;
runningProfiles?: Set<string>;
}
export function ProfileSelectorDialog({
isOpen,
onClose,
url,
runningProfiles = new Set(),
}: ProfileSelectorDialogProps) {
const [profiles, setProfiles] = useState<BrowserProfile[]>([]);
const [selectedProfile, setSelectedProfile] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [isLaunching, setIsLaunching] = useState(false);
useEffect(() => {
if (isOpen) {
void loadProfiles();
}
}, [isOpen]);
const loadProfiles = async () => {
setIsLoading(true);
try {
const profileList = await invoke<BrowserProfile[]>(
"list_browser_profiles"
);
// Sort profiles by name
profileList.sort((a, b) => a.name.localeCompare(b.name));
// Don't filter any profiles, show all of them
setProfiles(profileList);
// Auto-select first available profile for link opening
if (profileList.length > 0) {
// Find the first profile that can be used for opening links
const availableProfile = profileList.find((profile) => {
return canUseProfileForLinks(profile, profileList, runningProfiles);
});
if (availableProfile) {
setSelectedProfile(availableProfile.name);
} else {
// If no suitable profile found, still select the first one to show UI
setSelectedProfile(profileList[0].name);
}
}
} catch (error) {
console.error("Failed to load profiles:", error);
} finally {
setIsLoading(false);
}
};
// Helper function to determine if a profile can be used for opening links
const canUseProfileForLinks = (
profile: BrowserProfile,
allProfiles: BrowserProfile[],
runningProfiles: Set<string>
): boolean => {
const isRunning = runningProfiles.has(profile.name);
// For TOR browser: Check if any TOR browser is running
if (profile.browser === "tor-browser") {
const runningTorProfiles = allProfiles.filter(
(p) => p.browser === "tor-browser" && runningProfiles.has(p.name)
);
// If no TOR browser is running, allow any TOR profile
if (runningTorProfiles.length === 0) {
return true;
}
// If TOR browser(s) are running, only allow the running one(s)
return isRunning;
}
// For Mullvad browser: never allow if running
if (profile.browser === "mullvad-browser" && isRunning) {
return false;
}
// For other browsers: always allow
return true;
};
// Helper function to get tooltip content for profiles
const getProfileTooltipContent = (profile: BrowserProfile): string => {
const isRunning = runningProfiles.has(profile.name);
if (profile.browser === "tor-browser") {
const runningTorProfiles = profiles.filter(
(p) => p.browser === "tor-browser" && runningProfiles.has(p.name)
);
// If another TOR profile is running, this one is not available
return "Only 1 instance can run at a time";
}
if (profile.browser === "mullvad-browser") {
if (isRunning) {
return "Only launching the browser is supported, opening them in a running browser is not yet available";
}
return "Only launching the browser is supported, opening them in a running browser is not yet available";
}
if (isRunning) {
return "URL will open in a new tab in the existing browser window";
}
return "";
};
const handleOpenUrl = async () => {
if (!selectedProfile || !url) return;
setIsLaunching(true);
try {
await invoke("open_url_with_profile", {
profileName: selectedProfile,
url,
});
onClose();
} catch (error) {
console.error("Failed to open URL with profile:", error);
} finally {
setIsLaunching(false);
}
};
const handleCancel = () => {
setSelectedProfile(null);
onClose();
};
const handleCopyUrl = async () => {
if (!url) return;
try {
await navigator.clipboard.writeText(url);
toast.success("URL copied to clipboard!");
} catch (error) {
console.error("Failed to copy URL:", error);
toast.error("Failed to copy URL to clipboard");
}
};
const selectedProfileData = profiles.find((p) => p.name === selectedProfile);
const isSelectedProfileRunning = selectedProfile
? runningProfiles.has(selectedProfile)
: false;
// Check if the selected profile can be used for opening links
const canOpenWithSelectedProfile = () => {
if (!selectedProfileData) return false;
return canUseProfileForLinks(
selectedProfileData,
profiles,
runningProfiles
);
};
// Get tooltip content for disabled profiles
const getTooltipContent = () => {
if (!selectedProfileData) return "";
return getProfileTooltipContent(selectedProfileData);
};
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Choose Profile</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-4">
{url && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-sm font-medium">Opening URL:</Label>
<Button
variant="outline"
size="sm"
onClick={() => void handleCopyUrl()}
className="flex items-center gap-2"
>
<LuCopy className="h-3 w-3" />
Copy
</Button>
</div>
<div className="p-2 bg-muted rounded text-sm break-all">
{url}
</div>
</div>
)}
<div className="space-y-2">
<Label htmlFor="profile-select">Select Profile:</Label>
{isLoading ? (
<div className="text-sm text-muted-foreground">
Loading profiles...
</div>
) : profiles.length === 0 ? (
<div className="space-y-2">
<div className="text-sm text-muted-foreground">
No profiles available. Please create a profile first.
</div>
<div className="text-xs text-muted-foreground">
Close this dialog and create a profile from the main window to
get started.
</div>
</div>
) : (
<>
<Select
value={selectedProfile ?? undefined}
onValueChange={setSelectedProfile}
>
<SelectTrigger>
<SelectValue placeholder="Choose a profile" />
</SelectTrigger>
<SelectContent>
{profiles.map((profile) => {
const isRunning = runningProfiles.has(profile.name);
const canUseForLinks = canUseProfileForLinks(
profile,
profiles,
runningProfiles
);
const tooltipContent = getProfileTooltipContent(profile);
return (
<Tooltip key={profile.name}>
<TooltipTrigger asChild>
<SelectItem
value={profile.name}
disabled={!canUseForLinks}
>
<div
className={`flex items-center gap-2 ${
!canUseForLinks ? "opacity-50" : ""
}`}
>
<div className="flex items-center gap-3 p-3 rounded-lg hover:bg-accent cursor-pointer">
<div className="flex items-center gap-2">
{(() => {
const IconComponent = getBrowserIcon(
profile.browser
);
return IconComponent ? (
<IconComponent className="h-4 w-4" />
) : null;
})()}
<span className="text-sm text-muted-foreground">
{getBrowserDisplayName(profile.browser)}{" "}
{profile.version}
</span>
</div>
<div className="flex-1 text-right">
<div className="font-medium">
{profile.name}
</div>
</div>
</div>
<Badge variant="secondary" className="text-xs">
{getBrowserDisplayName(profile.browser)}
</Badge>
{profile.proxy?.enabled && (
<Badge variant="outline" className="text-xs">
Proxy
</Badge>
)}
{isRunning && (
<Badge variant="default" className="text-xs">
Running
</Badge>
)}
{!canUseForLinks && (
<Badge
variant="destructive"
className="text-xs"
>
Unavailable
</Badge>
)}
</div>
</SelectItem>
</TooltipTrigger>
{tooltipContent && (
<TooltipContent>{tooltipContent}</TooltipContent>
)}
</Tooltip>
);
})}
</SelectContent>
</Select>
</>
)}
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={handleCancel}>
Cancel
</Button>
<Tooltip>
<TooltipTrigger asChild>
<div>
<LoadingButton
isLoading={isLaunching}
onClick={() => void handleOpenUrl()}
disabled={
!selectedProfile ||
profiles.length === 0 ||
!canOpenWithSelectedProfile()
}
>
Open
</LoadingButton>
</div>
</TooltipTrigger>
{getTooltipContent() && (
<TooltipContent>{getTooltipContent()}</TooltipContent>
)}
</Tooltip>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+240
View File
@@ -0,0 +1,240 @@
"use client";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useEffect, useState } from "react";
interface ProxySettings {
enabled: boolean;
proxy_type: string;
host: string;
port: number;
}
interface ProxySettingsDialogProps {
isOpen: boolean;
onClose: () => void;
onSave: (proxySettings: ProxySettings) => void;
initialSettings?: ProxySettings;
browserType?: string;
}
export function ProxySettingsDialog({
isOpen,
onClose,
onSave,
initialSettings,
browserType,
}: ProxySettingsDialogProps) {
const [settings, setSettings] = useState<ProxySettings>({
enabled: initialSettings?.enabled ?? false,
proxy_type: initialSettings?.proxy_type ?? "http",
host: initialSettings?.host ?? "",
port: initialSettings?.port ?? 8080,
});
const [initialSettingsState, setInitialSettingsState] =
useState<ProxySettings>({
enabled: false,
proxy_type: "http",
host: "",
port: 8080,
});
useEffect(() => {
if (isOpen && initialSettings) {
const newSettings = {
enabled: initialSettings.enabled,
proxy_type: initialSettings.proxy_type,
host: initialSettings.host,
port: initialSettings.port,
};
setSettings(newSettings);
setInitialSettingsState(newSettings);
} else if (isOpen) {
const defaultSettings = {
enabled: false,
proxy_type: "http",
host: "",
port: 80,
};
setSettings(defaultSettings);
setInitialSettingsState(defaultSettings);
}
}, [isOpen, initialSettings]);
const handleSubmit = () => {
onSave(settings);
};
// Check if settings have changed
const hasChanged = () => {
return (
settings.enabled !== initialSettingsState.enabled ||
settings.proxy_type !== initialSettingsState.proxy_type ||
settings.host !== initialSettingsState.host ||
settings.port !== initialSettingsState.port
);
};
// Helper to determine if proxy should be disabled for the selected browser
const isProxyDisabled = browserType === "tor-browser";
// Update proxy enabled state when browser is tor-browser
useEffect(() => {
if (browserType === "tor-browser" && settings.enabled) {
setSettings((prev) => ({ ...prev, enabled: false }));
}
}, [browserType, settings.enabled]);
return (
<Dialog
open={isOpen}
onOpenChange={(open) => {
if (!open) {
onClose();
}
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Proxy Settings</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="flex items-center space-x-2">
{isProxyDisabled ? (
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center space-x-2 opacity-50">
<Checkbox
id="proxy-enabled"
checked={false}
disabled={true}
/>
<Label htmlFor="proxy-enabled" className="text-gray-500">
Enable Proxy
</Label>
</div>
</TooltipTrigger>
<TooltipContent>
<p>
Tor Browser has its own built-in proxy system and
doesn&apos;t support additional proxy configuration
</p>
</TooltipContent>
</Tooltip>
) : (
<>
<Checkbox
id="proxy-enabled"
checked={settings.enabled}
onCheckedChange={(checked) => {
setSettings({ ...settings, enabled: checked as boolean });
}}
/>
<Label htmlFor="proxy-enabled">Enable Proxy</Label>
</>
)}
</div>
{settings.enabled && !isProxyDisabled && (
<>
<div className="grid gap-2">
<Label>Proxy Type</Label>
<Select
value={settings.proxy_type}
onValueChange={(value) => {
setSettings({
...settings,
proxy_type: value,
});
}}
>
<SelectTrigger>
<SelectValue placeholder="Select proxy type" />
</SelectTrigger>
<SelectContent>
{["http", "https", "socks4", "socks5"].map((type) => (
<SelectItem key={type} value={type}>
{type.toUpperCase()}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label htmlFor="host">Host</Label>
<Input
id="host"
value={settings.host}
onChange={(e) => {
setSettings({ ...settings, host: e.target.value });
}}
placeholder="e.g. 127.0.0.1"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="port">Port</Label>
<Input
id="port"
type="number"
value={settings.port}
onChange={(e) => {
setSettings({
...settings,
port: Number.parseInt(e.target.value, 10) || 0,
});
}}
placeholder="e.g. 8080"
min="1"
max="65535"
/>
</div>
</>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
Cancel
</Button>
<Button
onClick={handleSubmit}
disabled={
!hasChanged() ||
(!isProxyDisabled &&
settings.enabled &&
(!settings.host || !settings.port))
}
>
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+266
View File
@@ -0,0 +1,266 @@
"use client";
import { LoadingButton } from "@/components/loading-button";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { invoke } from "@tauri-apps/api/core";
import { useTheme } from "next-themes";
import { useEffect, useState } from "react";
interface AppSettings {
set_as_default_browser: boolean;
show_settings_on_startup: boolean;
theme: string;
auto_updates_enabled: boolean;
}
interface SettingsDialogProps {
isOpen: boolean;
onClose: () => void;
}
export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
const [settings, setSettings] = useState<AppSettings>({
set_as_default_browser: false,
show_settings_on_startup: true,
theme: "system",
auto_updates_enabled: true,
});
const [originalSettings, setOriginalSettings] = useState<AppSettings>({
set_as_default_browser: false,
show_settings_on_startup: true,
theme: "system",
auto_updates_enabled: true,
});
const [isDefaultBrowser, setIsDefaultBrowser] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [isSettingDefault, setIsSettingDefault] = useState(false);
const { setTheme } = useTheme();
useEffect(() => {
if (isOpen) {
void loadSettings();
void checkDefaultBrowserStatus();
// Set up interval to check default browser status
const intervalId = setInterval(() => {
void checkDefaultBrowserStatus();
}, 500); // Check every 2 seconds
// Cleanup interval on component unmount or dialog close
return () => {
clearInterval(intervalId);
};
}
}, [isOpen]);
const loadSettings = async () => {
setIsLoading(true);
try {
const appSettings = await invoke<AppSettings>("get_app_settings");
setSettings(appSettings);
setOriginalSettings(appSettings);
} catch (error) {
console.error("Failed to load settings:", error);
} finally {
setIsLoading(false);
}
};
const checkDefaultBrowserStatus = async () => {
try {
const isDefault = await invoke<boolean>("is_default_browser");
setIsDefaultBrowser(isDefault);
} catch (error) {
console.error("Failed to check default browser status:", error);
}
};
const handleSetDefaultBrowser = async () => {
setIsSettingDefault(true);
try {
await invoke("set_as_default_browser");
await checkDefaultBrowserStatus();
} catch (error) {
console.error("Failed to set as default browser:", error);
} finally {
setIsSettingDefault(false);
}
};
const handleSave = async () => {
setIsSaving(true);
try {
await invoke("save_app_settings", { settings });
// Apply theme change immediately
setTheme(settings.theme);
setOriginalSettings(settings);
onClose();
} catch (error) {
console.error("Failed to save settings:", error);
} finally {
setIsSaving(false);
}
};
const updateSetting = (key: keyof AppSettings, value: boolean | string) => {
setSettings((prev) => ({ ...prev, [key]: value }));
};
// Check if settings have changed (excluding default browser setting)
const hasChanges =
settings.show_settings_on_startup !==
originalSettings.show_settings_on_startup ||
settings.theme !== originalSettings.theme ||
settings.auto_updates_enabled !== originalSettings.auto_updates_enabled;
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-md max-h-[80vh] my-8 flex flex-col">
<DialogHeader className="flex-shrink-0">
<DialogTitle>Settings</DialogTitle>
</DialogHeader>
<div className="grid gap-6 py-4 overflow-y-auto flex-1 min-h-0">
{/* Appearance Section */}
<div className="space-y-4">
<Label className="text-base font-medium">Appearance</Label>
<div className="grid gap-2">
<Label htmlFor="theme-select" className="text-sm">
Theme
</Label>
<Select
value={settings.theme}
onValueChange={(value) => {
updateSetting("theme", value);
}}
>
<SelectTrigger id="theme-select">
<SelectValue placeholder="Select theme" />
</SelectTrigger>
<SelectContent>
<SelectItem value="light">Light</SelectItem>
<SelectItem value="dark">Dark</SelectItem>
<SelectItem value="system">System</SelectItem>
</SelectContent>
</Select>
</div>
<p className="text-xs text-muted-foreground">
Choose your preferred theme or follow your system settings.
</p>
</div>
{/* Default Browser Section */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<Label className="text-base font-medium">Default Browser</Label>
<Badge variant={isDefaultBrowser ? "default" : "secondary"}>
{isDefaultBrowser ? "Active" : "Inactive"}
</Badge>
</div>
<LoadingButton
isLoading={isSettingDefault}
onClick={() => {
void handleSetDefaultBrowser();
}}
disabled={isDefaultBrowser}
variant={isDefaultBrowser ? "outline" : "default"}
className="w-full"
>
{isDefaultBrowser
? "Already Default Browser"
: "Set as Default Browser"}
</LoadingButton>
<p className="text-xs text-muted-foreground">
When set as default, Donut Browser will handle web links and allow
you to choose which profile to use.
</p>
</div>
{/* Auto-Update Section */}
<div className="space-y-4">
<Label className="text-base font-medium">Auto-Updates</Label>
<div className="flex items-center space-x-2">
<Checkbox
id="auto-updates"
checked={settings.auto_updates_enabled}
onCheckedChange={(checked) => {
updateSetting("auto_updates_enabled", checked as boolean);
}}
/>
<Label htmlFor="auto-updates" className="text-sm">
Enable automatic browser updates
</Label>
</div>
<p className="text-xs text-muted-foreground">
When enabled, Donut Browser will check for browser updates and
notify you when updates are available for your profiles.
</p>
</div>
{/* Startup Behavior Section */}
<div className="space-y-4">
<Label className="text-base font-medium">Startup Behavior</Label>
<div className="flex items-center space-x-2">
<Checkbox
id="show-settings"
checked={settings.show_settings_on_startup}
onCheckedChange={(checked) => {
updateSetting("show_settings_on_startup", checked as boolean);
}}
/>
<Label htmlFor="show-settings" className="text-sm">
Show settings on app startup
</Label>
</div>
<p className="text-xs text-muted-foreground">
When enabled, the settings dialog will be shown when the app
starts.
</p>
</div>
</div>
<DialogFooter className="flex-shrink-0">
<Button variant="outline" onClick={onClose}>
Cancel
</Button>
<LoadingButton
isLoading={isSaving}
onClick={() => {
void handleSave();
}}
disabled={isLoading || !hasChanges}
>
Save Settings
</LoadingButton>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+95
View File
@@ -0,0 +1,95 @@
"use client";
import { invoke } from "@tauri-apps/api/core";
import { ThemeProvider } from "next-themes";
import { useEffect, useState } from "react";
interface AppSettings {
show_settings_on_startup: boolean;
theme: string;
}
interface CustomThemeProviderProps {
children: React.ReactNode;
}
// Helper function to detect system dark mode preference
function getSystemTheme(): string {
if (typeof window !== "undefined") {
const isDarkMode = window.matchMedia(
"(prefers-color-scheme: dark)"
).matches;
return isDarkMode ? "dark" : "light";
}
return "light";
}
export function CustomThemeProvider({ children }: CustomThemeProviderProps) {
const [isLoading, setIsLoading] = useState(true);
const [defaultTheme, setDefaultTheme] = useState<string>("system");
useEffect(() => {
const loadTheme = async () => {
try {
const settings = await invoke<AppSettings>("get_app_settings");
setDefaultTheme(settings.theme);
} catch (error) {
console.error("Failed to load theme settings:", error);
// For first-time users, detect system preference and apply it
const systemTheme = getSystemTheme();
console.log(
"First-time user detected, applying system theme:",
systemTheme
);
// Save the detected theme as the default
try {
await invoke("save_app_settings", {
settings: {
show_settings_on_startup: true,
theme: "system",
auto_updates_enabled: true,
},
});
} catch (saveError) {
console.error("Failed to save initial theme settings:", saveError);
}
setDefaultTheme("system");
} finally {
setIsLoading(false);
}
};
void loadTheme();
}, []);
if (isLoading) {
// Detect system theme to show appropriate loading screen
const systemTheme = getSystemTheme();
const loadingBgColor = systemTheme === "dark" ? "bg-gray-900" : "bg-white";
const spinnerColor =
systemTheme === "dark" ? "border-white" : "border-gray-900";
return (
<div
className={`fixed inset-0 ${loadingBgColor} flex items-center justify-center`}
>
<div
className={`animate-spin rounded-full h-8 w-8 border-2 ${spinnerColor} border-t-transparent`}
/>
</div>
);
}
return (
<ThemeProvider
attribute="class"
defaultTheme={defaultTheme}
enableSystem={true}
disableTransitionOnChange={false}
>
{children}
</ThemeProvider>
);
}
+66
View File
@@ -0,0 +1,66 @@
import { type VariantProps, cva } from "class-variance-authority";
import type * as React from "react";
import { cn } from "@/lib/utils";
const alertVariants = cva(
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
{
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
},
},
defaultVariants: {
variant: "default",
},
},
);
function Alert({
className,
variant,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
);
}
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-title"
className={cn(
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
className,
)}
{...props}
/>
);
}
function AlertDescription({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-description"
className={cn(
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
className,
)}
{...props}
/>
);
}
export { Alert, AlertTitle, AlertDescription };
+46
View File
@@ -0,0 +1,46 @@
import { Slot } from "@radix-ui/react-slot";
import { type VariantProps, cva } from "class-variance-authority";
import type * as React from "react";
import { cn } from "@/lib/utils";
const badgeVariants = cva(
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},
},
defaultVariants: {
variant: "default",
},
},
);
function Badge({
className,
variant,
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "span";
return (
<Comp
data-slot="badge"
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
);
}
export { Badge, badgeVariants };
+61
View File
@@ -0,0 +1,61 @@
import { Slot } from "@radix-ui/react-slot";
import { type VariantProps, cva } from "class-variance-authority";
import type * as React from "react";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
destructive:
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
export type ButtonProps = React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
};
function Button({
className,
variant,
size,
asChild = false,
...props
}: ButtonProps) {
const Comp = asChild ? Slot : "button";
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
);
}
export { Button, buttonVariants };
+92
View File
@@ -0,0 +1,92 @@
import type * as React from "react";
import { cn } from "@/lib/utils";
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className,
)}
{...props}
/>
);
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className,
)}
{...props}
/>
);
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
);
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className,
)}
{...props}
/>
);
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
);
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
);
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
};
+32
View File
@@ -0,0 +1,32 @@
"use client";
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
import { LuCheck } from "react-icons/lu";
import type * as React from "react";
import { cn } from "@/lib/utils";
function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="flex items-center justify-center text-current transition-none"
>
<LuCheck className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
);
}
export { Checkbox };
+94
View File
@@ -0,0 +1,94 @@
"use client";
import * as React from "react";
import { LuCheck, LuChevronsUpDown } from "react-icons/lu";
import { Button } from "@/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { cn } from "@/lib/utils";
const frameworks = [
{
value: "next.js",
label: "Next.js",
},
{
value: "sveltekit",
label: "SvelteKit",
},
{
value: "nuxt.js",
label: "Nuxt.js",
},
{
value: "remix",
label: "Remix",
},
{
value: "astro",
label: "Astro",
},
];
export function ComboboxDemo() {
const [open, setOpen] = React.useState(false);
const [value, setValue] = React.useState("");
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="w-[200px] justify-between"
>
{value
? frameworks.find((framework) => framework.value === value)?.label
: "Select framework..."}
<LuChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[200px] p-0">
<Command>
<CommandInput placeholder="Search framework..." />
<CommandList>
<CommandEmpty>No framework found.</CommandEmpty>
<CommandGroup>
{frameworks.map((framework) => (
<CommandItem
key={framework.value}
value={framework.value}
onSelect={(currentValue) => {
setValue(currentValue === value ? "" : currentValue);
setOpen(false);
}}
>
<LuCheck
className={cn(
"mr-2 h-4 w-4",
value === framework.value ? "opacity-100" : "opacity-0",
)}
/>
{framework.label}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
+177
View File
@@ -0,0 +1,177 @@
"use client";
import { Command as CommandPrimitive } from "cmdk";
import type * as React from "react";
import { LuSearch } from "react-icons/lu";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { cn } from "@/lib/utils";
function Command({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive>) {
return (
<CommandPrimitive
data-slot="command"
className={cn(
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
className,
)}
{...props}
/>
);
}
function CommandDialog({
title = "Command Palette",
description = "Search for a command to run...",
children,
...props
}: React.ComponentProps<typeof Dialog> & {
title?: string;
description?: string;
}) {
return (
<Dialog {...props}>
<DialogHeader className="sr-only">
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogContent className="overflow-hidden p-0">
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
);
}
function CommandInput({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
return (
<div
data-slot="command-input-wrapper"
className="flex h-9 items-center gap-2 border-b px-3"
>
<LuSearch className="size-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
data-slot="command-input"
className={cn(
"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
/>
</div>
);
}
function CommandList({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.List>) {
return (
<CommandPrimitive.List
data-slot="command-list"
className={cn(
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
className,
)}
{...props}
/>
);
}
function CommandEmpty({
...props
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
return (
<CommandPrimitive.Empty
data-slot="command-empty"
className="py-6 text-center text-sm"
{...props}
/>
);
}
function CommandGroup({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
return (
<CommandPrimitive.Group
data-slot="command-group"
className={cn(
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-x-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium overflow-y-scroll",
className,
)}
{...props}
/>
);
}
function CommandSeparator({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
return (
<CommandPrimitive.Separator
data-slot="command-separator"
className={cn("bg-border -mx-1 h-px", className)}
{...props}
/>
);
}
function CommandItem({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
return (
<CommandPrimitive.Item
data-slot="command-item"
className={cn(
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
);
}
function CommandShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="command-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className,
)}
{...props}
/>
);
}
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
};
+135
View File
@@ -0,0 +1,135 @@
"use client";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import type * as React from "react";
import { RxCross2 } from "react-icons/rx";
import { cn } from "@/lib/utils";
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className,
)}
{...props}
/>
);
}
function DialogContent({
className,
children,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content>) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className,
)}
{...props}
>
{children}
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
<RxCross2 />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
);
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
);
}
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className,
)}
{...props}
/>
);
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
);
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
};
+257
View File
@@ -0,0 +1,257 @@
"use client";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { LuCheck, LuChevronRight, LuCircle } from "react-icons/lu";
import type * as React from "react";
import { cn } from "@/lib/utils";
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
);
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
);
}
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
);
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
);
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
variant?: "default" | "destructive";
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
);
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<LuCheck className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
);
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
);
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<LuCircle className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
);
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean;
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className
)}
{...props}
/>
);
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
);
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
);
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />;
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8",
className
)}
{...props}
>
{children}
<LuChevronRight className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
);
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className
)}
{...props}
/>
);
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
};
+21
View File
@@ -0,0 +1,21 @@
import type * as React from "react";
import { cn } from "@/lib/utils";
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className,
)}
{...props}
/>
);
}
export { Input };
+24
View File
@@ -0,0 +1,24 @@
"use client";
import * as LabelPrimitive from "@radix-ui/react-label";
import type * as React from "react";
import { cn } from "@/lib/utils";
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className,
)}
{...props}
/>
);
}
export { Label };
+48
View File
@@ -0,0 +1,48 @@
"use client";
import * as PopoverPrimitive from "@radix-ui/react-popover";
import type * as React from "react";
import { cn } from "@/lib/utils";
function Popover({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />;
}
function PopoverTrigger({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />;
}
function PopoverContent({
className,
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
data-slot="popover-content"
align={align}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
className,
)}
{...props}
/>
</PopoverPrimitive.Portal>
);
}
function PopoverAnchor({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />;
}
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };
+31
View File
@@ -0,0 +1,31 @@
"use client";
import * as ProgressPrimitive from "@radix-ui/react-progress";
import type * as React from "react";
import { cn } from "@/lib/utils";
function Progress({
className,
value,
...props
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
return (
<ProgressPrimitive.Root
data-slot="progress"
className={cn(
"bg-primary/20 relative h-2 w-full overflow-hidden rounded-full",
className,
)}
{...props}
>
<ProgressPrimitive.Indicator
data-slot="progress-indicator"
className="bg-primary h-full w-full flex-1 transition-all"
style={{ transform: `translateX(-${100 - (value ?? 0)}%)` }}
/>
</ProgressPrimitive.Root>
);
}
export { Progress };
+58
View File
@@ -0,0 +1,58 @@
"use client";
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
import type * as React from "react";
import { cn } from "@/lib/utils";
function ScrollArea({
className,
children,
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn("relative", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
);
}
function ScrollBar({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
return (
<ScrollAreaPrimitive.ScrollAreaScrollbar
data-slot="scroll-area-scrollbar"
orientation={orientation}
className={cn(
"flex touch-none p-px transition-colors select-none",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent",
className,
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb
data-slot="scroll-area-thumb"
className="bg-border relative flex-1 rounded-full"
/>
</ScrollAreaPrimitive.ScrollAreaScrollbar>
);
}
export { ScrollArea, ScrollBar };
+185
View File
@@ -0,0 +1,185 @@
"use client";
import * as SelectPrimitive from "@radix-ui/react-select";
import { LuCheck, LuChevronDown, LuChevronUp } from "react-icons/lu";
import type * as React from "react";
import { cn } from "@/lib/utils";
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />;
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default";
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<LuChevronDown className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
);
}
function SelectContent({
className,
children,
position = "popper",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
);
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...props}
/>
);
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<span className="absolute right-2 flex size-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<LuCheck className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
);
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
{...props}
/>
);
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<LuChevronUp className="size-4" />
</SelectPrimitive.ScrollUpButton>
);
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<LuChevronDown className="size-4" />
</SelectPrimitive.ScrollDownButton>
);
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
};
+25
View File
@@ -0,0 +1,25 @@
"use client";
import { useTheme } from "next-themes";
import { Toaster as Sonner, type ToasterProps } from "sonner";
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme();
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
} as React.CSSProperties
}
{...props}
/>
);
};
export { Toaster };
+116
View File
@@ -0,0 +1,116 @@
"use client";
import type * as React from "react";
import { cn } from "@/lib/utils";
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto"
>
<table
data-slot="table"
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
);
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return (
<thead
data-slot="table-header"
className={cn("[&_tr]:border-b", className)}
{...props}
/>
);
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return (
<tbody
data-slot="table-body"
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
);
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
data-slot="table-footer"
className={cn(
"bg-muted/50 border-t font-medium [&>tr]:last:border-b-0",
className,
)}
{...props}
/>
);
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
data-slot="table-row"
className={cn(
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
className,
)}
{...props}
/>
);
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
data-slot="table-head"
className={cn(
"text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className,
)}
{...props}
/>
);
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className,
)}
{...props}
/>
);
}
function TableCaption({
className,
...props
}: React.ComponentProps<"caption">) {
return (
<caption
data-slot="table-caption"
className={cn("text-muted-foreground mt-4 text-sm", className)}
{...props}
/>
);
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
};
+61
View File
@@ -0,0 +1,61 @@
"use client";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import type * as React from "react";
import { cn } from "@/lib/utils";
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
);
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return (
<TooltipProvider>
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
</TooltipProvider>
);
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
className,
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="bg-primary fill-primary z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
);
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
+305
View File
@@ -0,0 +1,305 @@
"use client";
/* eslint-disable @typescript-eslint/no-misused-promises */
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { invoke } from "@tauri-apps/api/core";
import { toast } from "sonner";
import { useCallback, useEffect, useState } from "react";
import { FaDownload, FaTimes } from "react-icons/fa";
import { showToast } from "@/components/custom-toast";
import { getBrowserDisplayName } from "@/lib/browser-utils";
interface UpdateNotification {
id: string;
browser: string;
current_version: string;
new_version: string;
affected_profiles: string[];
is_stable_update: boolean;
timestamp: number;
}
interface UpdateNotificationProps {
notification: UpdateNotification;
onUpdate: (browser: string, newVersion: string) => Promise<void>;
onDismiss: (notificationId: string) => Promise<void>;
isUpdating?: boolean;
}
function UpdateNotificationComponent({
notification,
onUpdate,
onDismiss,
isUpdating = false,
}: UpdateNotificationProps) {
const browserDisplayName = getBrowserDisplayName(notification.browser);
const profileText =
notification.affected_profiles.length === 1
? `profile "${notification.affected_profiles[0]}"`
: `${notification.affected_profiles.length} profiles`;
const handleUpdateClick = async () => {
// Dismiss the notification immediately to close the modal
await onDismiss(notification.id);
// Then start the update process
await onUpdate(notification.browser, notification.new_version);
};
return (
<div className="flex flex-col gap-3 p-4 max-w-md bg-background border border-border rounded-lg shadow-lg">
<div className="flex items-start justify-between gap-2">
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<span className="font-semibold text-foreground">
{browserDisplayName} Update Available
</span>
<Badge
variant={notification.is_stable_update ? "default" : "secondary"}
>
{notification.is_stable_update ? "Stable" : "Beta"}
</Badge>
</div>
<div className="text-sm text-muted-foreground">
Update {profileText} from {notification.current_version} to{" "}
<span className="font-medium">{notification.new_version}</span>
</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={async () => {
await onDismiss(notification.id);
}}
className="h-6 w-6 p-0 shrink-0"
>
<FaTimes className="h-3 w-3" />
</Button>
</div>
<div className="flex items-center gap-2">
<Button
onClick={handleUpdateClick}
disabled={isUpdating}
size="sm"
className="flex items-center gap-2"
>
<FaDownload className="h-3 w-3" />
Update
</Button>
<Button
variant="outline"
onClick={async () => {
await onDismiss(notification.id);
}}
size="sm"
>
Later
</Button>
</div>
{notification.affected_profiles.length > 1 && (
<div className="text-xs text-muted-foreground">
Affected profiles: {notification.affected_profiles.join(", ")}
</div>
)}
</div>
);
}
export function useUpdateNotifications() {
const [notifications, setNotifications] = useState<UpdateNotification[]>([]);
const [updatingBrowsers, setUpdatingBrowsers] = useState<Set<string>>(
new Set()
);
const [isClient, setIsClient] = useState(false);
// Ensure we're on the client side to prevent hydration mismatches
useEffect(() => {
setIsClient(true);
}, []);
const checkForUpdates = useCallback(async () => {
if (!isClient) return; // Only run on client side
try {
const updates = await invoke<UpdateNotification[]>(
"check_for_browser_updates"
);
setNotifications(updates);
// Show toasts for new notifications - we'll define handleUpdate and handleDismiss separately
// to avoid circular dependencies
} catch (error) {
console.error("Failed to check for updates:", error);
}
}, [isClient]);
const handleUpdate = useCallback(
async (browser: string, newVersion: string) => {
try {
setUpdatingBrowsers((prev) => new Set(prev).add(browser));
const browserDisplayName = getBrowserDisplayName(browser);
// Dismiss all notifications for this browser first
const browserNotifications = notifications.filter(
(n) => n.browser === browser
);
for (const notification of browserNotifications) {
toast.dismiss(notification.id);
await invoke("dismiss_update_notification", {
notificationId: notification.id,
});
}
try {
// Check if browser already exists before downloading
const isDownloaded = await invoke<boolean>("check_browser_exists", {
browserStr: browser,
version: newVersion,
});
if (isDownloaded) {
// Browser already exists, skip download and go straight to profile update
console.log(
`${browserDisplayName} ${newVersion} already exists, skipping download`
);
} else {
// Mark download as auto-update in the backend for toast suppression
await invoke("mark_auto_update_download", {
browser,
version: newVersion,
});
// Download the browser (progress will be handled by use-browser-download hook)
await invoke("download_browser", {
browserStr: browser,
version: newVersion,
});
}
// Complete the update with auto-update of profile versions
const updatedProfiles = await invoke<string[]>(
"complete_browser_update_with_auto_update",
{
browser,
newVersion,
}
);
// Show success message based on whether profiles were updated
if (updatedProfiles.length > 0) {
const profileText =
updatedProfiles.length === 1
? `Profile "${updatedProfiles[0]}" has been updated`
: `${updatedProfiles.length} profiles have been updated`;
showToast({
type: "success",
title: `${browserDisplayName} update completed`,
description: `${profileText} to version ${newVersion}. Running profiles were not updated and can be updated manually.`,
duration: 5000,
});
} else {
showToast({
type: "success",
title: `${browserDisplayName} update ready`,
description:
"All affected profiles are currently running. Stop them and manually update their versions to use the new version.",
duration: 5000,
});
}
} catch (downloadError) {
console.error("Failed to download browser:", downloadError);
// Clean up auto-update tracking on error
try {
await invoke("remove_auto_update_download", {
browser,
version: newVersion,
});
} catch (e) {
console.error("Failed to clean up auto-update tracking:", e);
}
showToast({
type: "error",
title: `Failed to download ${browserDisplayName} ${newVersion}`,
description: String(downloadError),
duration: 6000,
});
throw downloadError;
}
// Refresh notifications to clear any remaining ones
await checkForUpdates();
} catch (error) {
console.error("Failed to start update:", error);
const browserDisplayName = getBrowserDisplayName(browser);
showToast({
type: "error",
title: `Failed to update ${browserDisplayName}`,
description: String(error),
duration: 6000,
});
} finally {
setUpdatingBrowsers((prev) => {
const next = new Set(prev);
next.delete(browser);
return next;
});
}
},
[notifications, checkForUpdates]
);
const handleDismiss = useCallback(
async (notificationId: string) => {
if (!isClient) return; // Only run on client side
try {
toast.dismiss(notificationId);
await invoke("dismiss_update_notification", { notificationId });
await checkForUpdates();
} catch (error) {
console.error("Failed to dismiss notification:", error);
}
},
[checkForUpdates, isClient]
);
// Separate effect to show toasts when notifications change
useEffect(() => {
if (!isClient) return;
notifications.forEach((notification) => {
const isUpdating = updatingBrowsers.has(notification.browser);
toast.custom(
() => (
<UpdateNotificationComponent
notification={notification}
onUpdate={handleUpdate}
onDismiss={handleDismiss}
isUpdating={isUpdating}
/>
),
{
id: notification.id,
duration: Infinity, // Persistent until user action
position: "top-right",
// Remove transparent styling to fix background issue
style: undefined,
}
);
});
}, [notifications, updatingBrowsers, handleUpdate, handleDismiss, isClient]);
return {
notifications,
checkForUpdates,
isUpdating: (browser: string) => updatingBrowsers.has(browser),
};
}
+156
View File
@@ -0,0 +1,156 @@
"use client";
import { LoadingButton } from "@/components/loading-button";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { cn } from "@/lib/utils";
import { LuDownload } from "react-icons/lu";
import { useState } from "react";
import { LuCheck, LuChevronsUpDown } from "react-icons/lu";
import { ScrollArea } from "./ui/scroll-area";
interface GithubRelease {
tag_name: string;
assets: Array<{
name: string;
browser_download_url: string;
hash?: string;
}>;
published_at: string;
is_alpha: boolean;
}
interface VersionSelectorProps {
selectedVersion: string | null;
onVersionSelect: (version: string | null) => void;
availableVersions: GithubRelease[];
downloadedVersions: string[];
isDownloading: boolean;
onDownload: () => void;
placeholder?: string;
showDownloadButton?: boolean;
}
export function VersionSelector({
selectedVersion,
onVersionSelect,
availableVersions,
downloadedVersions,
isDownloading,
onDownload,
placeholder = "Select version...",
showDownloadButton = true,
}: VersionSelectorProps) {
const [versionPopoverOpen, setVersionPopoverOpen] = useState(false);
const isVersionDownloaded = selectedVersion
? downloadedVersions.includes(selectedVersion)
: false;
return (
<div className="space-y-4">
<Popover
open={versionPopoverOpen}
onOpenChange={setVersionPopoverOpen}
modal={true}
>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={versionPopoverOpen}
className="justify-between w-full"
>
{selectedVersion ?? placeholder}
<LuChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[300px] p-0">
<Command>
<CommandInput placeholder="Search versions..." />
<CommandEmpty>No versions found.</CommandEmpty>
<CommandList>
<ScrollArea
className={
"[&>[data-radix-scroll-area-viewport]]:max-h-[200px]"
}
>
<CommandGroup>
{availableVersions.map((version) => {
const isDownloaded = downloadedVersions.includes(
version.tag_name
);
return (
<CommandItem
key={version.tag_name}
value={version.tag_name}
onSelect={(currentValue) => {
onVersionSelect(
currentValue === selectedVersion
? null
: currentValue
);
setVersionPopoverOpen(false);
}}
>
<LuCheck
className={cn(
"mr-2 h-4 w-4",
selectedVersion === version.tag_name
? "opacity-100"
: "opacity-0"
)}
/>
<div className="flex items-center gap-2">
<span>{version.tag_name}</span>
{version.is_alpha && (
<Badge variant="secondary" className="text-xs">
Alpha
</Badge>
)}
{isDownloaded && (
<Badge variant="default" className="text-xs">
Downloaded
</Badge>
)}
</div>
</CommandItem>
);
})}
</CommandGroup>
</ScrollArea>
</CommandList>
</Command>
</PopoverContent>
</Popover>
{/* Download Button */}
{showDownloadButton && selectedVersion && !isVersionDownloaded && (
<LoadingButton
isLoading={isDownloading}
onClick={() => {
onDownload();
}}
variant="outline"
className="w-full"
>
<LuDownload className="mr-2 h-4 w-4" />
{isDownloading ? "Downloading..." : "Download Browser"}
</LoadingButton>
)}
</div>
);
}
+130
View File
@@ -0,0 +1,130 @@
"use client";
import { LoadingButton } from "@/components/loading-button";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { useVersionUpdater } from "@/hooks/use-version-updater";
import {
LuRefreshCw,
LuClock,
LuCheckCheck,
LuCircleAlert,
} from "react-icons/lu";
export function VersionUpdateSettings() {
const {
isUpdating,
lastUpdateTime,
timeUntilNextUpdate,
updateProgress,
triggerManualUpdate,
formatTimeUntilUpdate,
formatLastUpdateTime,
} = useVersionUpdater();
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<LuRefreshCw className="h-5 w-5" />
Background Version Updates
</CardTitle>
<CardDescription>
Browser versions are automatically checked every 3 hours in the
background. New versions are cached and ready when you need them.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Current Status */}
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm font-medium">
<LuClock className="h-4 w-4" />
Last Update
</div>
<div className="text-sm text-muted-foreground">
{formatLastUpdateTime(lastUpdateTime)}
</div>
</div>
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm font-medium">
<LuCheckCheck className="h-4 w-4" />
Next Update
</div>
<div className="text-sm text-muted-foreground">
{timeUntilNextUpdate <= 0
? "Now"
: `In ${formatTimeUntilUpdate(timeUntilNextUpdate)}`}
</div>
</div>
</div>
{/* Progress indicator */}
{isUpdating && updateProgress && (
<Alert>
<LuRefreshCw className="h-4 w-4 animate-spin" />
<AlertTitle>Updating Browser Versions</AlertTitle>
<AlertDescription>
{updateProgress.current_browser ? (
<>
Checking {updateProgress.current_browser} (
{updateProgress.completed_browsers}/
{updateProgress.total_browsers})
<br />
{updateProgress.new_versions_found} new versions found so far
</>
) : (
"Starting version update..."
)}
</AlertDescription>
</Alert>
)}
{/* Manual update button */}
<div className="flex items-center justify-between pt-2 border-t">
<div className="space-y-1">
<div className="text-sm font-medium">Manual Update</div>
<div className="text-xs text-muted-foreground">
Check for new browser versions now
</div>
</div>
<LoadingButton
isLoading={isUpdating}
onClick={() => {
void triggerManualUpdate();
}}
variant="outline"
size="sm"
disabled={isUpdating}
>
<LuRefreshCw className="h-4 w-4 mr-2" />
{isUpdating ? "Updating..." : "Check Now"}
</LoadingButton>
</div>
{/* Information about background updates */}
<Alert>
<LuCircleAlert className="h-4 w-4" />
<AlertTitle>How it works</AlertTitle>
<AlertDescription className="text-xs">
Version information is checked automatically every 3 hours
<br />
New versions are added to the cache without removing old ones
<br />
When creating profiles or changing versions, you&apos;ll see how
many new versions were found
<br /> This keeps the app responsive while ensuring you have the
latest information
</AlertDescription>
</Alert>
</CardContent>
</Card>
);
}
+372
View File
@@ -0,0 +1,372 @@
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { useCallback, useEffect, useState } from "react";
import { toast } from "sonner";
import {
showDownloadToast,
showFetchingToast,
showSuccessToast,
showErrorToast,
dismissToast,
} from "../components/custom-toast";
import { getBrowserDisplayName } from "@/lib/browser-utils";
interface GithubRelease {
tag_name: string;
assets: Array<{
name: string;
browser_download_url: string;
hash?: string;
}>;
published_at: string;
is_alpha: boolean;
}
interface BrowserVersionInfo {
version: string;
is_prerelease: boolean;
date: string;
}
interface DownloadProgress {
browser: string;
version: string;
downloaded_bytes: number;
total_bytes?: number;
percentage: number;
speed_bytes_per_sec: number;
eta_seconds?: number;
stage: string;
}
interface BrowserVersionsResult {
versions: string[];
new_versions_count?: number;
total_versions_count: number;
}
interface VersionUpdateProgress {
current_browser: string;
total_browsers: number;
completed_browsers: number;
new_versions_found: number;
browser_new_versions: number;
status: string;
}
const isAlphaVersion = (version: string): boolean => {
// Check for common alpha/beta/dev indicators
const lowerVersion = version.toLowerCase();
return (
lowerVersion.includes("a") ||
lowerVersion.includes("b") ||
lowerVersion.includes("alpha") ||
lowerVersion.includes("beta") ||
lowerVersion.includes("dev") ||
lowerVersion.includes("rc") ||
lowerVersion.includes("pre") ||
// Check for patterns like "139.0b1" or "140.0a1"
/\d+\.\d+[ab]\d+/.test(lowerVersion)
);
};
export function useBrowserDownload() {
const [availableVersions, setAvailableVersions] = useState<GithubRelease[]>(
[]
);
const [downloadedVersions, setDownloadedVersions] = useState<string[]>([]);
const [isDownloading, setIsDownloading] = useState(false);
const [downloadProgress, setDownloadProgress] =
useState<DownloadProgress | null>(null);
const [isUpdatingVersions, setIsUpdatingVersions] = useState(false);
// Listen for download progress events
useEffect(() => {
const unlisten = listen<DownloadProgress>("download-progress", (event) => {
const progress = event.payload;
setDownloadProgress(progress);
const browserName = getBrowserDisplayName(progress.browser);
// Check if this is an auto-update download to suppress completion toast
const checkAutoUpdate = async () => {
let isAutoUpdate = false;
try {
isAutoUpdate = await invoke<boolean>("is_auto_update_download", {
browser: progress.browser,
version: progress.version,
});
} catch (error) {
console.error("Failed to check auto-update status:", error);
}
// Show toast with progress
if (progress.stage === "downloading") {
const speedMBps = (
progress.speed_bytes_per_sec /
(1024 * 1024)
).toFixed(1);
const etaText = progress.eta_seconds
? formatTime(progress.eta_seconds)
: "calculating...";
showDownloadToast(browserName, progress.version, "downloading", {
percentage: progress.percentage,
speed: speedMBps,
eta: etaText,
});
} else if (progress.stage === "extracting") {
showDownloadToast(browserName, progress.version, "extracting");
} else if (progress.stage === "verifying") {
showDownloadToast(browserName, progress.version, "verifying");
} else if (progress.stage === "completed") {
// Suppress completion toast for auto-updates
showDownloadToast(
browserName,
progress.version,
"completed",
undefined,
{
suppressCompletionToast: isAutoUpdate,
}
);
setDownloadProgress(null);
}
};
void checkAutoUpdate();
});
return () => {
void unlisten.then((fn) => {
fn();
});
};
}, []);
// Listen for version update progress events
useEffect(() => {
const unlisten = listen<VersionUpdateProgress>(
"version-update-progress",
(event) => {
const progress = event.payload;
if (progress.status === "updating") {
setIsUpdatingVersions(true);
if (progress.current_browser) {
const browserName = getBrowserDisplayName(progress.current_browser);
showFetchingToast(browserName, {
id: `version-update-${progress.current_browser}`,
description: "Fetching latest release information...",
});
}
} else if (progress.status === "completed") {
setIsUpdatingVersions(false);
if (progress.new_versions_found > 0) {
showSuccessToast(
`Found ${progress.new_versions_found} new browser versions!`,
{
duration: 3000,
}
);
}
// Dismiss any update toasts
toast.dismiss();
} else if (progress.status === "error") {
setIsUpdatingVersions(false);
showErrorToast("Failed to check for new versions", {
duration: 4000,
});
toast.dismiss();
}
}
);
return () => {
void unlisten.then((fn) => {
fn();
});
};
}, []);
const formatTime = (seconds: number): string => {
if (seconds < 60) {
return `${Math.round(seconds)}s`;
} else if (seconds < 3600) {
const minutes = Math.floor(seconds / 60);
const remainingSeconds = Math.round(seconds % 60);
return `${minutes}m ${remainingSeconds}s`;
} else {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
return `${hours}h ${minutes}m`;
}
};
const formatBytes = (bytes: number): string => {
if (bytes === 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${Number.parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${
sizes[i]
}`;
};
const loadVersions = useCallback(async (browserStr: string) => {
const browserName = getBrowserDisplayName(browserStr);
// Show fetching toast
const toastId = showFetchingToast(browserName, {
id: `fetch-${browserStr}`,
});
try {
const versionInfos = await invoke<BrowserVersionInfo[]>(
"fetch_browser_versions_cached_first",
{ browserStr }
);
// Convert BrowserVersionInfo to GithubRelease format for compatibility
const githubReleases: GithubRelease[] = versionInfos.map(
(versionInfo) => ({
tag_name: versionInfo.version,
assets: [],
published_at: versionInfo.date,
is_alpha: versionInfo.is_prerelease,
})
);
setAvailableVersions(githubReleases);
dismissToast(toastId);
return githubReleases;
} catch (error) {
console.error("Failed to load versions:", error);
dismissToast(toastId);
showErrorToast(`Failed to fetch ${browserName} versions`, {
description:
error instanceof Error ? error.message : "Unknown error occurred",
duration: 4000,
});
throw error;
}
}, []);
const loadVersionsWithNewCount = useCallback(async (browserStr: string) => {
const browserName = getBrowserDisplayName(browserStr);
try {
// Get versions with new count info and cached detailed info
const result = await invoke<BrowserVersionsResult>(
"fetch_browser_versions_with_count_cached_first",
{ browserStr }
);
// Get detailed version info for compatibility
const versionInfos = await invoke<BrowserVersionInfo[]>(
"fetch_browser_versions_cached_first",
{ browserStr }
);
// Convert BrowserVersionInfo to GithubRelease format for compatibility
const githubReleases: GithubRelease[] = versionInfos.map(
(versionInfo) => ({
tag_name: versionInfo.version,
assets: [],
published_at: versionInfo.date,
is_alpha: versionInfo.is_prerelease,
})
);
setAvailableVersions(githubReleases);
// Show notification about new versions if any were found
if (result.new_versions_count && result.new_versions_count > 0) {
showSuccessToast(
`Found ${result.new_versions_count} new ${browserName} versions!`,
{
duration: 3000,
description: `Total available: ${result.total_versions_count} versions`,
}
);
}
return githubReleases;
} catch (error) {
console.error("Failed to load versions:", error);
showErrorToast(`Failed to fetch ${browserName} versions`, {
description:
error instanceof Error ? error.message : "Unknown error occurred",
duration: 4000,
});
throw error;
}
}, []);
const loadDownloadedVersions = useCallback(async (browserStr: string) => {
try {
const downloadedVersions = await invoke<string[]>(
"get_downloaded_browser_versions",
{ browserStr }
);
setDownloadedVersions(downloadedVersions);
return downloadedVersions;
} catch (error) {
console.error("Failed to load downloaded versions:", error);
throw error;
}
}, []);
const downloadBrowser = useCallback(
async (
browserStr: string,
version: string,
suppressNotifications: boolean = false
) => {
const browserName = getBrowserDisplayName(browserStr);
setIsDownloading(true);
try {
await invoke("download_browser", { browserStr, version });
await loadDownloadedVersions(browserStr);
} catch (error) {
console.error("Failed to download browser:", error);
if (!suppressNotifications) {
// Dismiss any existing download toast and show error
dismissToast(`download-${browserStr}-${version}`);
showErrorToast(`Failed to download ${browserName} ${version}`, {
description:
error instanceof Error ? error.message : "Unknown error occurred",
});
}
throw error;
} finally {
setIsDownloading(false);
}
},
[loadDownloadedVersions]
);
const isVersionDownloaded = useCallback(
(version: string) => {
return downloadedVersions.includes(version);
},
[downloadedVersions]
);
return {
availableVersions,
downloadedVersions,
isDownloading,
downloadProgress,
isUpdatingVersions,
loadVersions,
loadVersionsWithNewCount,
loadDownloadedVersions,
downloadBrowser,
isVersionDownloaded,
formatBytes,
formatTime,
};
}
+79
View File
@@ -0,0 +1,79 @@
import { invoke } from "@tauri-apps/api/core";
import { useCallback, useEffect, useState } from "react";
import type { TableSortingSettings } from "@/types";
import type { SortingState } from "@tanstack/react-table";
export function useTableSorting() {
const [sortingSettings, setSortingSettings] = useState<TableSortingSettings>({
column: "name",
direction: "asc",
});
const [isLoaded, setIsLoaded] = useState(false);
// Load sorting settings on mount
useEffect(() => {
const loadSettings = async () => {
try {
const settings = await invoke<TableSortingSettings>(
"get_table_sorting_settings"
);
setSortingSettings(settings);
} catch (error) {
console.error("Failed to load table sorting settings:", error);
// Keep default settings if loading fails
} finally {
setIsLoaded(true);
}
};
void loadSettings();
}, []);
// Save sorting settings to disk
const saveSortingSettings = useCallback(
async (settings: TableSortingSettings) => {
try {
await invoke("save_table_sorting_settings", { sorting: settings });
setSortingSettings(settings);
} catch (error) {
console.error("Failed to save table sorting settings:", error);
}
},
[]
);
// Convert our settings to tanstack table sorting format
const getTableSorting = useCallback((): SortingState => {
if (!isLoaded) return [];
return [
{
id: sortingSettings.column,
desc: sortingSettings.direction === "desc",
},
];
}, [sortingSettings, isLoaded]);
// Update sorting when table state changes
const updateSorting = useCallback(
(sorting: SortingState) => {
if (!isLoaded) return;
if (sorting.length > 0) {
const newSettings: TableSortingSettings = {
column: sorting[0].id,
direction: sorting[0].desc ? "desc" : "asc",
};
void saveSortingSettings(newSettings);
}
},
[saveSortingSettings, isLoaded]
);
return {
sortingSettings,
isLoaded,
getTableSorting,
updateSorting,
};
}
+268
View File
@@ -0,0 +1,268 @@
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { useCallback, useEffect, useState } from "react";
import { toast } from "sonner";
import {
showVersionUpdateToast,
showLoadingToast,
dismissToast,
} from "../components/custom-toast";
import { getBrowserDisplayName } from "@/lib/browser-utils";
interface VersionUpdateProgress {
current_browser: string;
total_browsers: number;
completed_browsers: number;
new_versions_found: number;
browser_new_versions: number;
status: string; // "updating", "completed", "error"
}
interface BackgroundUpdateResult {
browser: string;
new_versions_count: number;
total_versions_count: number;
updated_successfully: boolean;
error?: string;
}
interface BrowserVersionsResult {
versions: string[];
new_versions_count?: number;
total_versions_count: number;
}
export function useVersionUpdater() {
const [isUpdating, setIsUpdating] = useState(false);
const [lastUpdateTime, setLastUpdateTime] = useState<number | null>(null);
const [timeUntilNextUpdate, setTimeUntilNextUpdate] = useState<number>(0);
const [updateProgress, setUpdateProgress] =
useState<VersionUpdateProgress | null>(null);
// Listen for version update progress events
useEffect(() => {
const unlisten = listen<VersionUpdateProgress>(
"version-update-progress",
(event) => {
const progress = event.payload;
setUpdateProgress(progress);
if (progress.status === "updating") {
setIsUpdating(true);
if (progress.current_browser) {
const browserName = getBrowserDisplayName(progress.current_browser);
showVersionUpdateToast(
`Downloading release information for ${browserName}`,
{
id: "version-update-progress",
progress: {
current: progress.completed_browsers + 1,
total: progress.total_browsers,
found: progress.new_versions_found,
},
}
);
} else {
showLoadingToast("Starting version update check...", {
id: "version-update-progress",
description: "Initializing browser version check...",
});
}
} else if (progress.status === "completed") {
setIsUpdating(false);
setUpdateProgress(null);
if (progress.new_versions_found > 0) {
toast.success(
`Found ${progress.new_versions_found} new browser versions!`,
{
id: "version-update-progress",
duration: 4000,
description:
"Version information has been updated in the background",
}
);
} else {
toast.success("No new browser versions found", {
id: "version-update-progress",
duration: 3000,
description: "All browser versions are up to date",
});
}
// Refresh status
void loadUpdateStatus();
} else if (progress.status === "error") {
setIsUpdating(false);
setUpdateProgress(null);
toast.error("Failed to update browser versions", {
id: "version-update-progress",
duration: 4000,
description: "Check your internet connection and try again",
});
}
}
);
return () => {
void unlisten.then((fn) => {
fn();
});
};
}, []);
// Load update status on mount and periodically
useEffect(() => {
void loadUpdateStatus();
// Update status every minute
const interval = setInterval(() => {
void loadUpdateStatus();
}, 60000);
return () => {
clearInterval(interval);
};
}, []);
const loadUpdateStatus = useCallback(async () => {
try {
const [lastUpdate, timeUntilNext] = await invoke<[number | null, number]>(
"get_version_update_status"
);
setLastUpdateTime(lastUpdate);
setTimeUntilNextUpdate(timeUntilNext);
} catch (error) {
console.error("Failed to load version update status:", error);
}
}, []);
const triggerManualUpdate = useCallback(async () => {
try {
setIsUpdating(true);
const results = await invoke<BackgroundUpdateResult[]>(
"trigger_manual_version_update"
);
const totalNewVersions = results.reduce(
(sum, result) => sum + result.new_versions_count,
0
);
const successfulUpdates = results.filter(
(r) => r.updated_successfully
).length;
const failedUpdates = results.filter(
(r) => !r.updated_successfully
).length;
if (failedUpdates > 0) {
toast.warning(`Update completed with some errors`, {
description: `${totalNewVersions} new versions found, ${failedUpdates} browsers failed to update`,
duration: 5000,
});
} else if (totalNewVersions > 0) {
toast.success(`Found ${totalNewVersions} new browser versions!`, {
description: `Updated ${successfulUpdates} browsers successfully`,
duration: 4000,
});
} else {
toast.success("No new browser versions found", {
description: "All browser versions are up to date",
duration: 3000,
});
}
await loadUpdateStatus();
return results;
} catch (error) {
console.error("Failed to trigger manual update:", error);
toast.error("Failed to update browser versions", {
description:
error instanceof Error ? error.message : "Unknown error occurred",
duration: 4000,
});
throw error;
} finally {
setIsUpdating(false);
}
}, [loadUpdateStatus]);
const fetchBrowserVersionsWithNewCount = useCallback(
async (browserStr: string) => {
try {
const result = await invoke<BrowserVersionsResult>(
"fetch_browser_versions_with_count",
{ browserStr }
);
// Show notification about new versions if any were found
if (result.new_versions_count && result.new_versions_count > 0) {
const browserName = getBrowserDisplayName(browserStr);
toast.success(
`Found ${result.new_versions_count} new ${browserName} versions!`,
{
duration: 3000,
description: `Total available: ${result.total_versions_count} versions`,
}
);
}
return result;
} catch (error) {
console.error("Failed to fetch browser versions with count:", error);
throw error;
}
},
[]
);
const formatTimeUntilUpdate = useCallback((seconds: number): string => {
if (seconds <= 0) return "Update overdue";
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
if (hours > 0) {
return `${hours}h ${minutes}m`;
} else if (minutes > 0) {
return `${minutes}m`;
} else {
return "< 1m";
}
}, []);
const formatLastUpdateTime = useCallback(
(timestamp: number | null): string => {
if (!timestamp) return "Never";
const date = new Date(timestamp * 1000);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
const diffMinutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60));
if (diffHours > 0) {
return `${diffHours}h ${diffMinutes}m ago`;
} else if (diffMinutes > 0) {
return `${diffMinutes}m ago`;
} else {
return "Just now";
}
},
[]
);
return {
isUpdating,
lastUpdateTime,
timeUntilNextUpdate,
updateProgress,
triggerManualUpdate,
fetchBrowserVersionsWithNewCount,
formatTimeUntilUpdate,
formatLastUpdateTime,
loadUpdateStatus,
};
}
+58
View File
@@ -0,0 +1,58 @@
/**
* Browser utility functions
* Centralized helpers for browser name mapping, icons, etc.
*/
import { SiMullvad, SiBrave, SiTorbrowser } from "react-icons/si";
import { FaChrome, FaFirefox } from "react-icons/fa";
/**
* Map internal browser names to display names
*/
export function getBrowserDisplayName(browserType: string): string {
const browserNames: Record<string, string> = {
firefox: "Firefox",
"firefox-developer": "Firefox Developer Edition",
"mullvad-browser": "Mullvad Browser",
zen: "Zen Browser",
brave: "Brave",
chromium: "Chromium",
"tor-browser": "Tor Browser",
};
return browserNames[browserType] || browserType;
}
/**
* Get the appropriate icon component for a browser type
*/
export function getBrowserIcon(browserType: string) {
switch (browserType) {
case "mullvad-browser":
return SiMullvad;
case "chromium":
return FaChrome;
case "brave":
return SiBrave;
case "firefox":
case "firefox-developer":
return FaFirefox;
case "zen":
return FaFirefox;
case "tor-browser":
return SiTorbrowser;
default:
return null;
}
}
/**
* Format browser name by capitalizing words and joining with spaces
* (fallback method for simple transformations)
*/
export function formatBrowserName(browserType: string): string {
return browserType
.split("-")
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(" ");
}
+6
View File
@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
+125
View File
@@ -0,0 +1,125 @@
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
@theme {
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.141 0.005 285.823);
--card: oklch(1 0 0);
--card-foreground: oklch(0.141 0.005 285.823);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.141 0.005 285.823);
--primary: oklch(0.21 0.006 285.885);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.967 0.001 286.375);
--secondary-foreground: oklch(0.21 0.006 285.885);
--muted: oklch(0.967 0.001 286.375);
--muted-foreground: oklch(0.552 0.016 285.938);
--accent: oklch(0.967 0.001 286.375);
--accent-foreground: oklch(0.21 0.006 285.885);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.92 0.004 286.32);
--input: oklch(0.92 0.004 286.32);
--ring: oklch(0.705 0.015 286.067);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.141 0.005 285.823);
--sidebar-primary: oklch(0.21 0.006 285.885);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.967 0.001 286.375);
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
--sidebar-border: oklch(0.92 0.004 286.32);
--sidebar-ring: oklch(0.705 0.015 286.067);
}
.dark {
--background: oklch(0.141 0.005 285.823);
--foreground: oklch(0.985 0 0);
--card: oklch(0.21 0.006 285.885);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.21 0.006 285.885);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.92 0.004 286.32);
--primary-foreground: oklch(0.21 0.006 285.885);
--secondary: oklch(0.274 0.006 286.033);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.274 0.006 286.033);
--muted-foreground: oklch(0.705 0.015 286.067);
--accent: oklch(0.274 0.006 286.033);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.552 0.016 285.938);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.21 0.006 285.885);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.274 0.006 286.033);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.552 0.016 285.938);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}
+21
View File
@@ -0,0 +1,21 @@
export interface ProxySettings {
enabled: boolean;
proxy_type: string; // "http", "https", "socks4", or "socks5"
host: string;
port: number;
}
export interface TableSortingSettings {
column: string; // "name", "browser", "status"
direction: string; // "asc" or "desc"
}
export interface BrowserProfile {
name: string;
browser: string;
version: string;
profile_path: string;
proxy?: ProxySettings;
process_id?: number;
last_launch?: number;
}