From 958931f5ef48d6ee61a3722015e4f890ab3113b3 Mon Sep 17 00:00:00 2001 From: PJ Date: Thu, 3 Sep 2026 13:24:20 +0530 Subject: [PATCH] announce updates rather than install them when a package manager owns the binary --- src-tauri/src/lib.rs | 13 +++++++++- src/api/updates.ts | 4 ++++ src/keys/updates.test.ts | 51 ++++++++++++++++++++++++++++++++++++++++ src/keys/updates.ts | 12 ++++++++++ 4 files changed, 79 insertions(+), 1 deletion(-) create mode 100644 src/api/updates.ts create mode 100644 src/keys/updates.test.ts diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 9a06688..3812054 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -233,6 +233,16 @@ fn show_main_window(app: &tauri::AppHandle) { } } +/// The package manager that owns this install, when one does. The Nix wrapper sets it to "nix": +/// the binary lives in a read-only store there, so the updater may announce a version but not +/// install it. +#[tauri::command] +fn packaged_by() -> Option { + std::env::var("MARGIN_CALENDAR_PACKAGED_BY") + .ok() + .filter(|manager| !manager.is_empty()) +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { // generate_context! first, so the updater plugin registers only when the merged config @@ -323,7 +333,8 @@ pub fn run() { sync::event_delete, sync::sync_now, sync::sync_status, - sync::sync_flush + sync::sync_flush, + packaged_by ]) .build(context) .expect("error while building Margin Calendar"); diff --git a/src/api/updates.ts b/src/api/updates.ts new file mode 100644 index 0000000..d5c1036 --- /dev/null +++ b/src/api/updates.ts @@ -0,0 +1,4 @@ +import { call } from "../ipc"; + +/** The package manager that owns this install, or null when the app updates itself. */ +export const packagedBy = () => call("packaged_by"); diff --git a/src/keys/updates.test.ts b/src/keys/updates.test.ts new file mode 100644 index 0000000..b35c4af --- /dev/null +++ b/src/keys/updates.test.ts @@ -0,0 +1,51 @@ +// The one path where the updater must stand down: a package manager owns the binary, so a newer +// version is announced and never installed over it. + +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + check: vi.fn(), + relaunch: vi.fn(), + notify: vi.fn(), + call: vi.fn(), +})); + +vi.mock("@tauri-apps/plugin-updater", () => ({ check: mocks.check })); +vi.mock("@tauri-apps/plugin-process", () => ({ relaunch: mocks.relaunch })); +vi.mock("../store/useToast", () => ({ notify: mocks.notify })); +vi.mock("../ipc", () => ({ isDesktop: true, call: mocks.call })); + +import { checkForUpdates } from "./updates"; + +const downloadAndInstall = vi.fn(); + +beforeEach(() => { + vi.clearAllMocks(); + mocks.check.mockResolvedValue({ version: "0.0.5", downloadAndInstall }); +}); + +describe("checkForUpdates", () => { + it("installs and relaunches when the app owns its own binary", async () => { + mocks.call.mockResolvedValue(null); + await checkForUpdates(); + expect(downloadAndInstall).toHaveBeenCalled(); + expect(mocks.relaunch).toHaveBeenCalled(); + }); + + it("points at the package manager instead of installing over it", async () => { + mocks.call.mockResolvedValue("nix"); + await checkForUpdates(); + expect(downloadAndInstall).not.toHaveBeenCalled(); + expect(mocks.relaunch).not.toHaveBeenCalled(); + expect(mocks.notify).toHaveBeenCalledWith( + "0.0.5 is out. Update with: nix profile upgrade margin-calendar", + ); + }); + + it("does not ask who owns the install when there is nothing newer", async () => { + mocks.check.mockResolvedValue(null); + await checkForUpdates(); + expect(mocks.call).not.toHaveBeenCalled(); + expect(mocks.notify).toHaveBeenCalledWith("Margin Calendar is up to date"); + }); +}); diff --git a/src/keys/updates.ts b/src/keys/updates.ts index c528676..8bc2d97 100644 --- a/src/keys/updates.ts +++ b/src/keys/updates.ts @@ -4,11 +4,18 @@ import { relaunch } from "@tauri-apps/plugin-process"; import { check } from "@tauri-apps/plugin-updater"; +import { packagedBy } from "../api/updates"; import { isDesktop } from "../ipc"; import { notify } from "../store/useToast"; let running = false; +/** A package manager owns the binary, so the update is announced and left to it. */ +function updateHint(manager: string, version: string): string { + if (manager === "nix") return `${version} is out. Update with: nix profile upgrade margin-calendar`; + return `${version} is out. Update it through ${manager}`; +} + export async function checkForUpdates(): Promise { if (!isDesktop || running) return; running = true; @@ -18,6 +25,11 @@ export async function checkForUpdates(): Promise { notify("Margin Calendar is up to date"); return; } + const manager = await packagedBy(); + if (manager) { + notify(updateHint(manager, update.version)); + return; + } notify(`Installing ${update.version}…`); await update.downloadAndInstall(); await relaunch();