diff --git a/Cargo.lock b/Cargo.lock index 50347b7..e5d99fe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4611,6 +4611,7 @@ dependencies = [ "anyhow", "chrono", "dirs 5.0.1", + "semver", "serde", "serde_json", "stint-core", diff --git a/crates/stint-app/Cargo.toml b/crates/stint-app/Cargo.toml index 45dc789..0530059 100644 --- a/crates/stint-app/Cargo.toml +++ b/crates/stint-app/Cargo.toml @@ -14,7 +14,7 @@ path = "src/main.rs" [features] default = ["updater"] -updater = ["dep:tauri-plugin-updater"] +updater = ["dep:tauri-plugin-updater", "dep:semver"] [dependencies] stint-core = { path = "../stint-core" } @@ -32,6 +32,7 @@ tauri = { version = "2.1", features = ["macos-private-api", "tray-icon", "image- tauri-plugin-opener = "2.2" tauri-plugin-positioner = { version = "2.3", features = ["tray-icon"] } tauri-plugin-updater = { version = "2", optional = true } +semver = { version = "1.0", optional = true } [build-dependencies] tauri-build = { version = "2.0", features = [] } 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/crates/stint-app/src/updater.rs b/crates/stint-app/src/updater.rs index e356714..003ed03 100644 --- a/crates/stint-app/src/updater.rs +++ b/crates/stint-app/src/updater.rs @@ -12,11 +12,49 @@ pub struct UpdateInfo { pub notes: Option, } +/// Dev-only preview escape hatch. When `STINT_DEV_VERSION_OVERRIDE` is set +/// to a valid semver string lower than the real running version, +/// `check_for_updates` short-circuits and returns synthetic +/// `available: true` data (pretending the override is the running version +/// and the real version is the upgrade target). Lets a maintainer see the +/// update affordances — About badge, popover indicator, Settings panel +/// state machine — without editing tauri.conf.json or shipping a fake +/// release. +/// +/// Returns `None` if unset or unparseable, so production users (who never +/// set the env var) get the real network check every time. +/// +/// `install_update` is NOT mocked — clicking Install while the override is +/// active will fail with "no update available" because the real updater +/// sees current_version == latest. That's the right behavior: don't let +/// the dev preview path execute a real install. +#[cfg(feature = "updater")] +fn dev_version_override() -> Option { + std::env::var("STINT_DEV_VERSION_OVERRIDE") + .ok() + .and_then(|s| s.parse().ok()) +} + #[tauri::command] pub async fn check_for_updates(app: AppHandle, channel: String) -> Result { #[cfg(feature = "updater")] { use tauri_plugin_updater::UpdaterExt; + + if let Some(override_ver) = dev_version_override() { + tracing::warn!( + override_version = %override_ver, + "check_for_updates: dev version override active — returning synthetic UpdateInfo" + ); + let real_version = app.package_info().version.to_string(); + return Ok(UpdateInfo { + available: true, + current_version: override_ver.to_string(), + latest_version: Some(real_version), + notes: Some("see CHANGELOG.md".to_string()), + }); + } + let endpoint = resolve_endpoint(Channel::from_setting(&channel)); let updater = app .updater_builder() 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/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 + +
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() {
+ + +