feat(launch): add update status badge to launch window toolbar

- Add update badge button that shows current updater state
  (idle, checking, up-to-date, available, downloading, ready, error)
- Badge triggers check/download/install based on current state
- Poll update status summary every 2.5s for live state reflection
- Add CSS module styles for badge variants (quiet, hot, spin)
- Add i18n strings for badge labels and tooltips (en, es, zh-CN)
This commit is contained in:
webadderall committed 2026-03-29 19:30:02 +11:00
1 parent 001f86a550
commit 5a5023d1ea
5 files changed
+216 -3

No files matched your search

@@ -35,6 +35,66 @@
padding-right: 2px;
}
.updateBadge {
display: inline-flex;
align-items: center;
gap: 7px;
height: 34px;
padding: 0 12px;
border-radius: 11px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.03);
font-size: 12px;
font-weight: 700;
letter-spacing: 0.01em;
transition: all 0.15s ease;
cursor: pointer;
flex-shrink: 0;
}
.updateBadge:disabled {
opacity: 0.72;
cursor: default;
}
.updateBadgeQuiet {
color: #a5b4c7;
border-color: rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.035);
}
.updateBadgeQuiet:hover:not(:disabled) {
color: #d7dee8;
background: rgba(255, 255, 255, 0.06);
}
.updateBadgeHot {
color: #f8fbff;
border-color: rgba(125, 211, 252, 0.24);
background: linear-gradient(180deg, rgba(125, 211, 252, 0.12), rgba(125, 211, 252, 0.04));
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.05);
}
.updateBadgeHot:hover:not(:disabled) {
color: #ffffff;
border-color: rgba(125, 211, 252, 0.36);
background: linear-gradient(180deg, rgba(125, 211, 252, 0.17), rgba(125, 211, 252, 0.07));
transform: translateY(-1px);
}
.updateBadgeSpin {
animation: updateBadgeSpin 0.9s linear infinite;
}
@keyframes updateBadgeSpin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.barState {
display: flex;
align-items: center;
+120
View File
@@ -1,6 +1,8 @@
import {
AppWindow,
ArrowUpCircle,
ChevronUp,
CheckCircle2,
Eye,
EyeOff,
FolderOpen,
@@ -12,6 +14,7 @@ import {
MoreVertical,
Pause,
Play,
RefreshCw,
Square,
Timer,
Video,
@@ -185,6 +188,17 @@ export function LaunchWindow() {
const [hideHudFromCapture, setHideHudFromCapture] = useState(true);
const [platform, setPlatform] = useState<string | null>(null);
const [appVersion, setAppVersion] = useState<string | null>(null);
const [updateStatus, setUpdateStatus] = useState<{
status: "idle" | "checking" | "up-to-date" | "available" | "downloading" | "ready" | "error";
currentVersion: string;
availableVersion: string | null;
detail?: string;
}>({
status: "idle",
currentVersion: "",
availableVersion: null,
});
const [updateActionPending, setUpdateActionPending] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
const hudContentRef = useRef<HTMLDivElement>(null);
const hudBarRef = useRef<HTMLDivElement>(null);
@@ -372,6 +386,31 @@ export function LaunchWindow() {
};
}, []);
useEffect(() => {
let mounted = true;
const refreshUpdateStatus = async () => {
try {
const summary = await window.electronAPI.getUpdateStatusSummary();
if (mounted) {
setUpdateStatus(summary);
}
} catch (error) {
console.error("Failed to load update status summary:", error);
}
};
void refreshUpdateStatus();
const pollTimer = window.setInterval(() => {
void refreshUpdateStatus();
}, 2500);
return () => {
mounted = false;
window.clearInterval(pollTimer);
};
}, []);
useEffect(() => {
let cancelled = false;
const loadVersion = async () => {
@@ -619,6 +658,74 @@ export function LaunchWindow() {
toggleDropdown("webcam");
};
const updateButtonLabel =
updateStatus.status === "up-to-date"
? t("recording.update.updated")
: t("recording.update.update");
const updateButtonTitle = (() => {
switch (updateStatus.status) {
case "up-to-date":
return t("recording.update.upToDateTitle", "Recordly {{version}} is up to date.", {
version: updateStatus.currentVersion,
});
case "available":
case "ready":
return updateStatus.availableVersion
? t("recording.update.availableTitle", "Recordly {{version}} is available.", {
version: updateStatus.availableVersion,
})
: t("recording.update.availableGenericTitle");
case "downloading":
return updateStatus.detail ?? t("recording.update.downloadingTitle");
case "checking":
return t("recording.update.checkingTitle");
case "error":
return updateStatus.detail ?? t("recording.update.errorTitle");
default:
return t("recording.update.idleTitle");
}
})();
const updateButtonClassName = `${styles.updateBadge} ${updateStatus.status === "up-to-date" ? styles.updateBadgeQuiet : styles.updateBadgeHot} ${styles.electronNoDrag}`;
const updateButtonIcon = (() => {
switch (updateStatus.status) {
case "up-to-date":
return <CheckCircle2 size={14} />;
case "checking":
case "downloading":
return <RefreshCw size={14} className={styles.updateBadgeSpin} />;
default:
return <ArrowUpCircle size={14} />;
}
})();
const handleUpdateButtonClick = async () => {
if (updateActionPending || updateStatus.status === "downloading") {
return;
}
setUpdateActionPending(true);
try {
switch (updateStatus.status) {
case "available":
await window.electronAPI.downloadAvailableUpdate();
break;
case "ready":
await window.electronAPI.installDownloadedUpdate();
break;
default:
await window.electronAPI.checkForAppUpdates();
break;
}
const summary = await window.electronAPI.getUpdateStatusSummary();
setUpdateStatus(summary);
} catch (error) {
console.error("Failed to handle update button action:", error);
} finally {
setUpdateActionPending(false);
}
};
const recordingControls = (
<>
<div className="flex items-center gap-[5px]">
@@ -1054,6 +1161,19 @@ export function LaunchWindow() {
<RxDragHandleDots2 size={14} className="text-[#6b6b78]" />
</div>
<button
type="button"
onClick={() => {
void handleUpdateButtonClick();
}}
className={updateButtonClassName}
title={updateButtonTitle}
disabled={updateActionPending}
>
{updateButtonIcon}
<span>{updateButtonLabel}</span>
</button>
<div className={styles.barStateViewport}>
<AnimatePresence initial={false} mode="wait">
<motion.div
+12 -1
View File
@@ -37,7 +37,18 @@
"pause": "Pause",
"stop": "Stop",
"cancel": "Cancel",
"more": "More"
"more": "More",
"update": {
"update": "Update",
"updated": "Updated",
"idleTitle": "Check for updates.",
"checkingTitle": "Checking for updates...",
"downloadingTitle": "Downloading update...",
"errorTitle": "Update check failed. Click to retry.",
"upToDateTitle": "Recordly {{version}} is up to date.",
"availableTitle": "Recordly {{version}} is available.",
"availableGenericTitle": "An update is available."
}
},
"sourceSelector": {
"loadingSources": "Loading sources...",
+12 -1
View File
@@ -37,7 +37,18 @@
"pause": "Pausa",
"stop": "Detener",
"cancel": "Cancelar",
"more": "Más"
"more": "Más",
"update": {
"update": "Actualizar",
"updated": "Actualizado",
"idleTitle": "Buscar actualizaciones.",
"checkingTitle": "Buscando actualizaciones...",
"downloadingTitle": "Descargando actualización...",
"errorTitle": "La comprobación de actualizaciones falló. Haz clic para reintentar.",
"upToDateTitle": "Recordly {{version}} está actualizado.",
"availableTitle": "Recordly {{version}} está disponible.",
"availableGenericTitle": "Hay una actualización disponible."
}
},
"sourceSelector": {
"loadingSources": "Cargando fuentes...",
+12 -1
View File
@@ -37,7 +37,18 @@
"pause": "暂停",
"stop": "停止",
"cancel": "取消",
"more": "更多"
"more": "更多",
"update": {
"update": "更新",
"updated": "已是最新",
"idleTitle": "检查更新。",
"checkingTitle": "正在检查更新...",
"downloadingTitle": "正在下载更新...",
"errorTitle": "检查更新失败。点击重试。",
"upToDateTitle": "Recordly {{version}} 已是最新版本。",
"availableTitle": "Recordly {{version}} 可更新。",
"availableGenericTitle": "有可用更新。"
}
},
"sourceSelector": {
"loadingSources": "正在加载源...",