diff --git a/src/components/UpdateBanner.tsx b/src/components/UpdateBanner.tsx deleted file mode 100644 index 455f72a..0000000 --- a/src/components/UpdateBanner.tsx +++ /dev/null @@ -1,115 +0,0 @@ -/** - * UpdateBanner — bottom-right prompt shown when a newer release exists. - * - * Checks once on mount and never again: a browser session can run for days and - * nagging someone mid-task is worse than them finding out on next launch. - * Nothing is downloaded until the user agrees, and "Later" is remembered for - * the rest of the session only. - * - * Sits above the toast stack (z-9999 vs 9998) because it does not auto-dismiss. - */ -import { useEffect, useState } from "react"; -import { Download, X } from "lucide-react"; -import { check, type Update } from "@tauri-apps/plugin-updater"; -import { relaunch } from "@tauri-apps/plugin-process"; -import { useUIStore } from "@/stores/ui"; - -const IS_TAURI = typeof window !== "undefined" && "__TAURI_INTERNALS__" in window; - -type Phase = "idle" | "downloading" | "done"; - -export function UpdateBanner() { - const [update, setUpdate] = useState(null); - const [phase, setPhase] = useState("idle"); - const [pct, setPct] = useState(0); - const pushToast = useUIStore((s) => s.pushToast); - - useEffect(() => { - if (!IS_TAURI) return; - let cancelled = false; - // A failed check is not worth telling anyone about — offline is normal. - check() - .then((u) => { - if (!cancelled && u) setUpdate(u); - }) - .catch(() => {}); - return () => { - cancelled = true; - }; - }, []); - - if (!update) return null; - - async function install() { - if (!update) return; - setPhase("downloading"); - let total = 0; - let got = 0; - try { - await update.downloadAndInstall((e) => { - if (e.event === "Started") total = e.data.contentLength ?? 0; - else if (e.event === "Progress") { - got += e.data.chunkLength; - if (total > 0) setPct(Math.round((got / total) * 100)); - } - }); - setPhase("done"); - } catch { - setPhase("idle"); - pushToast("Update failed. Try downloading it from GitHub.", "danger"); - } - } - - return ( -
- - - - {phase === "downloading" - ? `Downloading ${pct}%` - : phase === "done" - ? "Restart to finish" - : `Version ${update.version} is available`} - - -
- {phase === "idle" && ( - <> - - - - )} - - {phase === "done" && ( - - )} -
-
- ); -} diff --git a/src/components/browser/Toolbar.tsx b/src/components/browser/Toolbar.tsx index 735e8e4..4f6c5a4 100644 --- a/src/components/browser/Toolbar.tsx +++ b/src/components/browser/Toolbar.tsx @@ -21,6 +21,7 @@ import { getLiveWorkspaceActiveTab } from "@/lib/workspaceTabs"; import { toggleBookmarkForActiveTab } from "@/lib/bookmarkAction"; import { resolveInput } from "@/lib/url"; import { focusAppWebview } from "@/services/browser"; +import { UpdateButton } from "@/components/browser/UpdateButton"; /** * Address-bar security indicator, derived purely from the URL scheme. @@ -252,6 +253,8 @@ export function Toolbar({ onNavigate, onBack, onForward, onReload }: ToolbarProp + + {/* Bookmark button */} + ); +} diff --git a/src/components/layout/RootLayout.tsx b/src/components/layout/RootLayout.tsx index e45b2fd..234a207 100644 --- a/src/components/layout/RootLayout.tsx +++ b/src/components/layout/RootLayout.tsx @@ -14,7 +14,7 @@ import { SettingsPanel } from "@/components/panels/SettingsPanel"; import { CommandPalette } from "@/components/CommandPalette"; import { ShortcutHelp } from "@/components/ShortcutHelp"; import { Toast } from "@/components/Toast"; -import { UpdateBanner } from "@/components/UpdateBanner"; +import { useUpdatesStore } from "@/stores/updates"; import { ErrorBoundary } from "@/components/ErrorBoundary"; import { ViewportSurface } from "@/components/panels/ViewportPanel"; import { useUIStore, useViewportMode } from "@/stores/ui"; @@ -35,6 +35,10 @@ type BridgeType = ReturnType; const IS_TAURI = typeof window !== "undefined" && "__TAURI_INTERNALS__" in window; export function RootLayout() { + // One update check per launch; the result shows as a toolbar pill. + useEffect(() => { + void useUpdatesStore.getState().check(); + }, []); const settingsPanelOpen = useUIStore((s) => s.settingsPanelOpen); const commandPaletteOpen = useUIStore((s) => s.commandPaletteOpen); const shortcutHelpOpen = useUIStore((s) => s.shortcutHelpOpen); @@ -155,7 +159,6 @@ export function RootLayout() { {commandPaletteOpen && } {shortcutHelpOpen && } - ); diff --git a/src/components/panels/SettingsPanel.tsx b/src/components/panels/SettingsPanel.tsx index 8ecb0f2..05cc73e 100644 --- a/src/components/panels/SettingsPanel.tsx +++ b/src/components/panels/SettingsPanel.tsx @@ -10,6 +10,7 @@ import { X } from "lucide-react"; import { useSettingsStore } from "@/stores/settings"; import { useUIStore } from "@/stores/ui"; import { SEARCH_ENGINES, type SearchEngineId } from "@/lib/url"; +import { useUpdatesStore } from "@/stores/updates"; function SectionHeader({ children }: { children: React.ReactNode }) { return ( @@ -298,7 +299,36 @@ export function SettingsPanel() {
ZYNLEX Browser{version && ` v${version}`}
Open source · Zero telemetry · Zero accounts
+
); } + +function UpdateCheck() { + const { update, phase, lastCheck, check } = useUpdatesStore(); + const status = + phase === "checking" + ? "Checking…" + : update + ? `Version ${update.version} is available — use the Update button in the toolbar.` + : lastCheck === "up-to-date" + ? "You're on the latest version." + : lastCheck === "error" + ? "Couldn't reach GitHub to check for updates." + : ""; + return ( +
+ + {status &&
{status}
} +
+ ); +} diff --git a/src/stores/updates.ts b/src/stores/updates.ts new file mode 100644 index 0000000..7060fb8 --- /dev/null +++ b/src/stores/updates.ts @@ -0,0 +1,70 @@ +/** + * App update state, shared by the toolbar pill and Settings. + * + * The prompt used to be a bottom-right banner in the main window's DOM — which + * is drawn *under* the native tab webview, so with any page open it was + * invisible and nobody ever saw an update. Everything here surfaces in the + * toolbar (never covered) or in Settings (which hides the webview while open). + * + * Checks on launch and when asked from Settings. Nothing is downloaded until + * the user clicks. + */ +import { create } from "zustand"; +import { check as checkForUpdate, type Update } from "@tauri-apps/plugin-updater"; +import { relaunch } from "@tauri-apps/plugin-process"; + +type Phase = "idle" | "checking" | "downloading" | "ready" | "failed"; + +interface UpdatesStore { + update: Update | null; + phase: Phase; + pct: number; + /** Outcome of the last manual check, shown in Settings. */ + lastCheck: "none" | "up-to-date" | "available" | "error"; + check: () => Promise; + install: () => Promise; + restart: () => Promise; +} + +const IS_TAURI = typeof window !== "undefined" && "__TAURI_INTERNALS__" in window; + +export const useUpdatesStore = create()((set, get) => ({ + update: null, + phase: "idle", + pct: 0, + lastCheck: "none", + + check: async () => { + if (!IS_TAURI || get().phase === "checking" || get().phase === "downloading") return; + set({ phase: "checking" }); + try { + const update = await checkForUpdate(); + set({ update, phase: "idle", lastCheck: update ? "available" : "up-to-date" }); + } catch { + // Offline is normal; Settings shows the error, the toolbar stays quiet. + set({ phase: "idle", lastCheck: "error" }); + } + }, + + install: async () => { + const { update } = get(); + if (!update) return; + set({ phase: "downloading", pct: 0 }); + let total = 0; + let got = 0; + try { + await update.downloadAndInstall((e) => { + if (e.event === "Started") total = e.data.contentLength ?? 0; + else if (e.event === "Progress") { + got += e.data.chunkLength; + if (total > 0) set({ pct: Math.round((got / total) * 100) }); + } + }); + set({ phase: "ready" }); + } catch { + set({ phase: "failed" }); + } + }, + + restart: () => relaunch(), +}));