Skip to content
Closed
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
2 changes: 1 addition & 1 deletion libs/cua-driver/rust/crates/cua-driver/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ tokio = { workspace = true, features = ["full"] }
image = { workspace = true }
base64 = { workspace = true }
# Isolated HOME / cache directories for `version_check` unit tests so they
# never touch the developer's real `~/.cua-driver-rs/version_check.json`.
# never touch the developer's real `~/.cua-driver/version_check.json`.
tempfile = "3"
futures-util = "0.3"
tokio-tungstenite = "0.24"
Expand Down
127 changes: 113 additions & 14 deletions libs/cua-driver/rust/crates/cua-driver/src/version_check.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,14 +34,15 @@ use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};

/// Disk-resident cache file (sibling of the telemetry artifacts under
/// `~/.cua-driver-rs/`). Holds the last-seen latest version plus the list
/// `~/.cua-driver/`). Holds the last-seen latest version plus the list
/// of versions the user has actively dismissed.
const CACHE_FILE_NAME: &str = "version_check.json";

/// `~/.cua-driver-rs/` — same subdirectory the telemetry client uses,
/// kept separate from the `~/.cua-driver/` config tree that the Swift
/// reference owns.
const HOME_SUBDIRECTORY: &str = ".cua-driver-rs";
/// The pre-rename home. [`migrate_legacy_cache`] moves a cache written
/// there by an older build into the canonical home and drops the directory
/// once it is empty — same shape as the telemetry and skill-pack
/// migrations. Nothing in this module writes here.
const LEGACY_HOME_SUBDIRECTORY: &str = ".cua-driver-rs";

