feat(updater): add explicit download toast flow

This commit is contained in:
webadderall committed 2026-03-28 21:06:41 +11:00
1 parent 3fd52919d7
commit efc89870aa
7 files changed
+656 -140

No files matched your search

+10 -84
View File
@@ -1,9 +1,8 @@
import { Gift } from "lucide-react";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { CountdownOverlay } from "./components/countdown/CountdownOverlay";
import { LaunchWindow } from "./components/launch/LaunchWindow";
import { SourceSelector } from "./components/launch/SourceSelector";
import { UpdateToastWindow } from "./components/launch/UpdateToastWindow";
import { Toaster } from "./components/ui/sonner";
import { ShortcutsConfigDialog } from "./components/video-editor/ShortcutsConfigDialog";
import VideoEditor from "./components/video-editor/VideoEditor";
@@ -11,13 +10,6 @@ import { useI18n } from "./contexts/I18nContext";
import { ShortcutsProvider } from "./contexts/ShortcutsContext";
import { loadAllCustomFonts } from "./lib/customFonts";
const UPDATE_TOAST_ID = "recordly-update-ready";
function formatDelayHours(delayMs: number) {
const hours = Math.max(1, Math.round(delayMs / (60 * 60 * 1000)));
return `${hours}h`;
}
export default function App() {
const [windowType, setWindowType] = useState("");
const { locale, t } = useI18n();
@@ -27,13 +19,18 @@ export default function App() {
const type = params.get("windowType") || "";
setWindowType(type);
if (type === "hud-overlay" || type === "source-selector" || type === "countdown") {
if (
type === "hud-overlay" ||
type === "source-selector" ||
type === "countdown" ||
type === "update-toast"
) {
document.body.style.background = "transparent";
document.documentElement.style.background = "transparent";
document.getElementById("root")?.style.setProperty("background", "transparent");
}
if (type === "hud-overlay") {
if (type === "hud-overlay" || type === "update-toast") {
document.documentElement.style.overflow = "visible";
document.body.style.overflow = "visible";
document.getElementById("root")?.style.setProperty("overflow", "visible");
@@ -49,79 +46,6 @@ export default function App() {
windowType === "editor" ? t("app.editorTitle", "Recordly Editor") : t("app.name", "Recordly");
}, [windowType, locale, t]);
useEffect(() => {
if (
windowType === "countdown" ||
windowType === "source-selector" ||
typeof window.electronAPI?.onUpdateReadyToast !== "function"
) {
return;
}
return window.electronAPI.onUpdateReadyToast((payload) => {
toast.custom(
(toastInstance) => (
<div className="pointer-events-auto flex w-[390px] items-start gap-3 rounded-2xl border border-sky-300/20 bg-[#0d1117]/95 p-4 text-white shadow-2xl shadow-black/40 backdrop-blur-xl">
<div className="mt-0.5 flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-2xl bg-sky-400/15 text-sky-300">
<Gift className="h-5 w-5" />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<p className="text-sm font-semibold tracking-tight">
{payload.isPreview ? "Update Toast Preview" : `Recordly ${payload.version} is ready`}
</p>
{payload.isPreview ? (
<span className="rounded-full border border-sky-300/20 bg-sky-400/10 px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.18em] text-sky-200">
Dev
</span>
) : null}
</div>
<p className="mt-1 text-sm leading-5 text-white/70">{payload.detail}</p>
<div className="mt-3 flex flex-wrap items-center gap-2">
<button
type="button"
onClick={async () => {
toast.dismiss(toastInstance);
if (payload.isPreview) {
toast.success("Preview only. No real update was installed.");
return;
}
await window.electronAPI.installDownloadedUpdate();
}}
className="rounded-xl bg-sky-400 px-3 py-2 text-xs font-semibold text-[#031a2c] transition-colors hover:bg-sky-300"
>
Update Now
</button>
<button
type="button"
onClick={async () => {
toast.dismiss(toastInstance);
if (payload.isPreview) {
toast.success(`Preview dismissed. We'll show it again in ${formatDelayHours(payload.delayMs)}.`);
return;
}
const result = await window.electronAPI.deferDownloadedUpdate(payload.delayMs);
if (result.success) {
toast.success(`Okay, we'll remind you in ${formatDelayHours(payload.delayMs)}.`);
} else if (result.message) {
toast.error(result.message);
}
}}
className="rounded-xl border border-white/10 bg-white/5 px-3 py-2 text-xs font-medium text-white/85 transition-colors hover:bg-white/10"
>
Update Later ({formatDelayHours(payload.delayMs)})
</button>
</div>
</div>
</div>
),
{ id: UPDATE_TOAST_ID, duration: Number.POSITIVE_INFINITY },
);
});
}, [windowType]);
switch (windowType) {
case "hud-overlay":
return (
@@ -134,6 +58,8 @@ export default function App() {
return <SourceSelector />;
case "countdown":
return <CountdownOverlay />;
case "update-toast":
return <UpdateToastWindow />;
case "editor":
return (
<ShortcutsProvider>
+180
View File
@@ -0,0 +1,180 @@
import { AlertCircle, Download, LoaderCircle, Rocket } from "lucide-react";
import { useEffect, useState } from "react";
type UpdateToastPayload = {
version: string;
detail: string;
phase: "available" | "downloading" | "ready" | "error";
delayMs: number;
isPreview?: boolean;
progressPercent?: number;
};
function formatDelayHours(delayMs: number) {
const hours = Math.max(1, Math.round(delayMs / (60 * 60 * 1000)));
return `${hours}h`;
}
function getToastTitle(payload: UpdateToastPayload) {
if (payload.isPreview) {
return "Update Toast Preview";
}
switch (payload.phase) {
case "available":
return `Recordly ${payload.version} is available`;
case "downloading":
return `Downloading Recordly ${payload.version}`;
case "ready":
return `Recordly ${payload.version} is ready`;
case "error":
return `Recordly ${payload.version} needs attention`;
}
}
function getIcon(payload: UpdateToastPayload) {
switch (payload.phase) {
case "available":
return <Download className="h-5 w-5" />;
case "downloading":
return <LoaderCircle className="h-5 w-5 animate-spin" />;
case "ready":
return <Rocket className="h-5 w-5" />;
case "error":
return <AlertCircle className="h-5 w-5" />;
}
}
export function UpdateToastWindow() {
const [payload, setPayload] = useState<UpdateToastPayload | null>(null);
useEffect(() => {
let mounted = true;
void window.electronAPI.getCurrentUpdateToastPayload().then((nextPayload) => {
if (mounted) {
setPayload(nextPayload);
}
});
const dispose = window.electronAPI.onUpdateToastStateChanged((nextPayload) => {
setPayload(nextPayload);
});
return () => {
mounted = false;
dispose();
};
}, []);
if (!payload) {
return <div className="h-full w-full bg-transparent" />;
}
const normalizedProgress = Math.max(0, Math.min(100, Math.round(payload.progressPercent ?? 0)));
return (
<div className="flex h-full w-full items-center justify-center bg-transparent p-2">
<div className="pointer-events-auto flex w-full max-w-[404px] items-start gap-3 rounded-[24px] border border-sky-300/20 bg-[#0d1117]/95 p-4 text-white shadow-2xl shadow-black/45 backdrop-blur-xl">
<div className="mt-0.5 flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-2xl bg-sky-400/15 text-sky-300">
{getIcon(payload)}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<p className="text-sm font-semibold tracking-tight">{getToastTitle(payload)}</p>
{payload.isPreview ? (
<span className="rounded-full border border-sky-300/20 bg-sky-400/10 px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.18em] text-sky-200">
Dev
</span>
) : null}
</div>
<p className="mt-1 text-sm leading-5 text-white/70">{payload.detail}</p>
{payload.phase === "downloading" ? (
<div className="mt-3">
<div className="h-2 overflow-hidden rounded-full bg-white/10">
<div
className="h-full rounded-full bg-sky-300 transition-[width] duration-300"
style={{ width: `${normalizedProgress}%` }}
/>
</div>
<p className="mt-2 text-xs font-medium text-sky-100/85">{normalizedProgress}% downloaded</p>
</div>
) : null}
<div className="mt-3 flex flex-wrap items-center gap-2">
{payload.phase === "available" || payload.phase === "error" ? (
<button
type="button"
onClick={async () => {
if (payload.isPreview) {
await window.electronAPI.dismissUpdateToast();
return;
}
await window.electronAPI.downloadAvailableUpdate();
}}
className="rounded-xl bg-sky-400 px-3 py-2 text-xs font-semibold text-[#031a2c] transition-colors hover:bg-sky-300"
>
Download Update
</button>
) : null}
{payload.phase === "ready" ? (
<button
type="button"
onClick={async () => {
await window.electronAPI.installDownloadedUpdate();
}}
className="rounded-xl bg-sky-400 px-3 py-2 text-xs font-semibold text-[#031a2c] transition-colors hover:bg-sky-300"
>
Install Update
</button>
) : null}
{payload.phase === "downloading" ? (
<button
type="button"
onClick={async () => {
await window.electronAPI.dismissUpdateToast();
}}
className="rounded-xl border border-white/10 bg-white/5 px-3 py-2 text-xs font-medium text-white/85 transition-colors hover:bg-white/10"
>
Hide
</button>
) : null}
{payload.phase !== "downloading" ? (
<button
type="button"
onClick={async () => {
if (payload.isPreview) {
await window.electronAPI.dismissUpdateToast();
return;
}
await window.electronAPI.deferDownloadedUpdate(payload.delayMs);
}}
className="rounded-xl border border-white/10 bg-white/5 px-3 py-2 text-xs font-medium text-white/85 transition-colors hover:bg-white/10"
>
Later ({formatDelayHours(payload.delayMs)})
</button>
) : null}
{!payload.isPreview && payload.phase !== "downloading" ? (
<button
type="button"
onClick={async () => {
await window.electronAPI.skipUpdateVersion();
}}
className="rounded-xl border border-sky-300/15 bg-transparent px-3 py-2 text-xs font-medium text-white/65 transition-colors hover:bg-white/5 hover:text-white"
>
Skip This Version
</button>
) : null}
</div>
</div>
</div>
</div>
);
}