From 7dc84ed09cb9781662a8e21d082d52abbaa9b804 Mon Sep 17 00:00:00 2001 From: Bhada Yun Date: Mon, 18 May 2026 16:18:16 +0200 Subject: [PATCH 1/2] feat: "newer version available" notice on pcr runs + start CHANGELOG.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a best-effort background update check that prints a soft "X is available — run: brew upgrade pcr" notice at the end of every interactive `pcr` command when a newer `pcr-dev` ships on npm. Modeled on the `update-notifier` npm package and `cargo`'s own behaviour: - Background thread fires off a 3-second-timeout GET against https://registry.npmjs.org/pcr-dev/latest at the *start* of the command, runs concurrently with the command itself, and writes the result to `~/.pcr-dev/update-check.json` regardless of whether the foreground command has exited. The thread is intentionally not joined — network failures, captive portals, slow DNS never delay the user's primary signal. - At the *end* of the command, the cached file is read and a one-line notice is printed to stderr if (a) the cached version is greater than `CARGO_PKG_VERSION` under naive `major.minor.patch` semver, and (b) we haven't shown the notice in the last hour (so back-to- back `pcr log; pcr show` doesn't double-print). - Suggested upgrade command is install-method-aware: inspects `current_exe()` for `/Cellar/`, `/opt/homebrew/`, or `/node_modules/` and prints `brew upgrade pcr`, `npm i -g pcr-dev@latest`, or a generic `https://pcr.dev/install` link respectively. - Hard skips: `--json` output, the hidden `hook` + `mcp` subcommands (they're stdio JSON-RPC / Stop-hook channels), `CI=*` env, and `PCR_NO_UPDATE_CHECK=1` for users who want to opt out. 6 new unit tests cover semver comparison (including prerelease suffixes), forward-compatible cache deserialisation (older payloads without `last_notice_unix` decode cleanly), and the quiet-subcommand skip list. Workspace tests: 134 passed (was 128 on this main baseline). Also adds CHANGELOG.md (none previously existed in this repo) seeded with an Unreleased section that catalogues this feature plus the in-flight fixes from PRs #85 (vscode dual-watch dedup) and #86 (watcher correctness + perf) so they have a single grep-able home when those PRs merge. Independent of PR #85 / #86 — touches `lib.rs`, `entry.rs`, and a new file. Safe to merge in any order. Made with [Cursor](https://cursor.com) Co-authored-by: Cursor --- CHANGELOG.md | 80 +++++++ README.md | 18 ++ crates/pcr-core/src/entry.rs | 42 ++++ crates/pcr-core/src/lib.rs | 1 + crates/pcr-core/src/update_check.rs | 333 ++++++++++++++++++++++++++++ 5 files changed, 474 insertions(+) create mode 100644 CHANGELOG.md create mode 100644 crates/pcr-core/src/update_check.rs diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..2cd64ce --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,80 @@ +# Changelog + +All notable changes to the `pcr-dev` CLI are documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) +and the project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +Each release line corresponds 1:1 to a git tag of the form `vX.Y.Z` and to +the `pcr-dev@X.Y.Z` npm dist-tag. + +## [Unreleased] + +### Added + +- **Update-available notice on `pcr` runs.** Every interactive command + now does a best-effort background check against the npm registry's + `pcr-dev@latest` and prints a soft "X is available" notice at the end + of the command if a newer version exists. The check is throttled + (registry hit at most once per 24h, notice shown at most once per 1h), + cached in `~/.pcr-dev/update-check.json`, and never blocks the + command. Suggested upgrade command is install-method-aware: + `brew upgrade pcr` for Homebrew installs, `npm i -g pcr-dev@latest` + for npm installs. Opt out with `PCR_NO_UPDATE_CHECK=1` (also skipped + automatically when `CI` is set, when `--json` is used, and for the + internal `hook` and `mcp` subcommands). + +### Fixed + +The following items are in-flight as separate PRs and will be folded +into the next release when those land: + +- **#85 — VS Code Copilot Chat dual-watch dedup.** When both + `chatSessions/` and the legacy `transcripts/` directory exist on + disk (the upgrade window for VS Code 0.45+), the watcher now scopes + itself to `chatSessions/` only, so the same prompt is no longer + ingested twice. Also normalises `captured_at` formatting in the V2 + content hash so timestamp-only differences ("Z" vs "+00:00", millis + vs micros) collapse to the same `prompt_id_v2`. Ships with a + one-shot DB cleanup migration in the `functions` repo + (`20260518000000_dedupe_vscode_prompts.sql`). +- **#86 — Watcher correctness + perf.** Seven fixes from the recent + audit: + - `pcr start` Ctrl-C now triggers cooperative shutdown across every + long-running scan loop (cursor watcher, session-state watcher, + diff tracker) — was previously SIGINT-with-leaked-PID-file. + - `update_draft_response` is scoped to the single most recent row + matching `(session_id, prompt_text)`, fixing the silent overwrite + when the same prompt text re-appears in one session ("go", + "continue", "yes"). + - Cursor watcher walks are skipped entirely when neither the + top-level dir mtime nor the notify-pending flag indicate new + activity, with per-file mtime cache to skip already-parsed files + on the cold path. Periodic interval bumped 20 s → 60 s. + - `gc::orphaned` batches its existence checks into a single + `git cat-file --batch-check` per repo, replacing O(N) one-shot + subprocesses. + - `pcr_dir()` now returns `Result` and surfaces a clear + "set HOME and re-run" error instead of silently writing auth + + SQLite to `/tmp` when no home directory can be resolved. + - Claude Code state cursor advances only after a successful parse, + so a transient JSONL truncation no longer drops unprocessed + lines. + - Byte-slicing on user-supplied IDs replaced with `chars().take(N)` + in `commands/log.rs::short_sha` and the session-state-watcher + short-id log line — matches the `truncate_diff` fix from #85. + +## [0.2.9] — 2026-05-17 + +Last shipped release before this changelog file existed. See the +[v0.2.9 release notes][v029] on GitHub for the full diff. Highlights: + +- TUI palette tokens + log goldens + parser edge tests + watcher cleanup + (#83). +- TUI command-browser polish (#84). + +Earlier releases pre-date this file. The full history is in the git log; +the most relevant ancestors are `v0.2.8`, `v0.2.7`, and `v0.2.0` (the +Rust port). + +[v029]: https://github.com/pcr-developers/cli/releases/tag/v0.2.9 diff --git a/README.md b/README.md index 63b4fb2..2e250ef 100644 --- a/README.md +++ b/README.md @@ -140,6 +140,24 @@ pcr --json status pcr --plain bundle "agent run" --select all && pcr --plain push # plain mode keeps --select for scripts ``` +### Update notifier + +Every interactive `pcr` command checks the npm registry (at most once +per 24 h, in a background thread that never blocks the command) for a +newer `pcr-dev` release and prints a one-line "X is available" notice +to stderr at the end of the command. The check is cached in +`~/.pcr-dev/update-check.json` and the notice is throttled to once per +hour, so back-to-back commands stay quiet. Suggested upgrade command +is install-method-aware: `brew upgrade pcr` for Homebrew, `npm i -g +pcr-dev@latest` for npm. + +```bash +PCR_NO_UPDATE_CHECK=1 pcr status # opt out for one command (or `export` it) +``` + +The notice is automatically skipped when `CI` is set, when `--json` is +used, and for the internal `hook` and `mcp` subcommands. + ## Privacy - Everything lives in `~/.pcr-dev/` until you explicitly run `pcr push`. diff --git a/crates/pcr-core/src/entry.rs b/crates/pcr-core/src/entry.rs index cca7b7f..187600f 100644 --- a/crates/pcr-core/src/entry.rs +++ b/crates/pcr-core/src/entry.rs @@ -234,6 +234,17 @@ pub fn run(argv: Vec) -> i32 { } }; let mode = cli.global.output_mode(); + + // Best-effort update-available notice. The background refresh runs + // for the whole duration of the command; the notice is printed at + // the end (after command output, before we return). Both calls are + // gated on subcommand kind + `--json` so machine output and the + // internal `hook` / `mcp` channels stay silent. See + // `update_check.rs` for the full rationale. + let subcommand_name = subcommand_name(&cli.command); + let json_output = cli.global.json; + crate::update_check::spawn_background_refresh(subcommand_name, json_output); + let code: ExitCode = match cli.command { // No subcommand → open the interactive command browser. On a non- // TTY / `--plain` / `--json` / `CI` / `NO_COLOR`, the help command @@ -255,9 +266,40 @@ pub fn run(argv: Vec) -> i32 { Some(Command::Help) => crate::commands::help::run(mode), Some(Command::Hook) => crate::commands::hook::run(mode), }; + + // Notice runs after the command's own output so it never confuses + // the actual command result, and never delays the user's primary + // signal. If the background refresh hasn't completed yet, this is + // a no-op — the cache from a previous run is consulted on every + // invocation, so users see the notice on the *next* command after + // the first successful refresh. + crate::update_check::print_notice_if_due(subcommand_name, json_output); + code.as_i32() } +/// Maps the parsed `Command` variant back to the kebab-case clap name. +/// Used by `update_check::*` to decide whether to suppress the notice +/// for internal-only subcommands (`hook`, `mcp`). +fn subcommand_name(cmd: &Option) -> Option<&'static str> { + Some(match cmd.as_ref()? { + Command::Login => "login", + Command::Logout => "logout", + Command::Init(_) => "init", + Command::Start(_) => "start", + Command::Mcp => "mcp", + Command::Status => "status", + Command::Bundle(_) => "bundle", + Command::Push => "push", + Command::Log => "log", + Command::Show(_) => "show", + Command::Pull(_) => "pull", + Command::Gc(_) => "gc", + Command::Help => "help", + Command::Hook => "hook", + }) +} + // Re-export a render helper for the command implementations that want to // emit the long-form help in plain mode. pub use help::render_plain as render_command_help; diff --git a/crates/pcr-core/src/lib.rs b/crates/pcr-core/src/lib.rs index 1a9bf42..fa76ffd 100644 --- a/crates/pcr-core/src/lib.rs +++ b/crates/pcr-core/src/lib.rs @@ -40,6 +40,7 @@ pub mod sources; pub mod store; pub mod supabase; pub mod tui; +pub mod update_check; pub mod util; pub mod versions; diff --git a/crates/pcr-core/src/update_check.rs b/crates/pcr-core/src/update_check.rs new file mode 100644 index 0000000..4c4e3fb --- /dev/null +++ b/crates/pcr-core/src/update_check.rs @@ -0,0 +1,333 @@ +//! Best-effort "newer version available" notice on `pcr` runs. +//! +//! Modeled on the npm ecosystem's `update-notifier` package and +//! `cargo`'s own behaviour: at the start of an interactive command we +//! kick off a background thread that fetches `https://registry.npmjs.org/pcr-dev/latest`, +//! caches the result, and at the end of the command we print a soft +//! "X is available" notice to stderr if a newer version is published. +//! +//! Design constraints: +//! +//! * **Never block the command.** Network failures, slow DNS, and +//! captive portals can stall the registry request for seconds. The +//! check runs on a detached thread; the foreground command never +//! `join()`s it. +//! * **No noise on failure.** Any error (network, parse, IO) just +//! silently leaves the cache untouched. The next run tries again. +//! * **No spam.** Two layers of throttling: +//! 1. The registry is hit at most once per `CACHE_TTL`. Between +//! ticks we read the cached version. +//! 2. The notice is printed at most once per `NOTICE_INTERVAL`, +//! so back-to-back `pcr log; pcr show` doesn't double-print. +//! * **Respect machine output.** Skipped entirely for `--json`, +//! the hidden `hook` subcommand, when `CI` is set, and when +//! `PCR_NO_UPDATE_CHECK=1`. +//! * **Install-method-aware suggestion.** Detects Homebrew vs npm by +//! inspecting `current_exe()`, and prints the right upgrade +//! command instead of a generic "go install it" link. +//! +//! Storage: `$PCR_DIR/update-check.json` (i.e. `~/.pcr-dev/update-check.json`). +//! Format is forward-compatible: unknown fields are ignored, missing +//! fields fall back to defaults. + +use std::path::PathBuf; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use serde::{Deserialize, Serialize}; + +const CACHE_TTL: Duration = Duration::from_secs(24 * 60 * 60); +const NOTICE_INTERVAL: Duration = Duration::from_secs(60 * 60); +const REQUEST_TIMEOUT: Duration = Duration::from_secs(3); +const NPM_LATEST_URL: &str = "https://registry.npmjs.org/pcr-dev/latest"; + +#[derive(Debug, Default, Serialize, Deserialize)] +struct CachedCheck { + /// Unix seconds — last time we hit the registry. + #[serde(default)] + last_check_unix: u64, + /// Unix seconds — last time we showed the user the notice. Used + /// to throttle back-to-back commands. + #[serde(default)] + last_notice_unix: u64, + /// Latest version string seen from the registry, e.g. `"0.3.0"`. + #[serde(default)] + latest_version: String, +} + +#[derive(Debug, Deserialize)] +struct NpmDistTag { + version: String, +} + +fn cache_path() -> PathBuf { + // `pcr_dir()` currently returns `PathBuf` infallibly (with a temp + // fallback). PR #86 changes it to `Result` — when that + // lands, swap this for `pcr_dir().ok()?.join(...)` and downgrade + // the helper signature to `Option`. Until then we keep + // the call shape simple. + crate::config::pcr_dir().join("update-check.json") +} + +fn load_cache() -> CachedCheck { + let path = cache_path(); + let Ok(bytes) = std::fs::read(&path) else { + return CachedCheck::default(); + }; + serde_json::from_slice(&bytes).unwrap_or_default() +} + +fn save_cache(cache: &CachedCheck) { + let path = cache_path(); + // Make sure the parent dir exists. We don't `?` this because the + // entire module is best-effort. + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + if let Ok(bytes) = serde_json::to_vec(cache) { + let _ = std::fs::write(&path, bytes); + } +} + +fn now_unix() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +/// Returns true if `latest > current` under naive semver (`major.minor.patch`). +/// Pre-release suffixes are ignored — a `0.3.0-beta.1` versus `0.2.9` will +/// correctly resolve to "newer", but `0.3.0` vs `0.3.0-beta.1` resolves +/// equal (so we never tell a beta user to "upgrade" to the stable that +/// already shipped earlier). +fn semver_greater(latest: &str, current: &str) -> bool { + fn parse(v: &str) -> Option<(u64, u64, u64)> { + let core = v.split(|c: char| c == '-' || c == '+').next()?; + let mut it = core.split('.'); + let a = it.next()?.parse().ok()?; + let b = it.next()?.parse().ok()?; + let c = it.next()?.parse().ok()?; + Some((a, b, c)) + } + match (parse(latest), parse(current)) { + (Some(l), Some(c)) => l > c, + _ => false, + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum InstallMethod { + Homebrew, + Npm, + Unknown, +} + +fn detect_install_method() -> InstallMethod { + let Some(exe) = std::env::current_exe() + .ok() + .and_then(|p| p.to_str().map(|s| s.to_lowercase())) + else { + return InstallMethod::Unknown; + }; + if exe.contains("/cellar/") + || exe.contains("/opt/homebrew/") + || exe.contains("/usr/local/cellar/") + || exe.contains("homebrew") + { + return InstallMethod::Homebrew; + } + if exe.contains("/node_modules/") || exe.contains("/pcr-dev/bin/") { + return InstallMethod::Npm; + } + InstallMethod::Unknown +} + +fn upgrade_hint(method: InstallMethod) -> &'static str { + match method { + InstallMethod::Homebrew => "brew upgrade pcr", + InstallMethod::Npm => "npm i -g pcr-dev@latest", + InstallMethod::Unknown => "see https://pcr.dev/install", + } +} + +/// Should we skip the check entirely for this process? Honours +/// `PCR_NO_UPDATE_CHECK`, `CI`, and the `pcr_no_update_check` +/// dotenv-style key that some sandbox setups prefer. +fn should_skip_env() -> bool { + if std::env::var_os("PCR_NO_UPDATE_CHECK").is_some() { + return true; + } + if std::env::var_os("CI").is_some() { + return true; + } + false +} + +/// True for commands where a stderr notice would be noise rather than +/// signal. `hook` runs once per Claude Code Stop and the user never +/// sees its stderr; `mcp` is a stdio JSON-RPC channel. +fn is_quiet_subcommand(name: Option<&str>) -> bool { + matches!(name, Some("hook") | Some("mcp")) +} + +/// Fire-and-forget refresh. Spawns a detached thread that: +/// 1. If the cached check is fresher than [`CACHE_TTL`], does nothing. +/// 2. Otherwise hits the npm registry with a short timeout, parses +/// the `latest` dist-tag, and rewrites the cache file. +/// +/// The thread is intentionally **not** joined. If the user's command +/// exits before the network call returns, the thread dies with the +/// process — net loss is a single missed cache update, and the next +/// run will retry. +/// +/// Callers should invoke this near the top of `entry::run` so the +/// thread has the whole command duration to complete in the +/// background. +pub fn spawn_background_refresh(subcommand: Option<&str>, json_output: bool) { + if should_skip_env() || json_output || is_quiet_subcommand(subcommand) { + return; + } + let now = now_unix(); + let cache = load_cache(); + if now.saturating_sub(cache.last_check_unix) < CACHE_TTL.as_secs() + && !cache.latest_version.is_empty() + { + // Cache is fresh — nothing to do. + return; + } + + std::thread::Builder::new() + .name("pcr-update-check".into()) + .spawn(move || { + let client = match reqwest::blocking::Client::builder() + .timeout(REQUEST_TIMEOUT) + .user_agent(concat!("pcr-update-check/", env!("CARGO_PKG_VERSION"))) + .build() + { + Ok(c) => c, + Err(_) => return, + }; + let Ok(resp) = client.get(NPM_LATEST_URL).send() else { + return; + }; + if !resp.status().is_success() { + return; + } + let Ok(body) = resp.json::() else { + return; + }; + let mut next = load_cache(); + next.last_check_unix = now_unix(); + next.latest_version = body.version; + save_cache(&next); + }) + // If we can't even spawn the thread (extremely rare — + // resource exhaustion), drop silently. + .ok(); +} + +/// Print the "newer version available" notice if appropriate. Reads +/// the cache that was populated by [`spawn_background_refresh`] — +/// either this run's, or any previous run's. Throttled to once per +/// [`NOTICE_INTERVAL`] to avoid double-printing on back-to-back +/// commands. +/// +/// Callers should invoke this near the bottom of `entry::run`, just +/// before returning the exit code, so the notice sits *after* command +/// output rather than in front of it. +pub fn print_notice_if_due(subcommand: Option<&str>, json_output: bool) { + if should_skip_env() || json_output || is_quiet_subcommand(subcommand) { + return; + } + let mut cache = load_cache(); + let now = now_unix(); + if cache.latest_version.is_empty() { + return; + } + if !semver_greater(&cache.latest_version, env!("CARGO_PKG_VERSION")) { + return; + } + if now.saturating_sub(cache.last_notice_unix) < NOTICE_INTERVAL.as_secs() { + return; + } + let method = detect_install_method(); + let upgrade = upgrade_hint(method); + let current = env!("CARGO_PKG_VERSION"); + let latest = &cache.latest_version; + // Plain text, no ANSI. Display is already colour-aware for the + // command's own output; we don't want to fight `NO_COLOR` here. + eprintln!(); + eprintln!(" ┌─ pcr {latest} is available (you have {current})"); + eprintln!(" └─ run: {upgrade}"); + eprintln!(); + cache.last_notice_unix = now; + save_cache(&cache); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn semver_compare_basic() { + assert!(semver_greater("0.3.0", "0.2.9")); + assert!(semver_greater("1.0.0", "0.99.99")); + assert!(semver_greater("0.2.10", "0.2.9")); + assert!(!semver_greater("0.2.9", "0.2.9")); + assert!(!semver_greater("0.2.8", "0.2.9")); + } + + #[test] + fn semver_compare_handles_prerelease_suffix() { + // Prereleases on either side strip down to their core triple; + // we never *promote* a user to a prerelease version, but we + // also don't accidentally demote-then-prompt. + assert!(semver_greater("0.3.0-beta.1", "0.2.9")); + assert!(!semver_greater("0.2.9-beta.1", "0.2.9")); + assert!(!semver_greater("0.2.9", "0.2.9-beta.1")); + } + + #[test] + fn semver_compare_rejects_malformed() { + assert!(!semver_greater("garbage", "0.2.9")); + assert!(!semver_greater("0.2.9", "garbage")); + assert!(!semver_greater("", "")); + assert!(!semver_greater("0.3", "0.2.9")); + } + + #[test] + fn cache_roundtrip_through_serde() { + let c = CachedCheck { + last_check_unix: 12345, + last_notice_unix: 23456, + latest_version: "0.99.0".into(), + }; + let bytes = serde_json::to_vec(&c).unwrap(); + let back: CachedCheck = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(back.last_check_unix, 12345); + assert_eq!(back.last_notice_unix, 23456); + assert_eq!(back.latest_version, "0.99.0"); + } + + #[test] + fn cache_decodes_partial_legacy_payload() { + // Forward-compat: an older cache file might not have the + // `last_notice_unix` field. Deserialisation must default it + // rather than erroring, otherwise a CLI upgrade silently + // disables the notice forever. + let payload = br#"{"last_check_unix":1,"latest_version":"9.9.9"}"#; + let back: CachedCheck = serde_json::from_slice(payload).unwrap(); + assert_eq!(back.last_check_unix, 1); + assert_eq!(back.last_notice_unix, 0); + assert_eq!(back.latest_version, "9.9.9"); + } + + #[test] + fn quiet_subcommands_skip() { + assert!(is_quiet_subcommand(Some("hook"))); + assert!(is_quiet_subcommand(Some("mcp"))); + assert!(!is_quiet_subcommand(Some("status"))); + assert!(!is_quiet_subcommand(Some("log"))); + assert!(!is_quiet_subcommand(None)); + } +} From ce75c18197912d2aefecf94064ebc4cd3741b671 Mon Sep 17 00:00:00 2001 From: Bhada Yun Date: Mon, 18 May 2026 16:51:05 +0200 Subject: [PATCH 2/2] update_check: handle pcr_dir() -> Result from #86 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #86 changed `config::pcr_dir()` to return `Result` so that auth + SQLite + watcher state never silently fall back to `/tmp` when neither `$HOME` nor `%USERPROFILE%` resolves. The update-notifier is best-effort, so it absorbs the Err the same way it absorbs every other failure: collapse to `None` and silently skip the cache operation. The foreground command never sees the error. Cache helper signatures: cache_path() -> PathBuf // before cache_path() -> Option // after load_cache + save_cache add a `let-else` guard at the top. Tests unchanged — all 6 update_check::tests still pass, workspace 153 passing / 0 failing. Co-authored-by: Cursor --- crates/pcr-core/src/update_check.rs | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/crates/pcr-core/src/update_check.rs b/crates/pcr-core/src/update_check.rs index 4c4e3fb..121e640 100644 --- a/crates/pcr-core/src/update_check.rs +++ b/crates/pcr-core/src/update_check.rs @@ -59,17 +59,21 @@ struct NpmDistTag { version: String, } -fn cache_path() -> PathBuf { - // `pcr_dir()` currently returns `PathBuf` infallibly (with a temp - // fallback). PR #86 changes it to `Result` — when that - // lands, swap this for `pcr_dir().ok()?.join(...)` and downgrade - // the helper signature to `Option`. Until then we keep - // the call shape simple. - crate::config::pcr_dir().join("update-check.json") +fn cache_path() -> Option { + // `pcr_dir()` returns `Result` and errors when neither + // `$HOME` nor `%USERPROFILE%` resolves (sandboxes / locked-down + // containers). The whole update-notifier module is best-effort, + // so we collapse the Err into None and silently skip every cache + // operation rather than propagating up to the foreground command. + crate::config::pcr_dir() + .ok() + .map(|p| p.join("update-check.json")) } fn load_cache() -> CachedCheck { - let path = cache_path(); + let Some(path) = cache_path() else { + return CachedCheck::default(); + }; let Ok(bytes) = std::fs::read(&path) else { return CachedCheck::default(); }; @@ -77,7 +81,7 @@ fn load_cache() -> CachedCheck { } fn save_cache(cache: &CachedCheck) { - let path = cache_path(); + let Some(path) = cache_path() else { return }; // Make sure the parent dir exists. We don't `?` this because the // entire module is best-effort. if let Some(parent) = path.parent() {