{
+ if (started || !isTauri()) return;
+ started = true;
+
+ try {
+ const update = await check();
+ if (update?.available) {
+ useUpdateStore.getState().setAvailable(update);
+ }
+ } catch (e) {
+ // Swallow: no endpoint yet, offline, or up to date — none are user-facing.
+ console.warn("update check failed:", e);
+ }
+}
+
+/**
+ * Reset the one-shot guard. Test-only — production code calls
+ * {@link checkForUpdates} exactly once at startup.
+ */
+export function resetUpdateCheck(): void {
+ started = false;
+}
diff --git a/src/features/updates/components/UpdateBanner.test.tsx b/src/features/updates/components/UpdateBanner.test.tsx
new file mode 100644
index 0000000..435f78b
--- /dev/null
+++ b/src/features/updates/components/UpdateBanner.test.tsx
@@ -0,0 +1,66 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { render, screen, fireEvent, waitFor } from "@testing-library/react";
+import type { Update } from "@tauri-apps/plugin-updater";
+import { UpdateBanner } from "./UpdateBanner";
+import { useUpdateStore } from "../store";
+
+const relaunch = vi.fn();
+vi.mock("@tauri-apps/plugin-process", () => ({
+ relaunch: () => relaunch(),
+}));
+
+/** Build a fake Update whose downloadAndInstall is controllable per test. */
+function fakeUpdate(downloadAndInstall = vi.fn().mockResolvedValue(undefined)): Update {
+ return { available: true, version: "0.2.0", downloadAndInstall } as unknown as Update;
+}
+
+beforeEach(() => {
+ relaunch.mockReset().mockResolvedValue(undefined);
+});
+
+describe("UpdateBanner", () => {
+ it("renders nothing while idle", () => {
+ render();
+ expect(screen.queryByTestId("update-banner")).toBeNull();
+ });
+
+ it("shows the available version once an update is recorded", () => {
+ useUpdateStore.getState().setAvailable(fakeUpdate());
+ render();
+ expect(screen.getByTestId("update-banner")).toHaveTextContent("Notey 0.2.0 is available");
+ });
+
+ it("installs then relaunches when the user confirms", async () => {
+ const downloadAndInstall = vi.fn().mockResolvedValue(undefined);
+ useUpdateStore.getState().setAvailable(fakeUpdate(downloadAndInstall));
+ render();
+
+ fireEvent.click(screen.getByTestId("update-install"));
+
+ await waitFor(() => expect(downloadAndInstall).toHaveBeenCalledOnce());
+ await waitFor(() => expect(relaunch).toHaveBeenCalledOnce());
+ });
+
+ it("surfaces an inline error and offers Retry when install fails", async () => {
+ const downloadAndInstall = vi.fn().mockRejectedValue(new Error("network down"));
+ useUpdateStore.getState().setAvailable(fakeUpdate(downloadAndInstall));
+ render();
+
+ fireEvent.click(screen.getByTestId("update-install"));
+
+ await waitFor(() =>
+ expect(screen.getByTestId("update-banner")).toHaveTextContent("Update failed: network down"),
+ );
+ expect(relaunch).not.toHaveBeenCalled();
+ expect(screen.getByTestId("update-install")).toHaveTextContent("Retry");
+ });
+
+ it("dismiss hides the banner", () => {
+ useUpdateStore.getState().setAvailable(fakeUpdate());
+ render();
+
+ fireEvent.click(screen.getByTestId("update-dismiss"));
+
+ expect(screen.queryByTestId("update-banner")).toBeNull();
+ });
+});
diff --git a/src/features/updates/components/UpdateBanner.tsx b/src/features/updates/components/UpdateBanner.tsx
new file mode 100644
index 0000000..03c872d
--- /dev/null
+++ b/src/features/updates/components/UpdateBanner.tsx
@@ -0,0 +1,110 @@
+import { relaunch } from "@tauri-apps/plugin-process";
+import { useUpdateStore } from "../store";
+
+/**
+ * Top-of-window banner offering to install a pending update. Renders nothing
+ * until {@link checkForUpdates} finds an available build, so it is inert in the
+ * common case. Non-modal: the user can dismiss it and keep capturing notes.
+ *
+ * "Install & restart" downloads + applies the signed artifact, then relaunches
+ * onto the new build via `@tauri-apps/plugin-process`. A failure is shown inline
+ * and leaves the app running on the current version.
+ */
+export function UpdateBanner() {
+ const update = useUpdateStore((s) => s.update);
+ const version = useUpdateStore((s) => s.version);
+ const phase = useUpdateStore((s) => s.phase);
+ const error = useUpdateStore((s) => s.error);
+ const setInstalling = useUpdateStore((s) => s.setInstalling);
+ const setError = useUpdateStore((s) => s.setError);
+ const dismiss = useUpdateStore((s) => s.dismiss);
+
+ if (phase === "idle" || !update) return null;
+
+ const installing = phase === "installing";
+
+ async function install() {
+ if (!update) return;
+ setInstalling();
+ try {
+ await update.downloadAndInstall();
+ // Restarts the app onto the freshly installed version; nothing after this
+ // line runs in the current process.
+ await relaunch();
+ } catch (e) {
+ setError(e instanceof Error ? e.message : String(e));
+ }
+ }
+
+ return (
+
+
+ {phase === "error"
+ ? `Update failed: ${error}`
+ : installing
+ ? `Installing Notey ${version}…`
+ : `Notey ${version} is available.`}
+
+
+
+
+ );
+}
diff --git a/src/features/updates/store.test.ts b/src/features/updates/store.test.ts
new file mode 100644
index 0000000..8d017a3
--- /dev/null
+++ b/src/features/updates/store.test.ts
@@ -0,0 +1,45 @@
+import { describe, it, expect } from "vitest";
+import type { Update } from "@tauri-apps/plugin-updater";
+import { useUpdateStore } from "./store";
+
+/** Minimal stand-in for the plugin's Update handle (only fields the store reads). */
+function fakeUpdate(version: string): Update {
+ return { available: true, version } as unknown as Update;
+}
+
+describe("useUpdateStore", () => {
+ it("starts idle with no update", () => {
+ const s = useUpdateStore.getState();
+ expect(s.phase).toBe("idle");
+ expect(s.update).toBeNull();
+ expect(s.version).toBeNull();
+ });
+
+ it("setAvailable records the update and mirrors its version", () => {
+ useUpdateStore.getState().setAvailable(fakeUpdate("0.2.0"));
+ const s = useUpdateStore.getState();
+ expect(s.phase).toBe("available");
+ expect(s.version).toBe("0.2.0");
+ expect(s.update).not.toBeNull();
+ });
+
+ it("setInstalling and setError transition phase, error clears on install", () => {
+ useUpdateStore.getState().setAvailable(fakeUpdate("0.2.0"));
+ useUpdateStore.getState().setError("boom");
+ expect(useUpdateStore.getState().phase).toBe("error");
+ expect(useUpdateStore.getState().error).toBe("boom");
+
+ useUpdateStore.getState().setInstalling();
+ expect(useUpdateStore.getState().phase).toBe("installing");
+ expect(useUpdateStore.getState().error).toBeNull();
+ });
+
+ it("dismiss resets back to idle", () => {
+ useUpdateStore.getState().setAvailable(fakeUpdate("0.2.0"));
+ useUpdateStore.getState().dismiss();
+ const s = useUpdateStore.getState();
+ expect(s.phase).toBe("idle");
+ expect(s.update).toBeNull();
+ expect(s.version).toBeNull();
+ });
+});
diff --git a/src/features/updates/store.ts b/src/features/updates/store.ts
new file mode 100644
index 0000000..d09748d
--- /dev/null
+++ b/src/features/updates/store.ts
@@ -0,0 +1,54 @@
+import { create } from "zustand";
+import type { Update } from "@tauri-apps/plugin-updater";
+
+/**
+ * Phase of the in-app update flow. `idle` covers both "no update" and "the user
+ * dismissed the banner"; the banner only renders for `available`/`installing`/`error`.
+ */
+export type UpdatePhase = "idle" | "available" | "installing" | "error";
+
+interface UpdateState {
+ /** The pending update handle from `check()`, or null when none is available. */
+ update: Update | null;
+ /** Target version string (e.g. "0.2.0") for display, mirrored from `update`. */
+ version: string | null;
+ phase: UpdatePhase;
+ /** Human-readable error shown in the banner when install fails. */
+ error: string | null;
+}
+
+interface UpdateActions {
+ /** Record an available update so the banner offers to install it. */
+ setAvailable: (update: Update) => void;
+ /** Enter the installing phase (download + apply in progress). */
+ setInstalling: () => void;
+ /** Record an install failure with a user-facing message. */
+ setError: (message: string) => void;
+ /** Dismiss the banner / clear update state back to idle. */
+ dismiss: () => void;
+ /** Reset all state to initial values (test cleanup only). */
+ reset: () => void;
+}
+
+const initial: UpdateState = {
+ update: null,
+ version: null,
+ phase: "idle",
+ error: null,
+};
+
+/** Per-feature Zustand store backing the in-app auto-update banner. */
+export const useUpdateStore = create((set) => ({
+ ...initial,
+
+ setAvailable: (update) =>
+ set({ update, version: update.version, phase: "available", error: null }),
+
+ setInstalling: () => set({ phase: "installing", error: null }),
+
+ setError: (message) => set({ phase: "error", error: message }),
+
+ dismiss: () => set({ ...initial }),
+
+ reset: () => set({ ...initial }),
+}));
diff --git a/src/test-utils/setup.ts b/src/test-utils/setup.ts
index 7a62069..18061a1 100644
--- a/src/test-utils/setup.ts
+++ b/src/test-utils/setup.ts
@@ -10,6 +10,7 @@ import { useToastStore } from '../features/toast/store';
import { useTrashStore } from '../features/trash/store';
import { useSettingsStore } from '../features/settings/store';
import { useOnboardingStore } from '../features/onboarding/store';
+import { useUpdateStore } from '../features/updates/store';
import { resetToggleTracking } from '../features/command-palette/actions';
import { resetSingleflight } from '../lib/singleflight';
@@ -45,6 +46,7 @@ afterEach(() => {
useTrashStore.getState().resetTrash();
useSettingsStore.getState().resetSettings();
useOnboardingStore.getState().reset();
+ useUpdateStore.getState().reset();
// Clear shared in-flight (singleflight) dedup keys and the sticky per-session
// theme/layout toggle markers (module-level, not a store)