Add update check functionality

Implement version checking and update notifications in the GUI
This commit is contained in:
2026-06-15 14:23:30 +07:00
parent 4c9eacccb7
commit 9db52595c7
8 changed files with 446 additions and 9 deletions
+19 -3
View File
@@ -9,8 +9,8 @@ import { Settings } from "./Settings";
import { EventCenter } from "./EventCenter";
import { maybeNotify } from "./notify";
import { COLORS, applyTheme, resolvePalette } from "./theme";
import { getStatusFull, applyPreset, onDaemonEvent, onDaemonRawEvent, setWorkspaceMeta, focusSurface, getEventLog, markEventsRead, clearEvents, getHealth, closeWorkspaceCmd, getConfig } from "./socketBridge";
import type { EventRecord, DaemonHealth, ConfigView } from "./socketBridge";
import { getStatusFull, applyPreset, onDaemonEvent, onDaemonRawEvent, setWorkspaceMeta, focusSurface, getEventLog, markEventsRead, clearEvents, getHealth, closeWorkspaceCmd, getConfig, checkUpdate } from "./socketBridge";
import type { EventRecord, DaemonHealth, ConfigView, UpdateInfo } from "./socketBridge";
import { leafIds } from "./layoutTypes";
import type { Group, WorkspaceView, SurfaceState } from "./layoutTypes";
@@ -36,6 +36,8 @@ export function App() {
const [eventsOpen, setEventsOpen] = useState(() => loadFlag("spacesh.eventsOpen", true));
const [sidebarOpen, setSidebarOpen] = useState(() => loadFlag("spacesh.sidebarOpen", true));
const [health, setHealth] = useState<DaemonHealth | null>(null);
const [update, setUpdate] = useState<UpdateInfo | null>(null);
const [updateChecking, setUpdateChecking] = useState(false);
const [config, setConfigState] = useState<ConfigView | null>(null);
// Bumped when the daemon connection is re-established; used to remount the
// layout so terminals re-attach (snapshot + live stream) to the restarted daemon.
@@ -77,6 +79,13 @@ export function App() {
catch { setConnected(false); }
}, []);
const runUpdateCheck = useCallback(async () => {
setUpdateChecking(true);
try { setUpdate(await checkUpdate()); }
catch { /* offline / server unreachable — leave the last known result */ }
finally { setUpdateChecking(false); }
}, []);
const wsOf = (surfaceId: string): WorkspaceView | undefined =>
wsRef.current.find((w) => surfaceId in w.surfaces);
@@ -142,6 +151,13 @@ export function App() {
return () => window.removeEventListener("keydown", onKey);
}, []);
// Update check: once on launch, then every 6h.
useEffect(() => {
void runUpdateCheck();
const id = setInterval(() => { void runUpdateCheck(); }, 6 * 60 * 60 * 1000);
return () => clearInterval(id);
}, [runUpdateCheck]);
useEffect(() => { saveFlag("spacesh.eventsOpen", eventsOpen); }, [eventsOpen]);
useEffect(() => { saveFlag("spacesh.sidebarOpen", sidebarOpen); }, [sidebarOpen]);
@@ -162,7 +178,7 @@ export function App() {
return (
<div style={{ display: "flex", flexDirection: "column", height: "100vh", background: COLORS.bgApp }}>
<TopBar active={active} eventsOpen={eventsOpen} onToggleEvents={() => setEventsOpen((v) => !v)} onShowEvents={() => setEventsOpen(true)} sidebarOpen={sidebarOpen} onToggleSidebar={() => setSidebarOpen((v) => !v)} unread={unread} onOpenSettings={() => { if (config) setSettingsOpen(true); }} />
<TopBar active={active} eventsOpen={eventsOpen} onToggleEvents={() => setEventsOpen((v) => !v)} onShowEvents={() => setEventsOpen(true)} sidebarOpen={sidebarOpen} onToggleSidebar={() => setSidebarOpen((v) => !v)} unread={unread} onOpenSettings={() => { if (config) setSettingsOpen(true); }} update={update} updateChecking={updateChecking} onCheckUpdate={() => { void runUpdateCheck(); }} />
<div style={{ flex: 1, display: "flex", minHeight: 0 }}>
<Sidebar railMode={!sidebarOpen} groups={groups} workspaces={workspaces} activeId={activeId} onSelect={selectWorkspace} onNew={() => setWizard(true)} onDelete={setDeleteTarget} health={health} connected={connected} />
<div style={{ flex: 1, display: "flex", flexDirection: "column", minWidth: 0 }}>
+88 -1
View File
@@ -1,7 +1,10 @@
import { FolderGit2, PanelLeft, PanelRight, Search, Bell, Settings, ChevronDown } from "lucide-react";
import { useState } from "react";
import { FolderGit2, PanelLeft, PanelRight, Search, Bell, Settings, ChevronDown, RefreshCw, Download } from "lucide-react";
import { COLORS, FONT } from "./theme";
import type { WorkspaceView } from "./layoutTypes";
import { leafIds } from "./layoutTypes";
import { openExternal } from "./socketBridge";
import type { UpdateInfo } from "./socketBridge";
/** Human-readable descriptor of the active workspace layout (mock until a real preset id is tracked). */
function describeLayout(w: WorkspaceView | null): string {
@@ -28,8 +31,83 @@ function IconBtn({ icon, onClick, active, title }: { icon: React.ReactNode; onCl
);
}
/** Update-check button (lights up when the server has a newer version) plus its popover. */
function UpdateControl({ update, checking, onCheck }: { update: UpdateInfo | null; checking: boolean; onCheck: () => void }) {
const [open, setOpen] = useState(false);
const hasUpdate = !!update?.has_update;
return (
<div style={{ position: "relative", display: "flex" }}>
<button
title={hasUpdate ? `Доступна версия ${update?.latest}` : "Проверить обновления"}
onClick={() => setOpen((v) => !v)}
style={{
display: "flex", alignItems: "center", justifyContent: "center",
width: 26, height: 26, borderRadius: 6,
background: hasUpdate ? "rgba(52,211,194,0.15)" : open ? COLORS.bgElevated : "transparent",
border: `1px solid ${hasUpdate ? COLORS.accent : open ? COLORS.borderSubtle : "transparent"}`,
color: hasUpdate ? COLORS.accent : COLORS.textSecondary,
boxShadow: hasUpdate ? `0 0 10px rgba(52,211,194,0.5)` : "none",
animation: hasUpdate ? "spaceshPulse 2s ease-in-out infinite" : "none",
}}
>
<RefreshCw size={15} style={{ animation: checking ? "spaceshSpin 0.8s linear infinite" : "none" }} />
</button>
{open && (
<>
{/* click-away backdrop */}
<div onClick={() => setOpen(false)} style={{ position: "fixed", inset: 0, zIndex: 200 }} />
<div style={{
position: "absolute", top: 32, right: 0, zIndex: 201, width: 240,
background: COLORS.bgPanel, border: `1px solid ${COLORS.borderSubtle}`, borderRadius: 8,
padding: 12, boxShadow: "0 8px 24px rgba(0,0,0,0.4)",
fontFamily: FONT.ui, color: COLORS.textPrimary,
}}>
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 8 }}>Обновление</div>
<div style={{ fontSize: 12, color: COLORS.textSecondary, display: "flex", justifyContent: "space-between" }}>
<span>Установлено</span><span style={{ fontFamily: FONT.mono }}>{update?.current ?? "—"}</span>
</div>
<div style={{ fontSize: 12, color: COLORS.textSecondary, display: "flex", justifyContent: "space-between", marginTop: 2 }}>
<span>На сервере</span><span style={{ fontFamily: FONT.mono }}>{update?.latest || "—"}</span>
</div>
{hasUpdate ? (
<button
onClick={() => { void openExternal(update!.url); setOpen(false); }}
style={{
marginTop: 10, width: "100%", height: 30, borderRadius: 6, border: "none", cursor: "pointer",
background: COLORS.accent, color: COLORS.bgApp, fontFamily: FONT.ui, fontSize: 13, fontWeight: 600,
display: "flex", alignItems: "center", justifyContent: "center", gap: 6,
}}
>
<Download size={14} /> Скачать {update?.latest}
</button>
) : (
<div style={{ marginTop: 10, fontSize: 12, color: COLORS.stDone }}>Установлена последняя версия</div>
)}
<button
onClick={onCheck}
disabled={checking}
style={{
marginTop: 8, width: "100%", height: 28, borderRadius: 6, cursor: checking ? "default" : "pointer",
background: "transparent", border: `1px solid ${COLORS.borderStrong}`, color: COLORS.textSecondary,
fontFamily: FONT.ui, fontSize: 12,
}}
>
{checking ? "Проверяю…" : "Проверить снова"}
</button>
</div>
</>
)}
</div>
);
}
export function TopBar({
active, eventsOpen, onToggleEvents, onShowEvents, sidebarOpen, onToggleSidebar, unread, onOpenSettings,
update, updateChecking, onCheckUpdate,
}: {
active: WorkspaceView | null;
eventsOpen: boolean;
@@ -39,6 +117,9 @@ export function TopBar({
onToggleSidebar: () => void;
unread: number;
onOpenSettings: () => void;
update: UpdateInfo | null;
updateChecking: boolean;
onCheckUpdate: () => void;
}) {
return (
<div
@@ -69,9 +150,15 @@ export function TopBar({
<div style={{ flex: 1 }} />
<style>{`
@keyframes spaceshSpin { to { transform: rotate(360deg); } }
@keyframes spaceshPulse { 0%,100% { box-shadow: 0 0 6px rgba(52,211,194,0.35); } 50% { box-shadow: 0 0 14px rgba(52,211,194,0.7); } }
`}</style>
{/* Right cluster */}
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
<IconBtn icon={<Search size={16} />} title="Search (mock)" />
<UpdateControl update={update} checking={updateChecking} onCheck={onCheckUpdate} />
<div style={{ position: "relative", display: "flex" }}>
<IconBtn icon={<Bell size={16} />} onClick={onShowEvents} active={eventsOpen} title="Open activity log" />
{unread > 0 && (
+10
View File
@@ -189,6 +189,16 @@ export async function getHealth(): Promise<DaemonHealth> {
return await invoke<DaemonHealth>("health");
}
export interface UpdateInfo { current: string; latest: string; has_update: boolean; url: string }
export async function checkUpdate(): Promise<UpdateInfo> {
return await invoke<UpdateInfo>("check_update");
}
export async function openExternal(url: string): Promise<void> {
await invoke("open_external", { url });
}
export async function setZoom(workspaceId: string, surfaceId: string | null): Promise<void> {
await invoke("set_zoom", { workspaceId, surfaceId });
}