From 726091dbea7fb35dcc3fefd9990d69f35060bfdd Mon Sep 17 00:00:00 2001 From: Mario Meyer Date: Fri, 22 May 2026 22:55:31 -0400 Subject: [PATCH 1/4] =?UTF-8?q?feat(menu):=20"Check=20for=20Updates?= =?UTF-8?q?=E2=80=A6"=20in=20menus=20+=20CHANGELOG=20link?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the macOS-standard "Check for Updates…" item to both the Stint app menu (between About and Settings) and the tray right-click menu. Both entries open the main window, navigate to /settings, and fire a \`check-for-updates\` Tauri event. A module-level signal in ui/src/lib/updates.ts (\`checkRequested\`) bridges the event to UpdatesPanel: App.tsx's listener calls \`requestCheckForUpdates()\` on receipt, which increments a counter that UpdatesPanel watches via createEffect. Using a module-level signal rather than a per-component one means the request lands even if the panel mounts after the event arrives — createEffect runs on first mount with the current counter value. Also: when the manifest's notes field is the placeholder string "see CHANGELOG.md", render it as an actual link to the GitHub CHANGELOG.md at the right release tag rather than dumping plain text. Custom notes (anything other than the placeholder) still render as a \`
\` block, leaving room for richer notes later.
---
 crates/stint-app/src/menu.rs            | 16 ++++++++++
 crates/stint-app/src/tray.rs            | 12 ++++++++
 ui/src/App.tsx                          |  6 ++++
 ui/src/lib/updates.ts                   | 14 +++++++++
 ui/src/routes/Settings/UpdatesPanel.tsx | 39 ++++++++++++++++++++++---
 5 files changed, 83 insertions(+), 4 deletions(-)

diff --git a/crates/stint-app/src/menu.rs b/crates/stint-app/src/menu.rs
index 7c15117..c49c197 100644
--- a/crates/stint-app/src/menu.rs
+++ b/crates/stint-app/src/menu.rs
@@ -18,6 +18,13 @@ pub fn build(app: &AppHandle) -> tauri::Result> {
         &[
             &MenuItem::with_id(app, "menu-about", "About Stint", true, None::<&str>)?,
             &PredefinedMenuItem::separator(app)?,
+            &MenuItem::with_id(
+                app,
+                "menu-check-updates",
+                "Check for Updates…",
+                true,
+                None::<&str>,
+            )?,
             &MenuItem::with_id(app, "menu-settings", "Settings…", true, Some("CmdOrCtrl+,"))?,
             &PredefinedMenuItem::separator(app)?,
             &PredefinedMenuItem::services(app, None)?,
@@ -70,6 +77,15 @@ pub fn handle(app: &AppHandle, id: &str) {
             let _ = windows::show_main(app);
             let _ = app.emit("navigate", "/settings");
         }
+        "menu-check-updates" => {
+            let _ = windows::show_main(app);
+            let _ = app.emit("navigate", "/settings");
+            // Second event the UpdatesPanel listens for. App.tsx's navigate
+            // listener fires synchronously, so by the time this lands the
+            // panel is on its way to mounting — UpdatesPanel registers a
+            // global signal handler that survives mount/unmount cycles.
+            let _ = app.emit("check-for-updates", ());
+        }
         _ => {}
     }
 }
diff --git a/crates/stint-app/src/tray.rs b/crates/stint-app/src/tray.rs
index 8aa96d1..cdcf33d 100644
--- a/crates/stint-app/src/tray.rs
+++ b/crates/stint-app/src/tray.rs
@@ -21,6 +21,13 @@ pub fn build(app: &AppHandle) -> tauri::Result {
             &MenuItem::with_id(app, "sync", "Sync now", true, None::<&str>)?,
             &PredefinedMenuItem::separator(app)?,
             &MenuItem::with_id(app, "about", "About Stint", true, None::<&str>)?,
+            &MenuItem::with_id(
+                app,
+                "check-updates",
+                "Check for Updates…",
+                true,
+                None::<&str>,
+            )?,
             &PredefinedMenuItem::separator(app)?,
             &MenuItem::with_id(app, "quit", "Quit", true, None::<&str>)?,
         ],
@@ -39,6 +46,11 @@ pub fn build(app: &AppHandle) -> tauri::Result {
                 let _ = windows::show_main(app);
                 let _ = app.emit("navigate", "/about");
             }
+            "check-updates" => {
+                let _ = windows::show_main(app);
+                let _ = app.emit("navigate", "/settings");
+                let _ = app.emit("check-for-updates", ());
+            }
             "sync" => {
                 let app_handle = app.clone();
                 tokio::spawn(async move {
diff --git a/ui/src/App.tsx b/ui/src/App.tsx
index 147f133..3af9567 100644
--- a/ui/src/App.tsx
+++ b/ui/src/App.tsx
@@ -4,6 +4,7 @@ import { listen } from "@tauri-apps/api/event";
 import { getCurrentWindow } from "@tauri-apps/api/window";
 import { useHotkey } from "./lib/useHotkey";
 import { useUpdateBanner } from "./lib/updateBanner";
+import { requestCheckForUpdates } from "./lib/updates";
 import About from "./routes/About";
 import Popover from "./routes/Popover";
 import Settings from "./routes/Settings";
@@ -21,6 +22,11 @@ if (isPopover) {
       window.location.hash = e.payload;
     }
   }).catch(() => {});
+
+  // Menu/tray "Check for Updates…" fires this in addition to the navigate
+  // event. Bridges to a module-level signal so the request lands even if
+  // UpdatesPanel hasn't mounted yet by the time the event arrives.
+  listen("check-for-updates", () => requestCheckForUpdates()).catch(() => {});
 }
 
 function navigate(path: string) {
diff --git a/ui/src/lib/updates.ts b/ui/src/lib/updates.ts
index ff625cd..0679ff3 100644
--- a/ui/src/lib/updates.ts
+++ b/ui/src/lib/updates.ts
@@ -1,3 +1,4 @@
+import { createSignal } from "solid-js";
 import { invoke } from "@tauri-apps/api/core";
 
 export interface UpdateInfo {
@@ -9,6 +10,19 @@ export interface UpdateInfo {
 
 export type Channel = "stable" | "beta";
 
+/**
+ * Monotonic counter incremented whenever something asks for an explicit
+ * update check (menu item, tray item, etc.). UpdatesPanel reacts to the
+ * counter so the check fires even if the panel mounts AFTER the request
+ * lands. Module-level so the signal survives panel mount/unmount cycles.
+ */
+const [checkRequested, setCheckRequested] = createSignal(0);
+export { checkRequested };
+
+export function requestCheckForUpdates(): void {
+  setCheckRequested((n) => n + 1);
+}
+
 export async function checkForUpdates(): Promise {
   const channel = await getChannel();
   return invoke("check_for_updates", { channel });
diff --git a/ui/src/routes/Settings/UpdatesPanel.tsx b/ui/src/routes/Settings/UpdatesPanel.tsx
index 1435834..651aad2 100644
--- a/ui/src/routes/Settings/UpdatesPanel.tsx
+++ b/ui/src/routes/Settings/UpdatesPanel.tsx
@@ -1,15 +1,21 @@
-import { Show, createResource, createSignal } from "solid-js";
+import { Show, createEffect, createResource, createSignal } from "solid-js";
+import { openUrl } from "@tauri-apps/plugin-opener";
 import Button from "~/components/ui/Button";
 import {
   type Channel,
   type UpdateInfo,
   checkForUpdates,
+  checkRequested,
   getChannel,
   installUpdate,
   restartApp,
   setChannel,
 } from "~/lib/updates";
 
+const CHANGELOG_PLACEHOLDER = "see CHANGELOG.md";
+const changelogUrl = (version: string) =>
+  `https://github.com/reyemtech/stint/blob/v${version}/CHANGELOG.md`;
+
 /**
  * Settings panel for the auto-updater. Lets the user pick an update channel,
  * trigger a check, and install when one is available. Mirrors the styling of
@@ -73,6 +79,16 @@ export default function UpdatesPanel() {
     }
   };
 
+  // Auto-fire a check whenever someone (menu, tray, anywhere) calls
+  // requestCheckForUpdates(). Module-level signal so the request lands
+  // even if the panel mounted after the request was made — createEffect
+  // runs once on first mount with the current counter value.
+  createEffect(() => {
+    if (checkRequested() > 0) {
+      void check();
+    }
+  });
+
   return (
     
@@ -121,9 +137,24 @@ export default function UpdatesPanel() {

-
-              {info()!.notes}
-            
+ + {info()!.notes} +
+ } + > +

+ See{" "} + +

+

From 82f99296a67ccddc534c39939a9b8e04242bfbb8 Mon Sep 17 00:00:00 2001 From: Mario Meyer Date: Fri, 22 May 2026 22:55:56 -0400 Subject: [PATCH 2/4] feat(about): show update-available badge next to version MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a small emerald pill ("v0.2.0 available") next to the version field in the About page identity card. Passive — clicking it doesn't do anything; it's strictly informational. Consumes the existing \`useUpdateBanner()\` signal so it shares the 24h auto-poll cadence rather than introducing a new check. The About page is low-frequency, so making the badge clickable would feel off — it's a "what is this app" surface, not an action surface. Users who want to act on the update have the top-of-window banner, the menu/tray "Check for Updates…" entries, and the popover indicator. --- ui/src/routes/About.tsx | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/ui/src/routes/About.tsx b/ui/src/routes/About.tsx index bf77318..d8f7b9a 100644 --- a/ui/src/routes/About.tsx +++ b/ui/src/routes/About.tsx @@ -1,4 +1,4 @@ -import { createResource, For } from "solid-js"; +import { createResource, For, Show } from "solid-js"; import { getVersion, getTauriVersion } from "@tauri-apps/api/app"; import { openUrl } from "@tauri-apps/plugin-opener"; import MainNav from "~/components/MainNav"; @@ -6,6 +6,7 @@ import StintIcon from "~/components/StintIcon"; import Button from "~/components/ui/Button"; import SectionLabel from "~/components/ui/SectionLabel"; import { openSolidtime } from "~/lib/openSolidtime"; +import { useUpdateBanner } from "~/lib/updateBanner"; const CREDITS = [ { name: "Tauri", purpose: "macOS shell + IPC", url: "https://tauri.app" }, @@ -35,6 +36,7 @@ export default function About() { const [tauriVersion] = createResource(() => getTauriVersion(), { initialValue: "", }); + const updateInfo = useUpdateBanner(); return (

@@ -65,8 +67,13 @@ export default function About() {
Version
-
- {appVersion()} +
+ {appVersion()} + + + v{updateInfo()!.latest_version} available + +
From 59e15b7e734786daae1b45be7cabe1f8648eede1 Mon Sep 17 00:00:00 2001 From: Mario Meyer Date: Fri, 22 May 2026 22:56:06 -0400 Subject: [PATCH 3/4] feat(popover): Settings cogwheel + update-available indicator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two additions to the popover, both targeting the same gap: popover users rarely open the main window, so neither the top-of-window update banner nor the existing "Open Stint →" affordance reaches them effectively. 1. Cogwheel button in the footer (between "Open Stint →" and "Solidtime ↗") that opens the main window and emits a navigate event to /settings. Same effect as menu/tray "Settings…" entries from inside the popover. 2. Conditional "Update available: vX.Y.Z → open Settings" link as a one-line row above the footer. Only renders when \`useUpdateBanner()\` reports an available update. Click → same path as the cogwheel: main window + /settings. The popover and main window are separate webview contexts in Tauri, so each runs its own \`useUpdateBanner\` polling instance. Bandwidth impact is trivial (one GitHub fetch per 24h per window) and the duplication keeps the code simple — no cross-webview signal sharing. --- ui/src/routes/Popover.tsx | 36 +++++++++++++++++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/ui/src/routes/Popover.tsx b/ui/src/routes/Popover.tsx index 9f59e0d..16cce74 100644 --- a/ui/src/routes/Popover.tsx +++ b/ui/src/routes/Popover.tsx @@ -1,6 +1,6 @@ import { For, Show, createResource, createSignal, onCleanup } from "solid-js"; import { invoke } from "@tauri-apps/api/core"; -import { listen } from "@tauri-apps/api/event"; +import { emit, listen } from "@tauri-apps/api/event"; import { api } from "~/api"; import Duration from "~/components/Duration"; import StartAtPicker, { type StartAtValue } from "~/components/StartAtPicker"; @@ -11,6 +11,7 @@ import StatusDot from "~/components/ui/StatusDot"; import Toggle from "~/components/ui/Toggle"; import { sumCompletedEntrySeconds } from "~/lib/entryFormat"; import { openSolidtime } from "~/lib/openSolidtime"; +import { useUpdateBanner } from "~/lib/updateBanner"; import { useTimerStore } from "~/stores/timer"; export default function Popover() { @@ -33,11 +34,17 @@ export default function Popover() { const totalSeconds = () => timer.elapsedSecs() + sumCompletedEntrySeconds(entries() ?? []); + const updateInfo = useUpdateBanner(); async function openMain() { await invoke("show_main_window"); } + async function openSettings() { + await invoke("show_main_window"); + await emit("navigate", "/settings"); + } + return (
@@ -163,6 +170,14 @@ export default function Popover() {
+ + +