feat: automatically update browsers on new versions

This commit is contained in:
zhom
2025-06-14 19:03:02 +04:00
parent 5a3fb7b2b0
commit 95cd2426c3
6 changed files with 148 additions and 259 deletions
+43 -4
View File
@@ -10,6 +10,7 @@
* - Progress bars for downloads/updates
* - Success/error states
* - Customizable icons and content
* - Auto-update notifications
*
* Usage Examples:
*
@@ -23,6 +24,11 @@
* });
* ```
*
* Auto-update toast:
* ```
* showAutoUpdateToast("Firefox", "125.0.1");
* ```
*
* Download progress toast:
* ```
* showToast({
@@ -47,6 +53,7 @@ import {
LuCheckCheck,
LuDownload,
LuRefreshCw,
LuRocket,
LuTriangleAlert,
} from "react-icons/lu";
@@ -139,6 +146,10 @@ function getToastIcon(type: ToastProps["type"], stage?: string) {
return (
<LuRefreshCw className="flex-shrink-0 w-4 h-4 text-purple-500 animate-spin" />
);
case "loading":
return (
<div className="flex-shrink-0 w-4 h-4 rounded-full border-2 border-blue-500 animate-spin border-t-transparent" />
);
default:
return (
<div className="flex-shrink-0 w-4 h-4 rounded-full border-2 border-blue-500 animate-spin border-t-transparent" />
@@ -151,11 +162,33 @@ export function UnifiedToast(props: ToastProps) {
const stage = "stage" in props ? props.stage : undefined;
const progress = "progress" in props ? props.progress : undefined;
// Check if this is an auto-update toast
const isAutoUpdate = title.includes("update started");
return (
<div className="flex items-start p-3 w-96 bg-white rounded-lg border border-gray-200 shadow-lg dark:bg-gray-800 dark:border-gray-700">
<div className="mr-3 mt-0.5">{getToastIcon(type, stage)}</div>
<div
className={`flex items-start p-3 w-96 rounded-lg border shadow-lg ${
isAutoUpdate
? "bg-emerald-50 border-emerald-200 dark:bg-emerald-950 dark:border-emerald-800"
: "bg-white border-gray-200 dark:bg-gray-800 dark:border-gray-700"
}`}
data-toast-type={isAutoUpdate ? "auto-update" : "default"}
>
<div className="mr-3 mt-0.5">
{isAutoUpdate ? (
<LuRocket className="flex-shrink-0 w-4 h-4 text-emerald-500" />
) : (
getToastIcon(type, stage)
)}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium leading-tight text-gray-900 dark:text-white">
<p
className={`text-sm font-medium leading-tight ${
isAutoUpdate
? "text-emerald-900 dark:text-emerald-100"
: "text-gray-900 dark:text-white"
}`}
>
{title}
</p>
@@ -225,7 +258,13 @@ export function UnifiedToast(props: ToastProps) {
{/* Description */}
{description && (
<p className="mt-1 text-xs leading-tight text-gray-600 dark:text-gray-300">
<p
className={`mt-1 text-xs leading-tight ${
isAutoUpdate
? "text-emerald-700 dark:text-emerald-300"
: "text-gray-600 dark:text-gray-300"
}`}
>
{description}
</p>
)}
-65
View File
@@ -1,6 +1,5 @@
"use client";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
@@ -275,70 +274,6 @@ export function ProfilesDataTable({
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="p-0 h-auto font-semibold hover:bg-transparent"
>
Status
{isSorted === "asc" && <LuChevronUp className="ml-2 w-4 h-4" />}
{isSorted === "desc" && (
<LuChevronDown className="ml-2 w-4 h-4" />
)}
{!isSorted && (
<LuChevronDown className="ml-2 w-4 h-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",
+2 -83
View File
@@ -3,9 +3,7 @@ import {
dismissToast,
showDownloadToast,
showErrorToast,
showFetchingToast,
showSuccessToast,
showUnifiedVersionUpdateToast,
} from "@/lib/toast-utils";
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
@@ -45,15 +43,6 @@ interface BrowserVersionsResult {
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;
}
export function useBrowserDownload() {
const [availableVersions, setAvailableVersions] = useState<GithubRelease[]>(
[],
@@ -62,7 +51,6 @@ export function useBrowserDownload() {
const [isDownloading, setIsDownloading] = useState(false);
const [downloadProgress, setDownloadProgress] =
useState<DownloadProgress | null>(null);
const [isUpdatingVersions, setIsUpdatingVersions] = useState(false);
// Listen for download progress events
useEffect(() => {
@@ -128,70 +116,6 @@ export function useBrowserDownload() {
};
}, []);
// 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);
// Show unified progress toast
const currentBrowserName = progress.current_browser
? getBrowserDisplayName(progress.current_browser)
: undefined;
showUnifiedVersionUpdateToast("Checking for browser updates...", {
description: currentBrowserName
? `Fetching ${currentBrowserName} release information...`
: "Initializing version check...",
progress: {
current: progress.completed_browsers,
total: progress.total_browsers,
found: progress.new_versions_found,
current_browser: currentBrowserName,
},
});
} else if (progress.status === "completed") {
setIsUpdatingVersions(false);
dismissToast("unified-version-update");
if (progress.new_versions_found > 0) {
showSuccessToast(
`Found ${progress.new_versions_found} new browser versions!`,
{
duration: 4000,
description:
"Version information has been updated in the background",
},
);
} else {
showSuccessToast("No new browser versions found", {
duration: 3000,
description: "All browser versions are up to date",
});
}
} else if (progress.status === "error") {
setIsUpdatingVersions(false);
dismissToast("unified-version-update");
showErrorToast("Failed to check for new versions", {
duration: 4000,
description: "Check your internet connection and try again",
});
}
},
);
return () => {
void unlisten.then((fn) => {
fn();
});
};
}, []);
const formatTime = (seconds: number): string => {
if (seconds < 60) {
return `${Math.round(seconds)}s`;
@@ -217,10 +141,8 @@ export function useBrowserDownload() {
const loadVersions = useCallback(async (browserStr: string) => {
const browserName = getBrowserDisplayName(browserStr);
// Show fetching toast
const toastId = showFetchingToast(browserName, {
id: `fetch-${browserStr}`,
});
// Use a simple loading state instead of toast for version fetching
console.log(`Fetching ${browserName} versions...`);
try {
const versionInfos = await invoke<BrowserVersionInfo[]>(
@@ -239,11 +161,9 @@ export function useBrowserDownload() {
);
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",
@@ -377,7 +297,6 @@ export function useBrowserDownload() {
downloadedVersions,
isDownloading,
downloadProgress,
isUpdatingVersions,
loadVersions,
loadVersionsWithNewCount,
loadDownloadedVersions,
+78 -99
View File
@@ -1,9 +1,7 @@
import { UpdateNotificationComponent } from "@/components/update-notification";
import { getBrowserDisplayName } from "@/lib/browser-utils";
import { showToast } from "@/lib/toast-utils";
import { showAutoUpdateToast, showToast } from "@/lib/toast-utils";
import { invoke } from "@tauri-apps/api/core";
import React, { useCallback, useEffect, useState } from "react";
import { toast } from "sonner";
import { useCallback, useEffect, useRef, useState } from "react";
interface UpdateNotification {
id: string;
@@ -23,73 +21,82 @@ export function useUpdateNotifications(
const [updatingBrowsers, setUpdatingBrowsers] = useState<Set<string>>(
new Set(),
);
const [dismissedNotifications, setDismissedNotifications] = useState<
const [processedNotifications, setProcessedNotifications] = useState<
Set<string>
>(new Set());
// Add refs to track ongoing operations to prevent duplicates
const isCheckingForUpdates = useRef(false);
const activeDownloads = useRef<Set<string>>(new Set()); // Track "browser-version" keys
const checkForUpdates = useCallback(async () => {
// Prevent multiple simultaneous calls
if (isCheckingForUpdates.current) {
console.log("Already checking for updates, skipping duplicate call");
return;
}
isCheckingForUpdates.current = true;
try {
const updates = await invoke<UpdateNotification[]>(
"check_for_browser_updates",
);
// Filter out dismissed notifications unless they're for a newer version
const filteredUpdates = updates.filter((notification) => {
// Check if this exact notification was dismissed
if (dismissedNotifications.has(notification.id)) {
return false;
}
// Check if we dismissed an older version for this browser
const dismissedForBrowser = Array.from(dismissedNotifications).find(
(dismissedId) => {
const parts = dismissedId.split("_");
if (parts.length >= 2) {
const browser = parts[0];
return browser === notification.browser;
}
return false;
},
);
if (dismissedForBrowser) {
// Extract the dismissed version to compare
const dismissedParts = dismissedForBrowser.split("_to_");
if (dismissedParts.length === 2) {
const dismissedToVersion = dismissedParts[1];
// Only show if this is a newer version than what was dismissed
return notification.new_version !== dismissedToVersion;
}
}
return true;
// Filter out already processed notifications
const newUpdates = updates.filter((notification) => {
return !processedNotifications.has(notification.id);
});
setNotifications(filteredUpdates);
setNotifications(newUpdates);
// Show toasts for new notifications - we'll define handleUpdate and handleDismiss separately
// to avoid circular dependencies
// Automatically start downloads for new update notifications
for (const notification of newUpdates) {
if (!processedNotifications.has(notification.id)) {
setProcessedNotifications((prev) =>
new Set(prev).add(notification.id),
);
// Start automatic update without user interaction
void handleAutoUpdate(
notification.browser,
notification.new_version,
notification.id,
);
}
}
} catch (error) {
console.error("Failed to check for updates:", error);
} finally {
isCheckingForUpdates.current = false;
}
}, [dismissedNotifications]);
}, [processedNotifications]);
const handleAutoUpdate = useCallback(
async (browser: string, newVersion: string, notificationId: string) => {
const downloadKey = `${browser}-${newVersion}`;
// Check if this download is already in progress
if (activeDownloads.current.has(downloadKey)) {
console.log(
`Download already in progress for ${downloadKey}, skipping duplicate`,
);
return;
}
// Mark download as active
activeDownloads.current.add(downloadKey);
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,
});
}
// Dismiss the notification in the backend
await invoke("dismiss_update_notification", {
notificationId,
});
// Show auto-update started toast
showAutoUpdateToast(browserDisplayName, newVersion);
try {
// Check if browser already exists before downloading
@@ -134,17 +141,19 @@ export function useUpdateNotifications(
: `${updatedProfiles.length} profiles have been updated`;
showToast({
id: `auto-update-success-${browser}-${newVersion}`,
type: "success",
title: `${browserDisplayName} update completed`,
description: `${profileText} to version ${newVersion}. Running profiles were not updated and can be updated manually.`,
description: `${profileText} to version ${newVersion}. To update running profiles, restart them.`,
duration: 5000,
});
} else {
showToast({
id: `auto-update-success-${browser}-${newVersion}`,
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.",
"All affected profiles are currently running. To update them, restart them.",
duration: 5000,
});
}
@@ -167,6 +176,7 @@ export function useUpdateNotifications(
}
showToast({
id: `auto-update-error-${browser}-${newVersion}`,
type: "error",
title: `Failed to download ${browserDisplayName} ${newVersion}`,
description: String(downloadError),
@@ -175,18 +185,21 @@ export function useUpdateNotifications(
throw downloadError;
}
// Refresh notifications to clear any remaining ones
await checkForUpdates();
// Don't call checkForUpdates() again here as it can cause recursion and duplicates
// The periodic checks will handle finding any remaining updates
} catch (error) {
console.error("Failed to start update:", error);
console.error("Failed to start auto-update:", error);
const browserDisplayName = getBrowserDisplayName(browser);
showToast({
id: `auto-update-error-${browser}-${newVersion}`,
type: "error",
title: `Failed to update ${browserDisplayName}`,
description: String(error),
duration: 6000,
});
} finally {
// Remove from active downloads and updating browsers
activeDownloads.current.delete(downloadKey);
setUpdatingBrowsers((prev) => {
const next = new Set(prev);
next.delete(browser);
@@ -194,52 +207,18 @@ export function useUpdateNotifications(
});
}
},
[notifications, checkForUpdates, onProfilesUpdated],
[onProfilesUpdated],
);
const handleDismiss = useCallback(
async (notificationId: string) => {
try {
toast.dismiss(notificationId);
await invoke("dismiss_update_notification", { notificationId });
// Track this notification as dismissed to prevent showing it again
setDismissedNotifications((prev) => new Set(prev).add(notificationId));
await checkForUpdates();
} catch (error) {
console.error("Failed to dismiss notification:", error);
}
},
[checkForUpdates],
);
// Separate effect to show toasts when notifications change
// Clean up notifications when they're no longer needed
useEffect(() => {
for (const notification of notifications) {
const isUpdating = updatingBrowsers.has(notification.browser);
toast.custom(
() => (
<UpdateNotificationComponent
notification={notification}
onUpdate={handleUpdate}
onDismiss={handleDismiss}
isUpdating={isUpdating}
/>
),
{
id: notification.id,
duration: Number.POSITIVE_INFINITY, // Persistent until user action
position: "top-right",
style: {
zIndex: 99999, // Ensure notifications appear above dialogs
pointerEvents: "auto", // Ensure notifications remain interactive
},
},
);
}
}, [notifications, updatingBrowsers, handleUpdate, handleDismiss]);
// Remove notifications that have been processed
setNotifications((prev) =>
prev.filter(
(notification) => !processedNotifications.has(notification.id),
),
);
}, [processedNotifications]);
return {
notifications,
+5 -8
View File
@@ -68,14 +68,11 @@ export function useVersionUpdater() {
dismissToast("unified-version-update");
if (progress.new_versions_found > 0) {
toast.success(
`Found ${progress.new_versions_found} new browser versions!`,
{
duration: 4000,
description:
"Version information has been updated in the background",
},
);
toast.success("Browser versions updated successfully", {
duration: 4000,
description:
"Version information has been updated in the background",
});
} else {
toast.success("No new browser versions found", {
duration: 3000,
+20
View File
@@ -294,6 +294,26 @@ export function showTwilightUpdateToast(
});
}
export function showAutoUpdateToast(
browserName: string,
version: string,
options?: {
id?: string;
description?: string;
duration?: number;
},
) {
return showToast({
type: "loading",
title: `${browserName} update started`,
description:
options?.description ??
`Automatically downloading ${browserName} ${version}. Progress will be shown in download notifications.`,
id: options?.id ?? `auto-update-${browserName.toLowerCase()}-${version}`,
duration: options?.duration ?? 4000,
});
}
// Generic helper for dismissing toasts
export function dismissToast(id: string) {
sonnerToast.dismiss(id);