diff --git a/src/App.tsx b/src/App.tsx index 8fb3e26..1199976 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -4,6 +4,7 @@ import { useEffect, useRef, useState } from "react"; import { HashRouter, Navigate, Route, Routes } from "react-router-dom"; import { AppShell } from "./components/layout/app-shell"; import { UpdateDialog } from "./components/layout/update-dialog"; +import { WebUpdateDialog } from "./components/layout/web-update-dialog"; import { Confetti } from "./components/onboarding/confetti"; import { Onboarding, useOnboarding } from "./components/onboarding/onboarding"; import { ErrorBoundary } from "./components/shared/error-boundary"; @@ -19,6 +20,7 @@ import { useAuditStore } from "./stores/audit-store"; import { useExtensionStore } from "./stores/extension-store"; import { resolveMode, useUIStore } from "./stores/ui-store"; import { useUpdateStore } from "./stores/update-store"; +import { useWebUpdateStore } from "./stores/web-update-store"; /** Minimum interval (ms) between consecutive scan_and_sync calls */ const SCAN_DEBOUNCE_MS = 5_000; @@ -50,10 +52,13 @@ export default function App() { return () => mq.removeEventListener("change", onChange); }, [mode]); - // Check for updates on startup (non-blocking, silent failure) — desktop only + // Check for updates on startup (non-blocking, silent failure). + // Desktop uses Tauri's native updater; web mode polls GitHub releases. useEffect(() => { if (isDesktop()) { useUpdateStore.getState().checkForUpdate(); + } else { + useWebUpdateStore.getState().checkForUpdate(); } }, []); @@ -144,7 +149,7 @@ export default function App() { return ( <> {showConfetti && } - {isDesktop() && } + {isDesktop() ? : } diff --git a/src/components/layout/changelog-markdown.tsx b/src/components/layout/changelog-markdown.tsx new file mode 100644 index 0000000..9848e64 --- /dev/null +++ b/src/components/layout/changelog-markdown.tsx @@ -0,0 +1,39 @@ +import Markdown from "react-markdown"; + +const components = { + a: ({ href, children }: { href?: string; children?: React.ReactNode }) => ( + + {children} + + ), + h2: ({ children }: { children?: React.ReactNode }) => ( +

+ {children} +

