feat(update): update banner and one-click upgrade/rollback in version badge
This commit is contained in:
@@ -49,6 +49,7 @@ import { MemoryPanel } from "@/app/components/MemoryPanel";
|
||||
import { ScheduledTasksPanel } from "@/app/components/ScheduledTasksPanel";
|
||||
import { ThemeToggle } from "@/app/components/ThemeToggle";
|
||||
import { VersionBadge } from "@/app/components/VersionBadge";
|
||||
import { UpdateBanner } from "@/app/components/UpdateBanner";
|
||||
import { HealthIndicator } from "@/app/components/HealthIndicator";
|
||||
import { InspectorPanel } from "@/app/components/InspectorPanel";
|
||||
import { setThreadAutoApprove } from "@/lib/autoApprove";
|
||||
@@ -481,6 +482,8 @@ function HomePageInner({ authEnabled, isAdmin }: HomePageInnerProps) {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<UpdateBanner />
|
||||
|
||||
<div className="relative flex-1 overflow-hidden">
|
||||
{sidebar && isDesktopLayout === false && (
|
||||
<div className="absolute inset-0 z-40 flex md:hidden">
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import useSWR from "swr";
|
||||
import { X } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { SystemVersionInfo } from "@/app/api/system/version/route";
|
||||
import {
|
||||
closeForSession,
|
||||
dismissVersion,
|
||||
isDismissed,
|
||||
isSessionClosed,
|
||||
loadDismissed,
|
||||
} from "@/lib/updatePrompt";
|
||||
|
||||
async function fetchVersion(url: string): Promise<SystemVersionInfo> {
|
||||
const res = await fetch(url, { cache: "no-store" });
|
||||
if (!res.ok) throw new Error(`version check failed (${res.status})`);
|
||||
return (await res.json()) as SystemVersionInfo;
|
||||
}
|
||||
|
||||
export function UpdateBanner() {
|
||||
const [, forceRender] = useState(0);
|
||||
const backend = useSWR<SystemVersionInfo>("/api/system/version", fetchVersion, {
|
||||
revalidateOnFocus: false,
|
||||
shouldRetryOnError: false,
|
||||
});
|
||||
const frontend = useSWR<SystemVersionInfo>("/api/system/self-version", fetchVersion, {
|
||||
revalidateOnFocus: false,
|
||||
shouldRetryOnError: false,
|
||||
});
|
||||
|
||||
if (typeof window === "undefined") return null;
|
||||
if (isSessionClosed(sessionStorage)) return null;
|
||||
const dismissed = loadDismissed(localStorage);
|
||||
|
||||
const rows: { key: "backend" | "frontend"; label: string; info: SystemVersionInfo }[] = [];
|
||||
if (
|
||||
backend.data?.has_update &&
|
||||
!isDismissed(dismissed, "backend", backend.data.latest_version)
|
||||
) {
|
||||
rows.push({ key: "backend", label: "Backend", info: backend.data });
|
||||
}
|
||||
if (
|
||||
frontend.data?.has_update &&
|
||||
!isDismissed(dismissed, "frontend", frontend.data.latest_version)
|
||||
) {
|
||||
rows.push({ key: "frontend", label: "Web UI", info: frontend.data });
|
||||
}
|
||||
if (rows.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="flex items-start gap-3 border-b border-amber-500/30 bg-amber-500/10 px-4 py-2 text-sm">
|
||||
<div className="flex-1 space-y-1">
|
||||
{rows.map((r) => (
|
||||
<p key={r.key}>
|
||||
{r.label} <span className="font-mono">v{r.info.current_version}</span> →{" "}
|
||||
<span className="font-mono font-medium">v{r.info.latest_version}</span> available.{" "}
|
||||
{r.info.release_url && (
|
||||
<a
|
||||
className="underline"
|
||||
href={r.info.release_url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Release notes
|
||||
</a>
|
||||
)}
|
||||
</p>
|
||||
))}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Upgrading briefly interrupts the service and disconnects all connected sessions.
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<a href="#version">Review & upgrade</a>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
for (const r of rows) dismissVersion(localStorage, r.key, r.info.latest_version);
|
||||
forceRender((n) => n + 1);
|
||||
}}
|
||||
>
|
||||
Ignore
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Close"
|
||||
onClick={() => {
|
||||
closeForSession(sessionStorage);
|
||||
forceRender((n) => n + 1);
|
||||
}}
|
||||
>
|
||||
<X className="size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+216
-108
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import useSWR from "swr";
|
||||
import { Copy, Download, ExternalLink, RefreshCw, Tag } from "lucide-react";
|
||||
import { ExternalLink, RefreshCw, RotateCcw, Tag, Upload } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { errorToast } from "@/lib/errorReporter";
|
||||
import type { SystemVersionInfo } from "@/app/api/system/version/route";
|
||||
import type { SystemVersionDownloadResult } from "@/app/api/system/version/download/route";
|
||||
import type { RollbackVersionsResult } from "@/app/api/system/rollback-versions/route";
|
||||
|
||||
async function fetchVersion(url: string): Promise<SystemVersionInfo> {
|
||||
const res = await fetch(url, { cache: "no-store" });
|
||||
@@ -26,23 +26,123 @@ async function fetchVersion(url: string): Promise<SystemVersionInfo> {
|
||||
return (await res.json()) as SystemVersionInfo;
|
||||
}
|
||||
|
||||
type UpdateTargetKey = "backend" | "frontend";
|
||||
|
||||
export function VersionBadge() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [downloading, setDownloading] = useState(false);
|
||||
const [downloaded, setDownloaded] =
|
||||
useState<SystemVersionDownloadResult | null>(null);
|
||||
const [updating, setUpdating] = useState(false);
|
||||
const [updateStatus, setUpdateStatus] = useState<string | null>(null);
|
||||
const [rollbackVersions, setRollbackVersions] = useState<
|
||||
RollbackVersionsResult["versions"] | null
|
||||
>(null);
|
||||
const { data, error, mutate } = useSWR<SystemVersionInfo>(
|
||||
"/api/system/version",
|
||||
fetchVersion,
|
||||
{ revalidateOnFocus: false, shouldRetryOnError: false }
|
||||
);
|
||||
const { data: selfData, mutate: mutateSelf } = useSWR<SystemVersionInfo>(
|
||||
"/api/system/self-version",
|
||||
fetchVersion,
|
||||
{ revalidateOnFocus: false, shouldRetryOnError: false }
|
||||
);
|
||||
|
||||
const pollStatus = () => {
|
||||
let failures = 0;
|
||||
const timer = setInterval(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/system/update/status", { cache: "no-store" });
|
||||
if (!res.ok) throw new Error(String(res.status));
|
||||
const body = await res.json();
|
||||
failures = 0;
|
||||
if (body.status === "success") {
|
||||
clearInterval(timer);
|
||||
setUpdateStatus("success — please reload the page");
|
||||
} else if (body.status === "failed" || body.status === "installed_restart_failed") {
|
||||
clearInterval(timer);
|
||||
setUpdateStatus(`failed: ${body.message}`);
|
||||
}
|
||||
} catch {
|
||||
// The service being upgraded is restarting; failed polls are expected.
|
||||
failures += 1;
|
||||
if (failures > 36) {
|
||||
// ~3 minutes at a 5s interval
|
||||
clearInterval(timer);
|
||||
setUpdateStatus("not recovering — check the terminal logs");
|
||||
}
|
||||
}
|
||||
}, 5000);
|
||||
};
|
||||
|
||||
const startApply = async (url: string) => {
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: "{}",
|
||||
});
|
||||
const body = await res.json().catch(() => null);
|
||||
if (res.status === 202) {
|
||||
setUpdateStatus("applying");
|
||||
pollStatus();
|
||||
return;
|
||||
}
|
||||
if (body?.code === "BREAKING_DB_CONFIRM_REQUIRED") {
|
||||
if (window.confirm("This update contains breaking DB changes. Continue?")) {
|
||||
await startApply(`${url}?confirm_breaking=true`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (res.status === 200 && body?.status === "manual") {
|
||||
setUpdateStatus(body.guidance ?? "manual update required");
|
||||
return;
|
||||
}
|
||||
throw new Error(body?.message ?? body?.code ?? `Update failed (${res.status}).`);
|
||||
};
|
||||
|
||||
const handleUpgrade = async (target: UpdateTargetKey) => {
|
||||
setUpdating(true);
|
||||
setUpdateStatus(null);
|
||||
try {
|
||||
await startApply(target === "backend" ? "/api/system/update" : "/api/system/self-update");
|
||||
} catch (e) {
|
||||
errorToast("version.update", e instanceof Error ? e.message : "Couldn't start the update.");
|
||||
} finally {
|
||||
setUpdating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRollback = async (version: string) => {
|
||||
setUpdating(true);
|
||||
setUpdateStatus(null);
|
||||
try {
|
||||
const res = await fetch("/api/system/update/rollback", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ version }),
|
||||
});
|
||||
const body = await res.json().catch(() => null);
|
||||
if (res.status === 202) {
|
||||
setUpdateStatus("applying");
|
||||
pollStatus();
|
||||
return;
|
||||
}
|
||||
throw new Error(body?.message ?? body?.code ?? `Rollback failed (${res.status}).`);
|
||||
} catch (e) {
|
||||
errorToast("version.rollback", e instanceof Error ? e.message : "Couldn't start the rollback.");
|
||||
} finally {
|
||||
setUpdating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRefresh = async () => {
|
||||
setRefreshing(true);
|
||||
try {
|
||||
const info = await fetchVersion("/api/system/version?force=true");
|
||||
const [info, selfInfo] = await Promise.all([
|
||||
fetchVersion("/api/system/version?force=true"),
|
||||
fetchVersion("/api/system/self-version?force=true"),
|
||||
]);
|
||||
await mutate(info, { revalidate: false });
|
||||
await mutateSelf(selfInfo, { revalidate: false });
|
||||
} catch (e) {
|
||||
errorToast(
|
||||
"version.refresh",
|
||||
@@ -53,41 +153,28 @@ export function VersionBadge() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownload = async () => {
|
||||
setDownloading(true);
|
||||
const loadRollbackVersions = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/system/version/download", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: "{}",
|
||||
});
|
||||
const body = await res.json().catch(() => null);
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
body && typeof body.message === "string"
|
||||
? body.message
|
||||
: body && typeof body.error === "string"
|
||||
? body.error
|
||||
: `Download failed (${res.status}).`
|
||||
);
|
||||
}
|
||||
setDownloaded(body as SystemVersionDownloadResult);
|
||||
} catch (e) {
|
||||
errorToast(
|
||||
"version.download",
|
||||
e instanceof Error ? e.message : "Couldn't download the update."
|
||||
);
|
||||
} finally {
|
||||
setDownloading(false);
|
||||
const res = await fetch("/api/system/rollback-versions", { cache: "no-store" });
|
||||
if (!res.ok) return;
|
||||
const body = (await res.json()) as RollbackVersionsResult;
|
||||
setRollbackVersions(body.versions);
|
||||
} catch {
|
||||
// The rollback list is best-effort; the upgrade path stays available.
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenChange = (next: boolean) => {
|
||||
setOpen(next);
|
||||
if (!next) setDownloaded(null);
|
||||
if (next) {
|
||||
setUpdateStatus(null);
|
||||
setRollbackVersions(null);
|
||||
if (data?.has_update) void loadRollbackVersions();
|
||||
}
|
||||
};
|
||||
|
||||
const hasUpdate = data?.has_update === true;
|
||||
const selfHasUpdate = selfData?.has_update === true;
|
||||
|
||||
return (
|
||||
<DropdownMenu modal={false} open={open} onOpenChange={handleOpenChange}>
|
||||
@@ -96,16 +183,17 @@ export function VersionBadge() {
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
aria-label="Version"
|
||||
id="version"
|
||||
title={data ? `EvoScientist v${data.current_version}` : "Version"}
|
||||
className={`relative h-8 gap-1 px-2 text-xs ${
|
||||
hasUpdate
|
||||
hasUpdate || selfHasUpdate
|
||||
? "bg-amber-500/15 text-amber-600 hover:bg-amber-500/25 dark:text-amber-400"
|
||||
: "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
<Tag className="size-3.5" aria-hidden="true" />
|
||||
{data ? `v${data.current_version}` : "…"}
|
||||
{hasUpdate && (
|
||||
{(hasUpdate || selfHasUpdate) && (
|
||||
<span className="absolute -right-0.5 -top-0.5 flex size-2">
|
||||
<span className="absolute inline-flex size-full animate-ping rounded-full bg-amber-500 opacity-75" />
|
||||
<span className="relative inline-flex size-2 rounded-full bg-amber-500" />
|
||||
@@ -114,88 +202,108 @@ export function VersionBadge() {
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-80 p-0">
|
||||
<div className="border-b border-border px-3 py-2 text-sm font-medium">
|
||||
Version
|
||||
</div>
|
||||
<div className="border-b border-border px-3 py-2 text-sm font-medium">Version</div>
|
||||
{error && !data ? (
|
||||
<p className="px-3 py-6 text-center text-sm text-muted-foreground">
|
||||
Couldn't load version info.
|
||||
</p>
|
||||
) : !data ? (
|
||||
<p className="px-3 py-6 text-center text-sm text-muted-foreground">
|
||||
Loading...
|
||||
</p>
|
||||
<p className="px-3 py-6 text-center text-sm text-muted-foreground">Loading...</p>
|
||||
) : (
|
||||
<div className="space-y-2 px-3 py-3 text-sm">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span className="text-muted-foreground">Installed</span>
|
||||
<span className="font-mono">v{data.current_version}</span>
|
||||
</div>
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span className="text-muted-foreground">Latest</span>
|
||||
<span
|
||||
className={`font-mono ${
|
||||
hasUpdate ? "font-medium text-amber-600 dark:text-amber-400" : ""
|
||||
}`}
|
||||
>
|
||||
v{data.latest_version}
|
||||
</span>
|
||||
</div>
|
||||
{hasUpdate && (
|
||||
<p className="rounded-md bg-amber-500/10 px-2 py-1.5 text-xs text-amber-700 dark:text-amber-300">
|
||||
A new version of EvoScientist is available.
|
||||
<div className="space-y-3 px-3 py-3 text-sm">
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Backend
|
||||
</p>
|
||||
)}
|
||||
{data.warning && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Last check failed: {data.warning}
|
||||
</p>
|
||||
)}
|
||||
{hasUpdate && !downloaded && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
disabled={downloading}
|
||||
onClick={() => void handleDownload()}
|
||||
>
|
||||
<Download
|
||||
className={`mr-1 size-3.5 ${downloading ? "animate-bounce" : ""}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{downloading
|
||||
? "Downloading..."
|
||||
: `Download v${data.latest_version}`}
|
||||
</Button>
|
||||
)}
|
||||
{downloaded && (
|
||||
<div className="rounded-md border border-border p-2 text-xs">
|
||||
<p className="break-all font-mono text-muted-foreground">
|
||||
{downloaded.path}
|
||||
</p>
|
||||
<div className="mt-1 flex items-start gap-1">
|
||||
<code className="flex-1 break-all rounded bg-muted px-1.5 py-1">
|
||||
{downloaded.suggested_command}
|
||||
</code>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-6 shrink-0"
|
||||
aria-label="Copy install command"
|
||||
title="Copy install command"
|
||||
onClick={() =>
|
||||
void navigator.clipboard.writeText(
|
||||
downloaded.suggested_command
|
||||
)
|
||||
}
|
||||
>
|
||||
<Copy className="size-3.5" aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
<p className="mt-1 text-muted-foreground">
|
||||
Run this on the backend host, then restart it.
|
||||
</p>
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span className="text-muted-foreground">Installed</span>
|
||||
<span className="font-mono">v{data.current_version}</span>
|
||||
</div>
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span className="text-muted-foreground">Latest</span>
|
||||
<span
|
||||
className={`font-mono ${
|
||||
hasUpdate ? "font-medium text-amber-600 dark:text-amber-400" : ""
|
||||
}`}
|
||||
>
|
||||
v{data.latest_version}
|
||||
</span>
|
||||
</div>
|
||||
{data.warning && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Last check failed: {data.warning}
|
||||
</p>
|
||||
)}
|
||||
{hasUpdate && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
disabled={updating}
|
||||
onClick={() => void handleUpgrade("backend")}
|
||||
>
|
||||
<Upload className="mr-1 size-3.5" aria-hidden="true" />
|
||||
{updating ? "Starting..." : `Upgrade to v${data.latest_version}`}
|
||||
</Button>
|
||||
)}
|
||||
{rollbackVersions && rollbackVersions.length > 0 && (
|
||||
<div className="flex items-center gap-1 text-xs">
|
||||
<RotateCcw className="size-3 text-muted-foreground" aria-hidden="true" />
|
||||
<span className="text-muted-foreground">Roll back:</span>
|
||||
{rollbackVersions.map((v) => (
|
||||
<Button
|
||||
key={v.version}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-1.5 font-mono text-xs"
|
||||
disabled={updating}
|
||||
onClick={() => void handleRollback(v.version)}
|
||||
>
|
||||
v{v.version}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{selfData && (
|
||||
<div className="space-y-2 border-t border-border pt-2">
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Web UI
|
||||
</p>
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span className="text-muted-foreground">Installed</span>
|
||||
<span className="font-mono">v{selfData.current_version}</span>
|
||||
</div>
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span className="text-muted-foreground">Latest</span>
|
||||
<span
|
||||
className={`font-mono ${
|
||||
selfHasUpdate ? "font-medium text-amber-600 dark:text-amber-400" : ""
|
||||
}`}
|
||||
>
|
||||
v{selfData.latest_version}
|
||||
</span>
|
||||
</div>
|
||||
{selfHasUpdate && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
disabled={updating}
|
||||
onClick={() => void handleUpgrade("frontend")}
|
||||
>
|
||||
<Upload className="mr-1 size-3.5" aria-hidden="true" />
|
||||
{updating ? "Starting..." : `Upgrade Web UI to v${selfData.latest_version}`}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{updateStatus && (
|
||||
<p className="rounded-md bg-muted px-2 py-1.5 text-xs text-muted-foreground">
|
||||
{updateStatus === "applying"
|
||||
? "Updating — the service will briefly interrupt; this page may lose connection."
|
||||
: updateStatus}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
closeForSession,
|
||||
dismissVersion,
|
||||
isDismissed,
|
||||
isSessionClosed,
|
||||
loadDismissed,
|
||||
} from "./updatePrompt";
|
||||
|
||||
function fakeStorage(): Storage {
|
||||
const map = new Map<string, string>();
|
||||
return {
|
||||
getItem: (k) => map.get(k) ?? null,
|
||||
setItem: (k, v) => void map.set(k, v),
|
||||
removeItem: (k) => void map.delete(k),
|
||||
clear: () => map.clear(),
|
||||
key: () => null,
|
||||
get length() {
|
||||
return map.size;
|
||||
},
|
||||
} as Storage;
|
||||
}
|
||||
|
||||
describe("updatePrompt storage", () => {
|
||||
it("dismissed version stays dismissed until a newer one appears", () => {
|
||||
const s = fakeStorage();
|
||||
dismissVersion(s, "backend", "0.3.0");
|
||||
const d = loadDismissed(s);
|
||||
expect(isDismissed(d, "backend", "0.3.0")).toBe(true);
|
||||
expect(isDismissed(d, "backend", "0.3.1")).toBe(false);
|
||||
expect(isDismissed(d, "frontend", "0.3.0")).toBe(false);
|
||||
});
|
||||
|
||||
it("session close is separate from dismiss", () => {
|
||||
const s = fakeStorage();
|
||||
expect(isSessionClosed(s)).toBe(false);
|
||||
closeForSession(s);
|
||||
expect(isSessionClosed(s)).toBe(true);
|
||||
});
|
||||
|
||||
it("corrupt storage falls back to empty", () => {
|
||||
const s = fakeStorage();
|
||||
s.setItem("dismissedVersions", "{not json");
|
||||
expect(loadDismissed(s)).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
const DISMISS_KEY = "dismissedVersions";
|
||||
const SESSION_KEY = "updateBannerClosed";
|
||||
|
||||
export type UpdateTarget = "backend" | "frontend";
|
||||
export type DismissedVersions = Partial<Record<UpdateTarget, string>>;
|
||||
|
||||
export function loadDismissed(storage: Storage): DismissedVersions {
|
||||
try {
|
||||
const raw = storage.getItem(DISMISS_KEY);
|
||||
if (!raw) return {};
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
if (parsed && typeof parsed === "object") return parsed as DismissedVersions;
|
||||
return {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export function dismissVersion(storage: Storage, key: UpdateTarget, version: string): void {
|
||||
const next = { ...loadDismissed(storage), [key]: version };
|
||||
storage.setItem(DISMISS_KEY, JSON.stringify(next));
|
||||
}
|
||||
|
||||
export function isDismissed(
|
||||
dismissed: DismissedVersions,
|
||||
key: UpdateTarget,
|
||||
version: string
|
||||
): boolean {
|
||||
return dismissed[key] === version;
|
||||
}
|
||||
|
||||
export function isSessionClosed(storage: Storage): boolean {
|
||||
return storage.getItem(SESSION_KEY) === "1";
|
||||
}
|
||||
|
||||
export function closeForSession(storage: Storage): void {
|
||||
storage.setItem(SESSION_KEY, "1");
|
||||
}
|
||||
Reference in New Issue
Block a user