Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion crates/stint-app/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand All @@ -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 = [] }
Expand Down
16 changes: 16 additions & 0 deletions crates/stint-app/src/menu.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,13 @@ pub fn build(app: &AppHandle) -> tauri::Result<Menu<Wry>> {
&[
&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)?,
Expand Down Expand Up @@ -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", ());
}
_ => {}
}
}
12 changes: 12 additions & 0 deletions crates/stint-app/src/tray.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,13 @@ pub fn build(app: &AppHandle) -> tauri::Result<TrayIcon> {
&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>)?,
],
Expand All @@ -39,6 +46,11 @@ pub fn build(app: &AppHandle) -> tauri::Result<TrayIcon> {
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 {
Expand Down
38 changes: 38 additions & 0 deletions crates/stint-app/src/updater.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,49 @@ pub struct UpdateInfo {
pub notes: Option<String>,
}

/// 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<semver::Version> {
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<UpdateInfo, String> {
#[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()
Expand Down
6 changes: 6 additions & 0 deletions ui/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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) {
Expand Down
14 changes: 14 additions & 0 deletions ui/src/lib/updates.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { createSignal } from "solid-js";
import { invoke } from "@tauri-apps/api/core";

export interface UpdateInfo {
Expand All @@ -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<UpdateInfo> {
const channel = await getChannel();
return invoke<UpdateInfo>("check_for_updates", { channel });
Expand Down
13 changes: 10 additions & 3 deletions ui/src/routes/About.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
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";
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" },
Expand Down Expand Up @@ -35,6 +36,7 @@ export default function About() {
const [tauriVersion] = createResource(() => getTauriVersion(), {
initialValue: "",
});
const updateInfo = useUpdateBanner();

return (
<div class="min-h-screen bg-zinc-50/60 dark:bg-zinc-950">
Expand Down Expand Up @@ -65,8 +67,13 @@ export default function About() {
<dl class="mt-4 grid grid-cols-2 gap-x-6 gap-y-2 text-xs">
<div>
<dt class="text-zinc-400 dark:text-zinc-500">Version</dt>
<dd class="font-mono tabular-nums text-zinc-700 dark:text-zinc-200">
{appVersion()}
<dd class="flex items-center gap-2 font-mono tabular-nums text-zinc-700 dark:text-zinc-200">
<span>{appVersion()}</span>
<Show when={updateInfo()?.available}>
<span class="inline-flex items-center rounded-full bg-emerald-100 px-1.5 py-px font-sans text-[10px] font-medium text-emerald-700 dark:bg-emerald-950 dark:text-emerald-300">
v{updateInfo()!.latest_version} available
</span>
</Show>
</dd>
</div>
<div>
Expand Down
36 changes: 35 additions & 1 deletion ui/src/routes/Popover.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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() {
Expand All @@ -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 (
<div class="h-screen w-screen p-2">
<div class="flex h-full flex-col overflow-hidden rounded-2xl border border-black/[0.06] bg-white text-zinc-900 shadow-[0_18px_40px_-12px_rgb(0_0_0/0.35),0_4px_16px_-4px_rgb(0_0_0/0.18)] dark:border-white/[0.06] dark:bg-zinc-900 dark:text-zinc-50">
Expand Down Expand Up @@ -163,13 +170,40 @@ export default function Popover() {
</Show>
</div>

<Show when={updateInfo()?.available}>
<button
class="border-t border-emerald-200 bg-emerald-50 px-5 py-1.5 text-center text-[11px] text-emerald-700 transition hover:bg-emerald-100 dark:border-emerald-900 dark:bg-emerald-950 dark:text-emerald-300 dark:hover:bg-emerald-900/40"
onClick={openSettings}
>
Update available: v{updateInfo()!.latest_version} → open Settings
</button>
</Show>
<footer class="flex items-center justify-between border-t border-black/[0.05] px-5 py-2.5 text-[11px] dark:border-white/[0.04]">
<button
class="text-zinc-500 transition hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-100"
onClick={openMain}
>
Open Stint →
</button>
<button
class="text-zinc-500 transition hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-100"
onClick={openSettings}
aria-label="Settings"
title="Settings"
>
<svg
class="h-3.5 w-3.5"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fill-rule="evenodd"
d="M8.34 1.804A1 1 0 0 1 9.32 1h1.36a1 1 0 0 1 .98.804l.295 1.473c.497.144.965.347 1.396.6l1.25-.834a1 1 0 0 1 1.262.125l.962.962a1 1 0 0 1 .125 1.262l-.834 1.25c.253.431.456.899.6 1.396l1.473.294a1 1 0 0 1 .804.98v1.361a1 1 0 0 1-.804.98l-1.473.295a6.95 6.95 0 0 1-.6 1.396l.834 1.25a1 1 0 0 1-.125 1.262l-.962.962a1 1 0 0 1-1.262.125l-1.25-.834a6.95 6.95 0 0 1-1.396.6l-.294 1.473a1 1 0 0 1-.98.804H9.32a1 1 0 0 1-.98-.804l-.295-1.473a6.95 6.95 0 0 1-1.396-.6l-1.25.834a1 1 0 0 1-1.262-.125l-.962-.962a1 1 0 0 1-.125-1.262l.834-1.25a6.95 6.95 0 0 1-.6-1.396l-1.473-.294A1 1 0 0 1 1 10.68V9.32a1 1 0 0 1 .804-.98l1.473-.295c.144-.497.347-.965.6-1.396l-.834-1.25a1 1 0 0 1 .125-1.262l.962-.962a1 1 0 0 1 1.262-.125l1.25.834a6.95 6.95 0 0 1 1.396-.6l.294-1.473ZM10 13a3 3 0 1 0 0-6 3 3 0 0 0 0 6Z"
clip-rule="evenodd"
/>
</svg>
</button>
<button
class="text-zinc-500 transition hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-100"
onClick={() => openSolidtime()}
Expand Down
39 changes: 35 additions & 4 deletions ui/src/routes/Settings/UpdatesPanel.tsx
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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();
Comment on lines +87 to +88
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Consume check requests instead of replaying them on every remount

checkRequested is treated as a permanent boolean (> 0) rather than a consumable counter, so after the first menu/tray-triggered request this effect will call check() on every future UpdatesPanel mount even when no new request happened. In practice, opening Settings later will keep issuing unexpected network update checks and UI state flips from stale requests; the panel should track the last handled counter value (or reset/consume it) so only new increments trigger checks.

Useful? React with 👍 / 👎.

}
});

return (
<div class="space-y-5">
<div class="grid grid-cols-3 gap-4">
Expand Down Expand Up @@ -121,9 +137,24 @@ export default function UpdatesPanel() {
</Show>
</p>
<Show when={!installed() && info()!.notes}>
<pre class="mt-1 whitespace-pre-wrap text-xs text-zinc-700 dark:text-zinc-300">
{info()!.notes}
</pre>
<Show
when={info()!.notes === CHANGELOG_PLACEHOLDER}
fallback={
<pre class="mt-1 whitespace-pre-wrap text-xs text-zinc-700 dark:text-zinc-300">
{info()!.notes}
</pre>
}
>
<p class="mt-1 text-xs text-zinc-700 dark:text-zinc-300">
See{" "}
<button
class="text-indigo-600 underline-offset-2 hover:underline dark:text-indigo-400"
onClick={() => openUrl(changelogUrl(info()!.latest_version!))}
>
CHANGELOG.md
</button>
</p>
</Show>
</Show>
<Show when={installed()}>
<p class="mt-1 text-xs text-emerald-800 dark:text-emerald-300">
Expand Down
Loading