feat: self-updates

This commit is contained in:
zhom
2025-05-30 07:12:07 +04:00
parent 03a3e9fc56
commit 5c23c77896
7 changed files with 829 additions and 6 deletions
+7 -1
View File
@@ -13,6 +13,7 @@ import {
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useAppUpdateNotifications } from "@/hooks/use-app-update-notifications";
import { useUpdateNotifications } from "@/hooks/use-update-notifications";
import { showErrorToast } from "@/lib/toast-utils";
import type { BrowserProfile, ProxySettings } from "@/types";
@@ -53,6 +54,9 @@ export default function Home() {
const updateNotifications = useUpdateNotifications();
const { checkForUpdates, isUpdating } = updateNotifications;
// App auto-update functionality
const appUpdateNotifications = useAppUpdateNotifications();
// Ensure we're on the client side to prevent hydration mismatches
useEffect(() => {
setIsClient(true);
@@ -249,7 +253,9 @@ export default function Home() {
await loadProfiles();
} catch (error) {
setError(
`Failed to create profile: ${error instanceof Error ? error.message : String(error)}`,
`Failed to create profile: ${
error instanceof Error ? error.message : String(error)
}`,
);
throw error;
}
+123
View File
@@ -0,0 +1,123 @@
"use client";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import React from "react";
import { FaDownload, FaTimes } from "react-icons/fa";
import { LuRefreshCw } from "react-icons/lu";
interface AppUpdateInfo {
current_version: string;
new_version: string;
release_notes: string;
download_url: string;
is_nightly: boolean;
published_at: string;
}
interface AppUpdateToastProps {
updateInfo: AppUpdateInfo;
onUpdate: (updateInfo: AppUpdateInfo) => Promise<void>;
onDismiss: () => void;
isUpdating?: boolean;
updateProgress?: string;
}
export function AppUpdateToast({
updateInfo,
onUpdate,
onDismiss,
isUpdating = false,
updateProgress,
}: AppUpdateToastProps) {
const handleUpdateClick = async () => {
await onUpdate(updateInfo);
};
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-4 shadow-lg max-w-md">
<div className="mr-3 mt-0.5">
{isUpdating ? (
<LuRefreshCw className="h-5 w-5 text-blue-500 animate-spin flex-shrink-0" />
) : (
<FaDownload className="h-5 w-5 text-blue-500 flex-shrink-0" />
)}
</div>
<div className="flex-1 min-w-0">
<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 text-sm">
Donut Browser Update Available
</span>
<Badge
variant={updateInfo.is_nightly ? "secondary" : "default"}
className="text-xs"
>
{updateInfo.is_nightly ? "Nightly" : "Stable"}
</Badge>
</div>
<div className="text-xs text-muted-foreground">
Update from {updateInfo.current_version} to{" "}
<span className="font-medium">{updateInfo.new_version}</span>
</div>
</div>
{!isUpdating && (
<Button
variant="ghost"
size="sm"
onClick={onDismiss}
className="h-6 w-6 p-0 shrink-0"
>
<FaTimes className="h-3 w-3" />
</Button>
)}
</div>
{isUpdating && updateProgress && (
<div className="mt-2">
<p className="text-xs text-muted-foreground">{updateProgress}</p>
</div>
)}
{!isUpdating && (
<div className="flex items-center gap-2 mt-3">
<Button
onClick={() => void handleUpdateClick()}
size="sm"
className="flex items-center gap-2 text-xs"
>
<FaDownload className="h-3 w-3" />
Update Now
</Button>
<Button
variant="outline"
onClick={onDismiss}
size="sm"
className="text-xs"
>
Later
</Button>
</div>
)}
{updateInfo.release_notes && !isUpdating && (
<div className="mt-2">
<details className="text-xs">
<summary className="cursor-pointer text-muted-foreground hover:text-foreground">
Release Notes
</summary>
<div className="mt-1 text-muted-foreground whitespace-pre-wrap max-h-32 overflow-y-auto">
{updateInfo.release_notes.length > 200
? `${updateInfo.release_notes.substring(0, 200)}...`
: updateInfo.release_notes}
</div>
</details>
</div>
)}
</div>
</div>
);
}
+125
View File
@@ -0,0 +1,125 @@
"use client";
import { AppUpdateToast } from "@/components/app-update-toast";
import { showToast } from "@/lib/toast-utils";
import type { AppUpdateInfo } from "@/types";
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import React, { useCallback, useEffect, useState } from "react";
import { toast } from "sonner";
export function useAppUpdateNotifications() {
const [updateInfo, setUpdateInfo] = useState<AppUpdateInfo | null>(null);
const [isUpdating, setIsUpdating] = useState(false);
const [updateProgress, setUpdateProgress] = useState<string>("");
const [isClient, setIsClient] = useState(false);
// Ensure we're on the client side to prevent hydration mismatches
useEffect(() => {
setIsClient(true);
}, []);
const checkForAppUpdates = useCallback(async () => {
if (!isClient) return;
try {
const update = await invoke<AppUpdateInfo | null>(
"check_for_app_updates",
);
setUpdateInfo(update);
} catch (error) {
console.error("Failed to check for app updates:", error);
}
}, [isClient]);
const handleAppUpdate = useCallback(async (appUpdateInfo: AppUpdateInfo) => {
try {
setIsUpdating(true);
setUpdateProgress("Starting update...");
await invoke("download_and_install_app_update", {
updateInfo: appUpdateInfo,
});
} catch (error) {
console.error("Failed to update app:", error);
showToast({
type: "error",
title: "Failed to update Donut Browser",
description: String(error),
duration: 6000,
});
setIsUpdating(false);
setUpdateProgress("");
}
}, []);
const dismissAppUpdate = useCallback(() => {
if (!isClient) return;
setUpdateInfo(null);
toast.dismiss("app-update");
}, [isClient]);
// Listen for app update availability
useEffect(() => {
if (!isClient) return;
const unlistenUpdate = listen<AppUpdateInfo>(
"app-update-available",
(event) => {
console.log("App update available:", event.payload);
setUpdateInfo(event.payload);
},
);
const unlistenProgress = listen<string>("app-update-progress", (event) => {
console.log("App update progress:", event.payload);
setUpdateProgress(event.payload);
});
return () => {
void unlistenUpdate.then((unlisten) => {
unlisten();
});
void unlistenProgress.then((unlisten) => {
unlisten();
});
};
}, [isClient]);
// Show toast when update is available
useEffect(() => {
if (!isClient || !updateInfo) return;
toast.custom(
() => (
<AppUpdateToast
updateInfo={updateInfo}
onUpdate={handleAppUpdate}
onDismiss={dismissAppUpdate}
isUpdating={isUpdating}
updateProgress={updateProgress}
/>
),
{
id: "app-update",
duration: Number.POSITIVE_INFINITY, // Persistent until user action
position: "top-right",
},
);
}, [
updateInfo,
handleAppUpdate,
dismissAppUpdate,
isUpdating,
updateProgress,
isClient,
]);
return {
updateInfo,
isUpdating,
checkForAppUpdates,
dismissAppUpdate,
};
}
+14
View File
@@ -19,3 +19,17 @@ export interface BrowserProfile {
process_id?: number;
last_launch?: number;
}
export interface AppUpdateInfo {
current_version: string;
new_version: string;
release_notes: string;
download_url: string;
is_nightly: boolean;
published_at: string;
}
export interface AppVersionInfo {
version: string;
is_nightly: boolean;
}