mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat(desktop): improve auto-updater UX and add global update indicator (#394)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
+184
-174
@@ -46,6 +46,8 @@ import {
|
||||
SidebarProvider,
|
||||
SidebarTrigger,
|
||||
} from "@/shared/ui/sidebar";
|
||||
import { UpdaterProvider } from "@/features/settings/hooks/UpdaterProvider";
|
||||
import { UpdateIndicator } from "@/features/settings/UpdateIndicator";
|
||||
|
||||
type AppView = "home" | "channel" | "agents" | "workflows" | "pulse";
|
||||
|
||||
@@ -117,6 +119,7 @@ export function AppShell() {
|
||||
const [settingsSection, setSettingsSection] = React.useState<SettingsSection>(
|
||||
DEFAULT_SETTINGS_SECTION,
|
||||
);
|
||||
|
||||
const [isChannelManagementOpen, setIsChannelManagementOpen] =
|
||||
React.useState(false);
|
||||
const [isSearchOpen, setIsSearchOpen] = React.useState(false);
|
||||
@@ -407,188 +410,195 @@ export function AppShell() {
|
||||
}, [handleCloseSettings, handleOpenSettings, settingsOpen]);
|
||||
|
||||
return (
|
||||
<ChannelNavigationProvider channels={channels}>
|
||||
<AppShellProvider
|
||||
value={{
|
||||
markChannelRead,
|
||||
openChannelManagement: () => {
|
||||
setIsChannelManagementOpen(true);
|
||||
},
|
||||
}}
|
||||
>
|
||||
<HuddleProvider>
|
||||
<div className="flex h-dvh flex-col overflow-hidden overscroll-none">
|
||||
<SidebarProvider className="min-h-0 flex-1 overflow-hidden">
|
||||
<div className="fixed left-[80px] top-[8px] z-50 flex items-center gap-1.5">
|
||||
<SidebarTrigger className="h-6 w-6 text-muted-foreground/70 hover:bg-muted/60 hover:text-foreground" />
|
||||
<Button
|
||||
aria-label="Go back"
|
||||
className="h-6 w-6 text-muted-foreground/70 hover:bg-muted/60 hover:text-foreground"
|
||||
data-testid="global-back"
|
||||
disabled={!canGoBack}
|
||||
onClick={goBack}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
<ChevronLeft className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
aria-label="Go forward"
|
||||
className="h-6 w-6 text-muted-foreground/70 hover:bg-muted/60 hover:text-foreground"
|
||||
data-testid="global-forward"
|
||||
disabled={!canGoForward}
|
||||
onClick={goForward}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
<ChevronRight className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
<AppSidebar
|
||||
channels={sidebarChannels}
|
||||
currentPubkey={identityQuery.data?.pubkey}
|
||||
errorMessage={
|
||||
channelsQuery.error instanceof Error
|
||||
? channelsQuery.error.message
|
||||
: undefined
|
||||
}
|
||||
fallbackDisplayName={identityQuery.data?.displayName}
|
||||
homeBadgeCount={homeBadgeCount}
|
||||
isCreatingChannel={createChannelMutation.isPending}
|
||||
isCreatingForum={createForumMutation.isPending}
|
||||
isLoading={channelsQuery.isLoading}
|
||||
isOpeningDm={openDmMutation.isPending}
|
||||
isNewDmOpen={isNewDmOpen}
|
||||
isPresencePending={presenceSession.isPending}
|
||||
onNewDmOpenChange={setIsNewDmOpen}
|
||||
selfPresenceStatus={presenceSession.currentStatus}
|
||||
onCreateChannel={async ({
|
||||
description,
|
||||
name,
|
||||
visibility,
|
||||
ttlSeconds,
|
||||
}) => {
|
||||
const createdChannel =
|
||||
await createChannelMutation.mutateAsync({
|
||||
<UpdaterProvider>
|
||||
<ChannelNavigationProvider channels={channels}>
|
||||
<AppShellProvider
|
||||
value={{
|
||||
markChannelRead,
|
||||
openChannelManagement: () => {
|
||||
setIsChannelManagementOpen(true);
|
||||
},
|
||||
}}
|
||||
>
|
||||
<HuddleProvider>
|
||||
<div className="flex h-dvh flex-col overflow-hidden overscroll-none">
|
||||
<SidebarProvider className="min-h-0 flex-1 overflow-hidden">
|
||||
<div className="fixed left-[80px] top-[8px] z-50 flex items-center gap-1.5">
|
||||
<SidebarTrigger className="h-6 w-6 text-muted-foreground/70 hover:bg-muted/60 hover:text-foreground" />
|
||||
<Button
|
||||
aria-label="Go back"
|
||||
className="h-6 w-6 text-muted-foreground/70 hover:bg-muted/60 hover:text-foreground"
|
||||
data-testid="global-back"
|
||||
disabled={!canGoBack}
|
||||
onClick={goBack}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
<ChevronLeft className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
aria-label="Go forward"
|
||||
className="h-6 w-6 text-muted-foreground/70 hover:bg-muted/60 hover:text-foreground"
|
||||
data-testid="global-forward"
|
||||
disabled={!canGoForward}
|
||||
onClick={goForward}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
<ChevronRight className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<UpdateIndicator
|
||||
onOpenUpdates={() => handleOpenSettings("updates")}
|
||||
/>
|
||||
</div>
|
||||
<AppSidebar
|
||||
channels={sidebarChannels}
|
||||
currentPubkey={identityQuery.data?.pubkey}
|
||||
errorMessage={
|
||||
channelsQuery.error instanceof Error
|
||||
? channelsQuery.error.message
|
||||
: undefined
|
||||
}
|
||||
fallbackDisplayName={identityQuery.data?.displayName}
|
||||
homeBadgeCount={homeBadgeCount}
|
||||
isCreatingChannel={createChannelMutation.isPending}
|
||||
isCreatingForum={createForumMutation.isPending}
|
||||
isLoading={channelsQuery.isLoading}
|
||||
isOpeningDm={openDmMutation.isPending}
|
||||
isNewDmOpen={isNewDmOpen}
|
||||
isPresencePending={presenceSession.isPending}
|
||||
onNewDmOpenChange={setIsNewDmOpen}
|
||||
selfPresenceStatus={presenceSession.currentStatus}
|
||||
onCreateChannel={async ({
|
||||
description,
|
||||
name,
|
||||
visibility,
|
||||
ttlSeconds,
|
||||
}) => {
|
||||
const createdChannel =
|
||||
await createChannelMutation.mutateAsync({
|
||||
name,
|
||||
description,
|
||||
channelType: "stream",
|
||||
visibility,
|
||||
ttlSeconds,
|
||||
});
|
||||
|
||||
await goChannel(createdChannel.id);
|
||||
}}
|
||||
onCreateForum={async ({
|
||||
description,
|
||||
name,
|
||||
visibility,
|
||||
ttlSeconds,
|
||||
}) => {
|
||||
const createdForum = await createForumMutation.mutateAsync({
|
||||
name,
|
||||
description,
|
||||
channelType: "stream",
|
||||
channelType: "forum",
|
||||
visibility,
|
||||
ttlSeconds,
|
||||
});
|
||||
|
||||
await goChannel(createdChannel.id);
|
||||
}}
|
||||
onCreateForum={async ({
|
||||
description,
|
||||
name,
|
||||
visibility,
|
||||
ttlSeconds,
|
||||
}) => {
|
||||
const createdForum = await createForumMutation.mutateAsync({
|
||||
name,
|
||||
description,
|
||||
channelType: "forum",
|
||||
visibility,
|
||||
ttlSeconds,
|
||||
});
|
||||
await goChannel(createdForum.id);
|
||||
}}
|
||||
onHideDm={handleHideDm}
|
||||
onOpenBrowseChannels={handleOpenBrowseChannels}
|
||||
onOpenBrowseForums={handleOpenBrowseForums}
|
||||
onOpenDm={async ({ pubkeys }) => {
|
||||
const directMessage = await openDmMutation.mutateAsync({
|
||||
pubkeys,
|
||||
});
|
||||
await goChannel(directMessage.id);
|
||||
}}
|
||||
onOpenSearch={handleOpenSearch}
|
||||
onSelectAgents={() => {
|
||||
void goAgents();
|
||||
}}
|
||||
onSelectChannel={(channelId) => {
|
||||
void goChannel(channelId);
|
||||
}}
|
||||
onSelectHome={() => {
|
||||
void goHome();
|
||||
}}
|
||||
onSelectPulse={() => {
|
||||
void goPulse();
|
||||
}}
|
||||
onSelectSettings={handleOpenSettings}
|
||||
onSelectWorkflows={() => {
|
||||
void goWorkflows();
|
||||
}}
|
||||
onSetPresenceStatus={(status) =>
|
||||
presenceSession.setStatus(status)
|
||||
}
|
||||
profile={profileQuery.data}
|
||||
selectedChannelId={selectedChannelId}
|
||||
selectedView={selectedView}
|
||||
unreadChannelIds={unreadChannelIds}
|
||||
/>
|
||||
|
||||
await goChannel(createdForum.id);
|
||||
}}
|
||||
onHideDm={handleHideDm}
|
||||
onOpenBrowseChannels={handleOpenBrowseChannels}
|
||||
onOpenBrowseForums={handleOpenBrowseForums}
|
||||
onOpenDm={async ({ pubkeys }) => {
|
||||
const directMessage = await openDmMutation.mutateAsync({
|
||||
pubkeys,
|
||||
});
|
||||
await goChannel(directMessage.id);
|
||||
}}
|
||||
onOpenSearch={handleOpenSearch}
|
||||
onSelectAgents={() => {
|
||||
void goAgents();
|
||||
}}
|
||||
onSelectChannel={(channelId) => {
|
||||
void goChannel(channelId);
|
||||
}}
|
||||
onSelectHome={() => {
|
||||
void goHome();
|
||||
}}
|
||||
onSelectPulse={() => {
|
||||
void goPulse();
|
||||
}}
|
||||
onSelectSettings={handleOpenSettings}
|
||||
onSelectWorkflows={() => {
|
||||
void goWorkflows();
|
||||
}}
|
||||
onSetPresenceStatus={(status) =>
|
||||
presenceSession.setStatus(status)
|
||||
}
|
||||
profile={profileQuery.data}
|
||||
selectedChannelId={selectedChannelId}
|
||||
selectedView={selectedView}
|
||||
unreadChannelIds={unreadChannelIds}
|
||||
/>
|
||||
<SidebarInset className="min-h-0 min-w-0 overflow-hidden">
|
||||
<Outlet />
|
||||
</SidebarInset>
|
||||
|
||||
<SidebarInset className="min-h-0 min-w-0 overflow-hidden">
|
||||
<Outlet />
|
||||
</SidebarInset>
|
||||
<AppShellOverlays
|
||||
activeChannel={activeChannel}
|
||||
browseDialogType={browseDialogType}
|
||||
channels={channels}
|
||||
currentPubkey={identityQuery.data?.pubkey}
|
||||
isChannelManagementOpen={isChannelManagementOpen}
|
||||
isSearchOpen={isSearchOpen}
|
||||
onBrowseChannelJoin={handleBrowseChannelJoin}
|
||||
onBrowseDialogOpenChange={handleBrowseDialogOpenChange}
|
||||
onChannelManagementOpenChange={setIsChannelManagementOpen}
|
||||
onDeleteActiveChannel={() => {
|
||||
setIsChannelManagementOpen(false);
|
||||
void goHome({ replace: true });
|
||||
}}
|
||||
onOpenSearchResult={handleOpenSearchResult}
|
||||
onSearchOpenChange={setIsSearchOpen}
|
||||
onSelectChannel={(channelId) => {
|
||||
void goChannel(channelId);
|
||||
}}
|
||||
/>
|
||||
|
||||
<AppShellOverlays
|
||||
activeChannel={activeChannel}
|
||||
browseDialogType={browseDialogType}
|
||||
channels={channels}
|
||||
currentPubkey={identityQuery.data?.pubkey}
|
||||
isChannelManagementOpen={isChannelManagementOpen}
|
||||
isSearchOpen={isSearchOpen}
|
||||
onBrowseChannelJoin={handleBrowseChannelJoin}
|
||||
onBrowseDialogOpenChange={handleBrowseDialogOpenChange}
|
||||
onChannelManagementOpenChange={setIsChannelManagementOpen}
|
||||
onDeleteActiveChannel={() => {
|
||||
setIsChannelManagementOpen(false);
|
||||
void goHome({ replace: true });
|
||||
}}
|
||||
onOpenSearchResult={handleOpenSearchResult}
|
||||
onSearchOpenChange={setIsSearchOpen}
|
||||
onSelectChannel={(channelId) => {
|
||||
void goChannel(channelId);
|
||||
}}
|
||||
/>
|
||||
|
||||
{settingsOpen ? (
|
||||
<React.Suspense fallback={null}>
|
||||
<LazySettingsScreen
|
||||
currentPubkey={identityQuery.data?.pubkey}
|
||||
fallbackDisplayName={identityQuery.data?.displayName}
|
||||
isUpdatingDesktopNotifications={
|
||||
notificationSettings.isUpdatingDesktopEnabled
|
||||
}
|
||||
notificationErrorMessage={notificationSettings.errorMessage}
|
||||
notificationPermission={notificationSettings.permission}
|
||||
notificationSettings={notificationSettings.settings}
|
||||
onClose={handleCloseSettings}
|
||||
onSectionChange={setSettingsSection}
|
||||
onSetDesktopNotificationsEnabled={
|
||||
notificationSettings.setDesktopEnabled
|
||||
}
|
||||
onSetHomeBadgeEnabled={
|
||||
notificationSettings.setHomeBadgeEnabled
|
||||
}
|
||||
onSetMentionNotificationsEnabled={
|
||||
notificationSettings.setMentionsEnabled
|
||||
}
|
||||
onSetNeedsActionNotificationsEnabled={
|
||||
notificationSettings.setNeedsActionEnabled
|
||||
}
|
||||
section={settingsSection}
|
||||
/>
|
||||
</React.Suspense>
|
||||
) : null}
|
||||
</SidebarProvider>
|
||||
<HuddleBar />
|
||||
</div>
|
||||
</HuddleProvider>
|
||||
</AppShellProvider>
|
||||
</ChannelNavigationProvider>
|
||||
{settingsOpen ? (
|
||||
<React.Suspense fallback={null}>
|
||||
<LazySettingsScreen
|
||||
currentPubkey={identityQuery.data?.pubkey}
|
||||
fallbackDisplayName={identityQuery.data?.displayName}
|
||||
isUpdatingDesktopNotifications={
|
||||
notificationSettings.isUpdatingDesktopEnabled
|
||||
}
|
||||
notificationErrorMessage={
|
||||
notificationSettings.errorMessage
|
||||
}
|
||||
notificationPermission={notificationSettings.permission}
|
||||
notificationSettings={notificationSettings.settings}
|
||||
onClose={handleCloseSettings}
|
||||
onSectionChange={setSettingsSection}
|
||||
onSetDesktopNotificationsEnabled={
|
||||
notificationSettings.setDesktopEnabled
|
||||
}
|
||||
onSetHomeBadgeEnabled={
|
||||
notificationSettings.setHomeBadgeEnabled
|
||||
}
|
||||
onSetMentionNotificationsEnabled={
|
||||
notificationSettings.setMentionsEnabled
|
||||
}
|
||||
onSetNeedsActionNotificationsEnabled={
|
||||
notificationSettings.setNeedsActionEnabled
|
||||
}
|
||||
section={settingsSection}
|
||||
/>
|
||||
</React.Suspense>
|
||||
) : null}
|
||||
</SidebarProvider>
|
||||
<HuddleBar />
|
||||
</div>
|
||||
</HuddleProvider>
|
||||
</AppShellProvider>
|
||||
</ChannelNavigationProvider>
|
||||
</UpdaterProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,168 +1,88 @@
|
||||
import { useState, useRef, useCallback } from "react";
|
||||
import { check, type Update } from "@tauri-apps/plugin-updater";
|
||||
import { relaunch } from "@tauri-apps/plugin-process";
|
||||
|
||||
type UpdateStatus =
|
||||
| { state: "idle" }
|
||||
| { state: "checking" }
|
||||
| { state: "up-to-date" }
|
||||
| { state: "available"; version: string }
|
||||
| { state: "downloading" }
|
||||
| { state: "installing" }
|
||||
| { state: "ready" }
|
||||
| { state: "error"; message: string };
|
||||
import { useUpdaterContext } from "./hooks/UpdaterProvider";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Badge } from "@/shared/ui/badge";
|
||||
|
||||
export function UpdateChecker() {
|
||||
const [status, setStatus] = useState<UpdateStatus>({ state: "idle" });
|
||||
const updateRef = useRef<Update | null>(null);
|
||||
|
||||
const closeUpdate = useCallback(async () => {
|
||||
if (updateRef.current) {
|
||||
await updateRef.current.close();
|
||||
updateRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
async function checkForUpdate() {
|
||||
try {
|
||||
await closeUpdate();
|
||||
setStatus({ state: "checking" });
|
||||
const update = await check();
|
||||
|
||||
if (update) {
|
||||
updateRef.current = update;
|
||||
setStatus({ state: "available", version: update.version });
|
||||
} else {
|
||||
setStatus({ state: "up-to-date" });
|
||||
}
|
||||
} catch (err) {
|
||||
setStatus({
|
||||
state: "error",
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadAndInstall() {
|
||||
try {
|
||||
const update = updateRef.current;
|
||||
if (!update) {
|
||||
setStatus({ state: "up-to-date" });
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus({ state: "downloading" });
|
||||
|
||||
await update.downloadAndInstall((event) => {
|
||||
if (event.event === "Finished") {
|
||||
setStatus({ state: "installing" });
|
||||
}
|
||||
});
|
||||
|
||||
setStatus({ state: "ready" });
|
||||
} catch (err) {
|
||||
setStatus({
|
||||
state: "error",
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRelaunch() {
|
||||
await relaunch();
|
||||
}
|
||||
const { status, checkForUpdate, downloadAndInstall, relaunch } =
|
||||
useUpdaterContext();
|
||||
|
||||
return (
|
||||
<div className="rounded-lg bg-zinc-900 p-4">
|
||||
<h3 className="mb-3 text-sm font-medium text-zinc-200">
|
||||
Software Updates
|
||||
</h3>
|
||||
<section className="min-w-0">
|
||||
<div className="mb-3 min-w-0">
|
||||
<h2 className="text-sm font-semibold tracking-tight">
|
||||
Software Updates
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Keep Sprout up to date with the latest features and fixes.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{status.state === "idle" && (
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-zinc-400">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Check if a new version is available.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={checkForUpdate}
|
||||
className="rounded-lg bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700"
|
||||
>
|
||||
<Button size="sm" onClick={checkForUpdate}>
|
||||
Check for Updates
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status.state === "checking" && (
|
||||
<p className="text-sm text-zinc-400">Checking for updates...</p>
|
||||
<p className="text-sm text-muted-foreground">Checking for updates...</p>
|
||||
)}
|
||||
|
||||
{status.state === "up-to-date" && (
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-zinc-300">You're on the latest version.</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={checkForUpdate}
|
||||
className="rounded-lg bg-zinc-800 px-3 py-1.5 text-sm font-medium text-zinc-300 hover:bg-zinc-700"
|
||||
>
|
||||
<p className="text-sm text-foreground">
|
||||
You're on the latest version.
|
||||
</p>
|
||||
<Button variant="outline" size="sm" onClick={checkForUpdate}>
|
||||
Check Again
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status.state === "available" && (
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-zinc-300">
|
||||
Version <span className="font-semibold">{status.version}</span> is
|
||||
available.
|
||||
<p className="text-sm text-foreground">
|
||||
Version <Badge variant="info">{status.version}</Badge> is available.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={downloadAndInstall}
|
||||
className="rounded-lg bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700"
|
||||
>
|
||||
<Button size="sm" onClick={downloadAndInstall}>
|
||||
Download & Install
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status.state === "downloading" && (
|
||||
<p className="text-sm text-zinc-400">Downloading update...</p>
|
||||
<p className="text-sm text-muted-foreground">Downloading update...</p>
|
||||
)}
|
||||
|
||||
{status.state === "installing" && (
|
||||
<p className="text-sm text-zinc-400">Installing update...</p>
|
||||
<p className="text-sm text-muted-foreground">Installing update...</p>
|
||||
)}
|
||||
|
||||
{status.state === "ready" && (
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-zinc-300">
|
||||
<p className="text-sm text-foreground">
|
||||
Update installed. Restart to apply.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRelaunch}
|
||||
className="rounded-lg bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700"
|
||||
>
|
||||
<Button size="sm" onClick={relaunch}>
|
||||
Restart Now
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status.state === "error" && (
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-red-400">
|
||||
<p className="text-sm text-destructive">
|
||||
Update failed: {status.message}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={checkForUpdate}
|
||||
className="rounded-lg bg-zinc-800 px-3 py-1.5 text-sm font-medium text-zinc-300 hover:bg-zinc-700"
|
||||
>
|
||||
<Button variant="outline" size="sm" onClick={checkForUpdate}>
|
||||
Retry
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Download, RefreshCw } from "lucide-react";
|
||||
|
||||
import { Button } from "@/shared/ui/button";
|
||||
|
||||
import { useUpdaterContext } from "./hooks/UpdaterProvider";
|
||||
|
||||
const indicatorButtonClass =
|
||||
"relative h-6 w-6 text-muted-foreground/70 hover:bg-muted/60 hover:text-foreground";
|
||||
|
||||
export function UpdateIndicator({
|
||||
onOpenUpdates,
|
||||
}: {
|
||||
onOpenUpdates: () => void;
|
||||
}) {
|
||||
const { status } = useUpdaterContext();
|
||||
|
||||
if (status.state === "available") {
|
||||
return (
|
||||
<Button
|
||||
aria-label="Update available"
|
||||
className={indicatorButtonClass}
|
||||
onClick={onOpenUpdates}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
<span className="absolute -top-0.5 -right-0.5 h-2 w-2 rounded-full bg-primary animate-pulse" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
if (status.state === "ready") {
|
||||
return (
|
||||
<Button
|
||||
aria-label="Restart to update"
|
||||
className={indicatorButtonClass}
|
||||
onClick={onOpenUpdates}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
<RefreshCw className="h-3.5 w-3.5" />
|
||||
<span className="absolute -top-0.5 -right-0.5 h-2 w-2 rounded-full bg-emerald-500" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { createContext, useContext, type ReactNode } from "react";
|
||||
import { useUpdater } from "./use-updater";
|
||||
|
||||
type UpdaterContextValue = ReturnType<typeof useUpdater>;
|
||||
|
||||
const UpdaterContext = createContext<UpdaterContextValue | null>(null);
|
||||
|
||||
export function UpdaterProvider({ children }: { children: ReactNode }) {
|
||||
const updater = useUpdater();
|
||||
return <UpdaterContext value={updater}>{children}</UpdaterContext>;
|
||||
}
|
||||
|
||||
export function useUpdaterContext(): UpdaterContextValue {
|
||||
const ctx = useContext(UpdaterContext);
|
||||
if (!ctx) {
|
||||
throw new Error("useUpdaterContext must be used within an UpdaterProvider");
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { useState, useRef, useCallback, useEffect } from "react";
|
||||
import { check, type Update } from "@tauri-apps/plugin-updater";
|
||||
import { relaunch } from "@tauri-apps/plugin-process";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export type UpdateStatus =
|
||||
| { state: "idle" }
|
||||
| { state: "checking" }
|
||||
| { state: "up-to-date" }
|
||||
| { state: "available"; version: string }
|
||||
| { state: "downloading" }
|
||||
| { state: "installing" }
|
||||
| { state: "ready" }
|
||||
| { state: "error"; message: string };
|
||||
|
||||
export function useUpdater() {
|
||||
const [status, setStatus] = useState<UpdateStatus>({ state: "idle" });
|
||||
const updateRef = useRef<Update | null>(null);
|
||||
|
||||
const closeUpdate = useCallback(async () => {
|
||||
if (updateRef.current) {
|
||||
await updateRef.current.close();
|
||||
updateRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const checkForUpdate = useCallback(async () => {
|
||||
try {
|
||||
await closeUpdate();
|
||||
setStatus({ state: "checking" });
|
||||
const update = await check();
|
||||
|
||||
if (update) {
|
||||
updateRef.current = update;
|
||||
setStatus({ state: "available", version: update.version });
|
||||
toast("Update Available", {
|
||||
id: "update-available",
|
||||
description: `Version ${update.version} is ready to download.`,
|
||||
});
|
||||
} else {
|
||||
setStatus({ state: "up-to-date" });
|
||||
}
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
if (
|
||||
msg.includes("plugin updater not found") ||
|
||||
msg.includes("not initialized")
|
||||
) {
|
||||
setStatus({ state: "idle" });
|
||||
return;
|
||||
}
|
||||
setStatus({ state: "error", message: msg });
|
||||
}
|
||||
}, [closeUpdate]);
|
||||
|
||||
const downloadAndInstall = useCallback(async () => {
|
||||
try {
|
||||
const update = updateRef.current;
|
||||
if (!update) {
|
||||
setStatus({ state: "up-to-date" });
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus({ state: "downloading" });
|
||||
|
||||
await update.downloadAndInstall((event) => {
|
||||
if (event.event === "Finished") {
|
||||
setStatus({ state: "installing" });
|
||||
}
|
||||
});
|
||||
|
||||
setStatus({ state: "ready" });
|
||||
} catch (err) {
|
||||
setStatus({
|
||||
state: "error",
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleRelaunch = useCallback(async () => {
|
||||
try {
|
||||
await relaunch();
|
||||
} catch (err) {
|
||||
setStatus({
|
||||
state: "error",
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
checkForUpdate();
|
||||
return () => {
|
||||
closeUpdate();
|
||||
};
|
||||
}, [checkForUpdate, closeUpdate]);
|
||||
|
||||
return {
|
||||
status,
|
||||
checkForUpdate,
|
||||
downloadAndInstall,
|
||||
relaunch: handleRelaunch,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user