/// Single-invocation opt-out env var. Recognised values mirror the
/// telemetry opt-out: `0|false|no|off` disables the check, everything
Expand Down Expand Up @@ -464,7 +465,7 @@ pub fn is_newer(latest: &str, current: &str) -> bool {

// ── On-disk cache ────────────────────────────────────────────────────────

/// Serialised shape of `~/.cua-driver-rs/version_check.json`.
/// Serialised shape of `~/.cua-driver/version_check.json`.
///
/// `last_checked_unix` is the source of truth for cache-age decisions;
/// `last_checked_at` is the same instant rendered as ISO-8601 for human
Expand All @@ -485,6 +486,7 @@ pub(crate) struct VersionCache {
/// Read the cache file. Returns `None` when missing / unreadable / not
/// valid JSON — callers fall back to the default (empty) shape.
fn read_cache() -> Option<VersionCache> {
migrate_legacy_cache();
let path = cache_path()?;
let raw = std::fs::read_to_string(&path).ok()?;
serde_json::from_str(&raw).ok()
Expand All @@ -508,19 +510,60 @@ fn write_cache(cache: &VersionCache) -> std::io::Result<()> {
std::fs::write(&path, json)
}

fn cache_path() -> Option<PathBuf> {
fn home_root() -> Option<PathBuf> {
let home = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE"))?;
Some(PathBuf::from(home))
}

fn cache_path() -> Option<PathBuf> {
Some(
PathBuf::from(home)
.join(if crate::bundle::is_local_installation() {
".cua-driver-local"
} else {
HOME_SUBDIRECTORY
})
home_root()?
.join(crate::bundle::user_home_subdirectory())
.join(CACHE_FILE_NAME),
)
}

/// Pre-rename cache location. `None` for the source-build product, which
/// has always used its own `~/.cua-driver-local/` home and never wrote here.
fn legacy_cache_path() -> Option<PathBuf> {
if crate::bundle::is_local_installation() {
return None;
}
Some(
home_root()?
.join(LEGACY_HOME_SUBDIRECTORY)
.join(CACHE_FILE_NAME),
)
}

/// Move a pre-rename cache into the canonical home, then remove the legacy
/// directory if nothing else is left in it.
///
/// Best-effort and idempotent: every step is allowed to fail silently, and
/// the caller falls back to an empty cache exactly as it would for a missing
/// file. Keeping the dismissed-version list is the only reason to bother —
/// losing it would re-nag the user about a release they already dismissed.
fn migrate_legacy_cache() {
let (Some(legacy), Some(current)) = (legacy_cache_path(), cache_path()) else {
return;
};
if !legacy.is_file() {
return;
}
if !current.exists() {
if let Some(parent) = current.parent() {
let _ = std::fs::create_dir_all(parent);
}
let _ = std::fs::rename(&legacy, &current);
}
// Either the rename already took it, or the canonical cache wins and the
// stale copy goes. Both paths end with the legacy home unreferenced.
let _ = std::fs::remove_file(&legacy);
if let Some(parent) = legacy.parent() {
let _ = std::fs::remove_dir(parent);
}
Comment on lines +553 to +564
}

// ── HTTP fetch (shared with the `update` subcommand) ─────────────────────

/// Fetch the highest `cua-driver-rs-v*` release tag from GitHub.
Expand Down Expand Up @@ -637,7 +680,7 @@ mod tests {
/// Redirect `HOME` / `USERPROFILE` to a fresh temp dir for the body
/// of `f`, then restore. Ensures the cache file lives in an isolated
/// directory and never touches the developer's real
/// `~/.cua-driver-rs/version_check.json`.
/// `~/.cua-driver/version_check.json`.
fn with_isolated_home<R>(f: impl FnOnce(&std::path::Path) -> R) -> R {
let tmp = tempfile::tempdir().expect("tempdir");
let saved_home = std::env::var_os("HOME");
Expand Down Expand Up @@ -751,6 +794,62 @@ mod tests {
});
}

#[test]
fn cache_lands_in_the_canonical_home() {
let _g = ENV_LOCK.lock().unwrap();
with_isolated_home(|home| {
write_cache(&VersionCache::default()).expect("write_cache");
assert!(home
.join(crate::bundle::user_home_subdirectory())
.join(CACHE_FILE_NAME)
.is_file());
// The pre-rename home must not be re-created behind the
// installer's back — sweeping it is what the installer does.
assert!(!home.join(LEGACY_HOME_SUBDIRECTORY).exists());
});
}

#[test]
fn legacy_cache_is_migrated_and_its_home_removed() {
let _g = ENV_LOCK.lock().unwrap();
with_isolated_home(|home| {
let legacy_home = home.join(LEGACY_HOME_SUBDIRECTORY);
std::fs::create_dir_all(&legacy_home).unwrap();
std::fs::write(
legacy_home.join(CACHE_FILE_NAME),
r#"{"latest_version":"0.1.4","dismissed_versions":["0.1.4"]}"#,
)
.unwrap();

let migrated = read_cache().expect("read_cache");
assert_eq!(migrated.latest_version.as_deref(), Some("0.1.4"));
assert_eq!(migrated.dismissed_versions, vec!["0.1.4".to_owned()]);
assert!(home
.join(crate::bundle::user_home_subdirectory())
.join(CACHE_FILE_NAME)
.is_file());
assert!(!legacy_home.exists());
});
}

#[test]
fn migration_leaves_a_legacy_home_that_still_holds_other_files() {
let _g = ENV_LOCK.lock().unwrap();
with_isolated_home(|home| {
let legacy_home = home.join(LEGACY_HOME_SUBDIRECTORY);
std::fs::create_dir_all(&legacy_home).unwrap();
std::fs::write(legacy_home.join(CACHE_FILE_NAME), "{}").unwrap();
std::fs::write(legacy_home.join(".telemetry_id"), "not-ours").unwrap();

let _ = read_cache();

// Our file is gone, somebody else's is untouched: the empty-dir
// removal must never turn into a recursive delete.
assert!(!legacy_home.join(CACHE_FILE_NAME).exists());
assert!(legacy_home.join(".telemetry_id").is_file());
});
}

#[test]
fn dismissed_versions_persist_across_writes() {
let _g = ENV_LOCK.lock().unwrap();
Expand Down
Loading