+ ), + ul: ({ children }: { children?: React.ReactNode }) => ( +
    + {children} +
+ ), + p: ({ children }: { children?: React.ReactNode }) => ( +

{children}

+ ), +}; + +/** Renders a release-notes markdown body, or an "improvements" fallback when empty. */ +export function ChangelogMarkdown({ body }: { body: string }) { + if (!body) { + return ( +

+ Bug fixes and improvements. +

+ ); + } + return {body}; +} diff --git a/src/components/layout/sidebar.tsx b/src/components/layout/sidebar.tsx index a83f8c6..d6cef92 100644 --- a/src/components/layout/sidebar.tsx +++ b/src/components/layout/sidebar.tsx @@ -11,6 +11,7 @@ import { NavLink } from "react-router-dom"; import { isDesktop } from "@/lib/transport"; import { ScopeSwitcher } from "./scope-switcher"; import { UpdateCard } from "./update-card"; +import { WebUpdateCard } from "./web-update-card"; const mainNavItems = [ { to: "/", icon: LayoutDashboard, label: "Overview" }, @@ -88,7 +89,7 @@ export function Sidebar() { {/* Settings separator */}
- {isDesktop() && } + {isDesktop() ? : } diff --git a/src/components/layout/update-card.tsx b/src/components/layout/update-card.tsx index c720517..ec60ac1 100644 --- a/src/components/layout/update-card.tsx +++ b/src/components/layout/update-card.tsx @@ -3,10 +3,11 @@ import { useUpdateStore } from "@/stores/update-store"; export function UpdateCard() { const available = useUpdateStore((s) => s.available); + const dismissed = useUpdateStore((s) => s.dismissed); const installing = useUpdateStore((s) => s.installing); const promptUpdate = useUpdateStore((s) => s.promptUpdate); - if (!available) return null; + if (!available || dismissed) return null; return (
diff --git a/src/components/layout/update-dialog.tsx b/src/components/layout/update-dialog.tsx index b63123b..525b813 100644 --- a/src/components/layout/update-dialog.tsx +++ b/src/components/layout/update-dialog.tsx @@ -1,12 +1,13 @@ import { Download, Loader2, X } from "lucide-react"; -import Markdown from "react-markdown"; import { useUpdateStore } from "@/stores/update-store"; +import { ChangelogMarkdown } from "./changelog-markdown"; export function UpdateDialog() { const available = useUpdateStore((s) => s.available); const showChangelog = useUpdateStore((s) => s.showChangelog); const installing = useUpdateStore((s) => s.installing); - const dismissChangelog = useUpdateStore((s) => s.dismissChangelog); + const dismissDialog = useUpdateStore((s) => s.dismissDialog); + const dismissUpdate = useUpdateStore((s) => s.dismissUpdate); const confirmUpdate = useUpdateStore((s) => s.confirmUpdate); if (!showChangelog || !available) return null; @@ -16,7 +17,7 @@ export function UpdateDialog() { {/* Backdrop */}
{/* Dialog */} @@ -27,7 +28,7 @@ export function UpdateDialog() { Update to v{available.version} +
+ ); +} diff --git a/src/components/layout/web-update-dialog.tsx b/src/components/layout/web-update-dialog.tsx new file mode 100644 index 0000000..47ee406 --- /dev/null +++ b/src/components/layout/web-update-dialog.tsx @@ -0,0 +1,59 @@ +import { ExternalLink, X } from "lucide-react"; +import { useWebUpdateStore } from "@/stores/web-update-store"; +import { ChangelogMarkdown } from "./changelog-markdown"; + +const INSTRUCTIONS_URL = "https://github.com/RealZST/HarnessKit#updating"; + +export function WebUpdateDialog() { + const available = useWebUpdateStore((s) => s.available); + const showDialog = useWebUpdateStore((s) => s.showDialog); + const dismissDialog = useWebUpdateStore((s) => s.dismissDialog); + const dismissUpdate = useWebUpdateStore((s) => s.dismissUpdate); + + if (!showDialog || !available) return null; + + return ( +
+
+ +
+
+

+ Update to v{available.version} +

+ +
+ +
+ +
+ + +
+
+ ); +} diff --git a/src/pages/settings.tsx b/src/pages/settings.tsx index feca85f..5dc7826 100644 --- a/src/pages/settings.tsx +++ b/src/pages/settings.tsx @@ -24,6 +24,7 @@ import { toast } from "@/stores/toast-store"; import type { AppIcon, ThemeName } from "@/stores/ui-store"; import { useUIStore } from "@/stores/ui-store"; import { useUpdateStore } from "@/stores/update-store"; +import { useWebUpdateStore } from "@/stores/web-update-store"; const THEME_OPTIONS: { value: ThemeName; @@ -104,6 +105,48 @@ function UpdateSection() { ); } +function WebUpdateSection() { + const available = useWebUpdateStore((s) => s.available); + const checking = useWebUpdateStore((s) => s.checking); + const checkForUpdate = useWebUpdateStore((s) => s.checkForUpdate); + const promptUpdate = useWebUpdateStore((s) => s.promptUpdate); + + const handleCheck = async () => { + await checkForUpdate(true); + if (!useWebUpdateStore.getState().available) { + toast.success("You're up to date"); + } + }; + + return ( +
+ v{__APP_VERSION__} + {available ? ( + + ) : ( + + )} +
+ ); +} + export default function SettingsPage() { const { themeName, @@ -236,7 +279,7 @@ export default function SettingsPage() {

Settings

- {isDesktop() && } + {isDesktop() ? : }
diff --git a/src/stores/__tests__/web-update-store.test.ts b/src/stores/__tests__/web-update-store.test.ts new file mode 100644 index 0000000..cd1a6bb --- /dev/null +++ b/src/stores/__tests__/web-update-store.test.ts @@ -0,0 +1,250 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const RELEASES_URL = + "https://api.github.com/repos/RealZST/HarnessKit/releases/latest"; +const CACHE_KEY = "hk-web-update-cache"; +const DISMISS_KEY_PREFIX = "hk-update-dismissed-v"; + +function mockReleasesResponse(tag: string, body = "") { + return { + ok: true, + json: async () => ({ tag_name: tag, body }), + } as Response; +} + +describe("web-update-store", () => { + beforeEach(() => { + localStorage.clear(); + sessionStorage.clear(); + vi.resetModules(); + vi.unstubAllGlobals(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("starts with no update available", async () => { + const { useWebUpdateStore } = await import("../web-update-store"); + const state = useWebUpdateStore.getState(); + expect(state.available).toBeNull(); + expect(state.checking).toBe(false); + expect(state.showDialog).toBe(false); + expect(state.dismissed).toBe(false); + }); + + it("does not flag update when current version matches latest", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue(mockReleasesResponse("v1.2.1")), + ); + + const { useWebUpdateStore } = await import("../web-update-store"); + await useWebUpdateStore.getState().checkForUpdate(); + + expect(useWebUpdateStore.getState().available).toBeNull(); + }); + + it("flags update when latest is newer than current", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue(mockReleasesResponse("v1.3.0", "## Changes")), + ); + + const { useWebUpdateStore } = await import("../web-update-store"); + await useWebUpdateStore.getState().checkForUpdate(); + + const { available } = useWebUpdateStore.getState(); + expect(available?.version).toBe("1.3.0"); + expect(available?.body).toContain("## Changes"); + }); + + it("does not flag update when latest is older (downgrade)", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue(mockReleasesResponse("v1.0.0")), + ); + + const { useWebUpdateStore } = await import("../web-update-store"); + await useWebUpdateStore.getState().checkForUpdate(); + + expect(useWebUpdateStore.getState().available).toBeNull(); + }); + + it("strips 'New Contributors' and 'Full Changelog' sections from body", async () => { + const raw = [ + "## What's Changed", + "- feat: thing", + "## New Contributors", + "@someone made their first contribution", + "**Full Changelog**: https://...", + ].join("\n"); + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue(mockReleasesResponse("v1.3.0", raw)), + ); + + const { useWebUpdateStore } = await import("../web-update-store"); + await useWebUpdateStore.getState().checkForUpdate(); + + const body = useWebUpdateStore.getState().available?.body ?? ""; + expect(body).toContain("## What's Changed"); + expect(body).toContain("- feat: thing"); + expect(body).not.toContain("New Contributors"); + expect(body).not.toContain("Full Changelog"); + }); + + it("promptUpdate opens dialog only when an update is available", async () => { + const { useWebUpdateStore } = await import("../web-update-store"); + + useWebUpdateStore.getState().promptUpdate(); + expect(useWebUpdateStore.getState().showDialog).toBe(false); + + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue(mockReleasesResponse("v1.3.0")), + ); + await useWebUpdateStore.getState().checkForUpdate(); + useWebUpdateStore.getState().promptUpdate(); + expect(useWebUpdateStore.getState().showDialog).toBe(true); + }); + + it("dismissDialog closes the dialog without persisting dismissal", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue(mockReleasesResponse("v1.3.0")), + ); + const { useWebUpdateStore } = await import("../web-update-store"); + await useWebUpdateStore.getState().checkForUpdate(); + useWebUpdateStore.getState().promptUpdate(); + expect(useWebUpdateStore.getState().showDialog).toBe(true); + + useWebUpdateStore.getState().dismissDialog(); + expect(useWebUpdateStore.getState().showDialog).toBe(false); + expect(useWebUpdateStore.getState().dismissed).toBe(false); + expect(localStorage.getItem(`${DISMISS_KEY_PREFIX}1.3.0`)).toBeNull(); + }); + + it("dismissUpdate closes the dialog AND persists dismissal for the version", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue(mockReleasesResponse("v1.3.0")), + ); + const { useWebUpdateStore } = await import("../web-update-store"); + await useWebUpdateStore.getState().checkForUpdate(); + useWebUpdateStore.getState().promptUpdate(); + + useWebUpdateStore.getState().dismissUpdate(); + expect(useWebUpdateStore.getState().showDialog).toBe(false); + expect(useWebUpdateStore.getState().dismissed).toBe(true); + expect(localStorage.getItem(`${DISMISS_KEY_PREFIX}1.3.0`)).toBe("1"); + }); + + it("dismissUpdate is a no-op when no update is available", async () => { + const { useWebUpdateStore } = await import("../web-update-store"); + useWebUpdateStore.getState().dismissUpdate(); + expect(useWebUpdateStore.getState().dismissed).toBe(false); + expect(localStorage.length).toBe(0); + }); + + it("checkForUpdate seeds dismissed=true when localStorage has flag for the detected version", async () => { + localStorage.setItem(`${DISMISS_KEY_PREFIX}1.3.0`, "1"); + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue(mockReleasesResponse("v1.3.0")), + ); + const { useWebUpdateStore } = await import("../web-update-store"); + await useWebUpdateStore.getState().checkForUpdate(); + + expect(useWebUpdateStore.getState().available?.version).toBe("1.3.0"); + expect(useWebUpdateStore.getState().dismissed).toBe(true); + }); + + it("checkForUpdate keeps dismissed=false when a newer version appears", async () => { + localStorage.setItem(`${DISMISS_KEY_PREFIX}1.3.0`, "1"); + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue(mockReleasesResponse("v1.4.0")), + ); + const { useWebUpdateStore } = await import("../web-update-store"); + await useWebUpdateStore.getState().checkForUpdate(); + + expect(useWebUpdateStore.getState().available?.version).toBe("1.4.0"); + expect(useWebUpdateStore.getState().dismissed).toBe(false); + }); + + it("uses sessionStorage cache instead of refetching within TTL", async () => { + sessionStorage.setItem( + CACHE_KEY, + JSON.stringify({ tag: "v1.3.0", body: "cached body", at: Date.now() }), + ); + const fetchSpy = vi.fn(); + vi.stubGlobal("fetch", fetchSpy); + + const { useWebUpdateStore } = await import("../web-update-store"); + await useWebUpdateStore.getState().checkForUpdate(); + + expect(fetchSpy).not.toHaveBeenCalled(); + expect(useWebUpdateStore.getState().available?.version).toBe("1.3.0"); + expect(useWebUpdateStore.getState().available?.body).toContain( + "cached body", + ); + }); + + it("ignores stale sessionStorage cache and refetches", async () => { + sessionStorage.setItem( + CACHE_KEY, + JSON.stringify({ + tag: "v1.3.0", + body: "old", + at: Date.now() - 2 * 60 * 60 * 1000, + }), + ); + const fetchSpy = vi + .fn() + .mockResolvedValue(mockReleasesResponse("v1.4.0", "fresh")); + vi.stubGlobal("fetch", fetchSpy); + + const { useWebUpdateStore } = await import("../web-update-store"); + await useWebUpdateStore.getState().checkForUpdate(); + + expect(fetchSpy).toHaveBeenCalledWith(RELEASES_URL); + expect(useWebUpdateStore.getState().available?.version).toBe("1.4.0"); + expect(useWebUpdateStore.getState().available?.body).toContain("fresh"); + }); + + it("silently absorbs fetch failures", async () => { + vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("network"))); + + const { useWebUpdateStore } = await import("../web-update-store"); + await expect( + useWebUpdateStore.getState().checkForUpdate(), + ).resolves.toBeUndefined(); + expect(useWebUpdateStore.getState().available).toBeNull(); + }); + + it("silently absorbs non-OK HTTP responses", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ ok: false, status: 500 } as Response), + ); + + const { useWebUpdateStore } = await import("../web-update-store"); + await useWebUpdateStore.getState().checkForUpdate(); + expect(useWebUpdateStore.getState().available).toBeNull(); + }); + + it("ignores malformed tag_name", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ tag_name: "garbage" }), + } as Response), + ); + + const { useWebUpdateStore } = await import("../web-update-store"); + await useWebUpdateStore.getState().checkForUpdate(); + expect(useWebUpdateStore.getState().available).toBeNull(); + }); +}); diff --git a/src/stores/update-store.ts b/src/stores/update-store.ts index b3a774f..039df87 100644 --- a/src/stores/update-store.ts +++ b/src/stores/update-store.ts @@ -5,7 +5,7 @@ import { create } from "zustand"; /** Clean up GitHub auto-generated release notes for in-app display. * - Removes "New Contributors" and "Full Changelog" sections * - Converts bare PR URLs to clickable markdown links (e.g. [#3](url)) */ -function cleanChangelog(body: string): string { +export function cleanChangelog(body: string): string { const lines: string[] = []; let skip = false; for (const line of body.split("\n")) { @@ -32,6 +32,10 @@ function cleanChangelog(body: string): string { return lines.join("\n").trim(); } +/** localStorage key prefix for "user has dismissed the update reminder for this version". + * Shared between desktop and web update flows so a dismissal in either mode silences both. */ +export const DISMISS_KEY_PREFIX = "hk-update-dismissed-v"; + interface UpdateState { /** Available update version, null if none or not checked yet */ available: { version: string; body: string } | null; @@ -39,12 +43,16 @@ interface UpdateState { installing: boolean; /** Whether the changelog confirmation dialog is visible */ showChangelog: boolean; + /** User dismissed the reminder for `available.version` (sidebar card hidden). */ + dismissed: boolean; checkForUpdate: () => Promise; /** Open the changelog dialog (called when user clicks Update) */ promptUpdate: () => void; - /** Close the changelog dialog without updating */ - dismissChangelog: () => void; + /** Close the changelog dialog without updating (X / backdrop). Does not hide card. */ + dismissDialog: () => void; + /** Close the dialog AND persist a "don't remind for this version" flag (Later button). */ + dismissUpdate: () => void; /** Confirm and install the update */ confirmUpdate: () => Promise; } @@ -54,6 +62,7 @@ export const useUpdateStore = create((set, get) => ({ checking: false, installing: false, showChangelog: false, + dismissed: false, async checkForUpdate() { if (get().checking) return; @@ -61,11 +70,15 @@ export const useUpdateStore = create((set, get) => ({ try { const update = await check(); if (update) { + const dismissed = + localStorage.getItem(`${DISMISS_KEY_PREFIX}${update.version}`) === + "1"; set({ available: { version: update.version, body: cleanChangelog(update.body ?? ""), }, + dismissed, }); } } catch { @@ -81,10 +94,21 @@ export const useUpdateStore = create((set, get) => ({ } }, - dismissChangelog() { + dismissDialog() { set({ showChangelog: false }); }, + dismissUpdate() { + const { available } = get(); + if (!available) return; + try { + localStorage.setItem(`${DISMISS_KEY_PREFIX}${available.version}`, "1"); + } catch { + // localStorage unavailable — keep the in-memory dismissal anyway + } + set({ showChangelog: false, dismissed: true }); + }, + async confirmUpdate() { if (get().installing) return; set({ installing: true, showChangelog: false }); diff --git a/src/stores/web-update-store.ts b/src/stores/web-update-store.ts new file mode 100644 index 0000000..e99a766 --- /dev/null +++ b/src/stores/web-update-store.ts @@ -0,0 +1,140 @@ +import { create } from "zustand"; +import { cleanChangelog, DISMISS_KEY_PREFIX } from "./update-store"; + +const RELEASES_URL = + "https://api.github.com/repos/RealZST/HarnessKit/releases/latest"; +const CACHE_KEY = "hk-web-update-cache"; +const CACHE_TTL_MS = 60 * 60 * 1000; + +interface CachedRelease { + tag: string; + body: string; + at: number; +} + +function parseVersion(raw: string): [number, number, number] | null { + const match = raw.trim().match(/^v?(\d+)\.(\d+)\.(\d+)/); + if (!match) return null; + return [Number(match[1]), Number(match[2]), Number(match[3])]; +} + +function isNewer(current: string, latest: string): boolean { + const a = parseVersion(current); + const b = parseVersion(latest); + if (!a || !b) return false; + for (let i = 0; i < 3; i++) { + if (b[i] > a[i]) return true; + if (b[i] < a[i]) return false; + } + return false; +} + +function readCache(): CachedRelease | null { + try { + const raw = sessionStorage.getItem(CACHE_KEY); + if (!raw) return null; + const cached = JSON.parse(raw) as CachedRelease; + if (Date.now() - cached.at > CACHE_TTL_MS) return null; + return cached; + } catch { + return null; + } +} + +function writeCache(tag: string, body: string): void { + try { + const entry: CachedRelease = { tag, body, at: Date.now() }; + sessionStorage.setItem(CACHE_KEY, JSON.stringify(entry)); + } catch { + // sessionStorage unavailable — ignore + } +} + +async function fetchLatestRelease( + force = false, +): Promise<{ tag: string; body: string } | null> { + if (!force) { + const cached = readCache(); + if (cached) return { tag: cached.tag, body: cached.body }; + } + try { + const response = await fetch(RELEASES_URL); + if (!response.ok) return null; + const data = (await response.json()) as { + tag_name?: string; + body?: string; + }; + if (!data.tag_name) return null; + const body = data.body ?? ""; + writeCache(data.tag_name, body); + return { tag: data.tag_name, body }; + } catch { + return null; + } +} + +interface WebUpdateState { + /** Available update payload, null if none or not yet checked. */ + available: { version: string; body: string } | null; + checking: boolean; + /** Whether the changelog/instructions dialog is visible. */ + showDialog: boolean; + /** User dismissed the reminder for `available.version` (sidebar card hidden). */ + dismissed: boolean; + + /** When `force` is true, skip the sessionStorage cache and re-fetch from GitHub. */ + checkForUpdate: (force?: boolean) => Promise; + /** Open the dialog (only when an update is available). */ + promptUpdate: () => void; + /** Close the dialog without persisting dismissal (X / backdrop). */ + dismissDialog: () => void; + /** Close the dialog AND persist a "don't remind for this version" flag (Close button). */ + dismissUpdate: () => void; +} + +export const useWebUpdateStore = create((set, get) => ({ + available: null, + checking: false, + showDialog: false, + dismissed: false, + + async checkForUpdate(force = false) { + if (get().checking) return; + set({ checking: true }); + try { + const release = await fetchLatestRelease(force); + if (!release) return; + if (!isNewer(__APP_VERSION__, release.tag)) return; + const version = release.tag.replace(/^v/, ""); + const dismissed = + localStorage.getItem(`${DISMISS_KEY_PREFIX}${version}`) === "1"; + set({ + available: { version, body: cleanChangelog(release.body) }, + dismissed, + }); + } finally { + set({ checking: false }); + } + }, + + promptUpdate() { + if (get().available) { + set({ showDialog: true }); + } + }, + + dismissDialog() { + set({ showDialog: false }); + }, + + dismissUpdate() { + const { available } = get(); + if (!available) return; + try { + localStorage.setItem(`${DISMISS_KEY_PREFIX}${available.version}`, "1"); + } catch { + // localStorage unavailable — keep the in-memory dismissal anyway + } + set({ showDialog: false, dismissed: true }); + }, +})); diff --git a/vitest.config.ts b/vitest.config.ts index e408a36..fde9fd2 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -6,6 +6,9 @@ export default defineConfig({ environment: "jsdom", globals: true, }, + define: { + __APP_VERSION__: JSON.stringify("1.2.1"), + }, resolve: { alias: { "@": path.resolve(__dirname, "./src"),