diff --git a/docs/supported-versions.md b/docs/supported-versions.md index 10caecf88b..ea2a52c987 100644 --- a/docs/supported-versions.md +++ b/docs/supported-versions.md @@ -23,6 +23,36 @@ The availability feed at `spicetify/modules/spotify-support.json` has a different job. It records the newest Spotify release the project has observed. It does not declare support and must not gate an update by itself. +## Refresh a newly published fix + +If a published compatibility fix has not reached your client after a normal +apply, run: + +```sh +spicetify apply --no-cache +``` + +This option is available in v3 builds whose `spicetify apply --help` lists +`--no-cache`. It bypasses local file reuse and CDN caches for the classmap +index, selected classmap, CSS-map overlay, verification metadata, and exposure +patches. Downloaded compatibility files must still match the index's SHA-256 +digests. New verified files are saved for later normal and offline applies. + +The refresh requires network access. If a download fails or its checksum does +not match, the command exits before stopping or changing Spotify. Retry when +the network or published files are available. A normal `spicetify apply` +continues to allow cached files when a refresh fails. + +After a successful apply, return to the restarted Spotify client and check the +fixed control. This command refreshes compatibility data; update a theme or +module through the Store separately if the fix also requires a new version. +It does not clear Spotify's music cache or update Spotify or the CLI. + +`SPICETIFY_CLASSMAPS_DIR` selects local files instead of downloading them, so +combining it with `--no-cache` is an error. Unset it to fetch published files. +Other explicit local CSS-map and exposure-patch overrides still take priority; +unset those too when verifying a published fix. + ## Classmap selection The key encodes `major.minor.patch`. For example, Spotify `1.3.0.277` uses diff --git a/rust/README.md b/rust/README.md index 65eba85013..ec88fe1322 100644 --- a/rust/README.md +++ b/rust/README.md @@ -42,6 +42,12 @@ Run the development binary directly: Keep `spicetify-daemon` beside `spicetify`. The CLI starts the daemon from its own directory. +To test a just-published compatibility fix without local or CDN caches, run +`./target/release/spicetify apply --no-cache`. This requires network access and +refreshes compatibility data, including classmaps and exposure patches. See +[refreshing a newly published fix](../docs/supported-versions.md#refresh-a-newly-published-fix) +for scope, failure behavior, and developer overrides. + ## Restart the daemon after local changes A local rebuild keeps the same crate version. The CLI therefore cannot detect diff --git a/rust/crates/cli/src/main.rs b/rust/crates/cli/src/main.rs index 0a09dc4c09..03a4bfdce2 100644 --- a/rust/crates/cli/src/main.rs +++ b/rust/crates/cli/src/main.rs @@ -32,7 +32,13 @@ struct SpicetifyCli { #[derive(Debug, Clone, Subcommand)] enum CliCommand { #[command(about = "Apply Spicetify patches to Spotify")] - Apply, + Apply { + #[arg( + long, + help = "Refresh compatibility files, bypassing local and CDN caches; requires network access" + )] + no_cache: bool, + }, #[command(about = "Manage Spicetify configuration")] Config { #[command(subcommand)] @@ -121,7 +127,7 @@ enum CliPkgAction { impl From for Command { fn from(c: CliCommand) -> Self { match c { - CliCommand::Apply => Command::Apply, + CliCommand::Apply { no_cache } => Command::Apply { no_cache }, CliCommand::Config { action } => { let action = match action { Some(CliConfigAction::Open) => ConfigAction::OpenFolder, @@ -229,3 +235,20 @@ fn run() -> Result<()> { ), } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn apply_cache_flag_reaches_dispatch() { + for (args, expected) in + [(vec!["spicetify", "apply"], false), (vec!["spicetify", "apply", "--no-cache"], true)] + { + let cli = SpicetifyCli::try_parse_from(args).expect("valid apply command"); + let cmd = Command::from(cli.command.expect("apply subcommand")); + assert!(matches!(cmd, Command::Apply { no_cache } if no_cache == expected)); + } + assert!(SpicetifyCli::try_parse_from(["spicetify", "restore", "--no-cache"]).is_err()); + } +} diff --git a/rust/crates/daemon/src/update_job.rs b/rust/crates/daemon/src/update_job.rs index 41c0e8e77a..b24df059c1 100644 --- a/rust/crates/daemon/src/update_job.rs +++ b/rust/crates/daemon/src/update_job.rs @@ -630,7 +630,7 @@ impl Supervisor { ); return; }; - if let Err(e) = spicetify::commands::apply::run(ctx, guard) { + if let Err(e) = spicetify::commands::apply::run(ctx, guard, false) { self.secure_failure( FailureCode::ApplyFailed, &format!("Spicetify apply failed after Spotify updated: {e}"), diff --git a/rust/crates/daemon/src/watcher.rs b/rust/crates/daemon/src/watcher.rs index 1943b2328f..9bd953113d 100644 --- a/rust/crates/daemon/src/watcher.rs +++ b/rust/crates/daemon/src/watcher.rs @@ -130,7 +130,7 @@ fn auto_apply(ctx: &AppContext, nth: u32) { tracing::info!("stock xpui.spa is no longer present; skipping auto-apply"); return; } - if let Err(e) = commands::apply::run(ctx, &guard) { + if let Err(e) = commands::apply::run(ctx, &guard, false) { tracing::warn!(error = %e, "auto-apply failed"); } } diff --git a/rust/crates/spicetify/src/commands/apply.rs b/rust/crates/spicetify/src/commands/apply.rs index fb37144f4b..e134d9d82d 100644 --- a/rust/crates/spicetify/src/commands/apply.rs +++ b/rust/crates/spicetify/src/commands/apply.rs @@ -44,6 +44,7 @@ fn fs_err<'a>(doing: &'a str, path: &'a Path) -> impl FnOnce(std::io::Error) -> pub fn run( ctx: &AppContext, _operation_guard: &super::guard::DisruptiveOperationGuard, + no_cache: bool, ) -> Result<()> { let _apply_lock = acquire_apply_lock(&ctx.config_root)?; let dest_apps = ctx.dest_apps_path(); @@ -68,6 +69,15 @@ pub fn run( // away here rather than left without a servable xpui. let detected = detect_supported_spotify_version(ctx)?; + // Refresh compatibility files before stopping Spotify, so a required fresh + // download can fail without disrupting the installed client. + if no_cache && detected.is_none() { + anyhow::bail!("cannot refresh compatibility files without a detected Spotify version"); + } + if let Some(version) = &detected { + refresh_classmap(ctx, &version.to_string(), no_cache)?; + } + crate::lifecycle::stop(ctx)?; if !spa.exists() && !ctx.mirror && backup.exists() { @@ -113,13 +123,6 @@ pub fn run( } } - // Refreshes the classmap cache and the exposure patches together: the - // patches are applied to the client bundle prepared next, so they must be - // current before that step, not at module staging. - if let Some(version) = &detected { - refresh_classmap(ctx, &version.to_string()); - } - let client_bundle = match detect_client_bundle(&tmp) { Ok(bundle) => bundle, Err(e) => { @@ -406,23 +409,37 @@ fn apply_css_map(ctx: &AppContext, dest: &Path) -> Result<()> { } // Classmaps are published per Spotify build, so apply pulls the current one -// before staging. A failure here is not fatal: whatever is already cached (or -// shipped) still applies, which keeps apply working offline. -fn refresh_classmap(ctx: &AppContext, version: &str) { +// before staging. Normal apply can use cached files offline; --no-cache requires +// a successful refresh instead. +fn refresh_classmap(ctx: &AppContext, version: &str, no_cache: bool) -> Result<()> { if std::env::var_os("SPICETIFY_CLASSMAPS_DIR").is_some() { + if no_cache { + anyhow::bail!( + "--no-cache cannot be used with SPICETIFY_CLASSMAPS_DIR; unset it to fetch published compatibility files" + ); + } tracing::debug!("SPICETIFY_CLASSMAPS_DIR is set: skipping the classmap fetch"); - return; + return Ok(()); } let Some(wanted) = crate::module::stage::classmap_key_for_version(version) else { - return; + anyhow::bail!("cannot derive a classmap key from Spotify version {version}"); }; - match crate::module::remote::fetch_classmap(&ctx.config_root, &wanted) { + if no_cache { + tracing::info!("refreshing compatibility files without local or CDN caches"); + } + match crate::module::remote::fetch_classmap(&ctx.config_root, &wanted, no_cache) { Ok(key) if key == wanted => tracing::info!("classmap {key} is current"), Ok(key) => tracing::info!("no published classmap for {wanted}; cached {key} instead"), + Err(e) if no_cache => { + return Err( + e.context("--no-cache compatibility refresh failed; Spotify was not changed") + ); + } Err(e) => { tracing::warn!(error = %e, "could not refresh the classmap; using what is cached"); } } + Ok(()) } // The modular loader boots from /modules/manifest.json, which carries the @@ -837,7 +854,7 @@ mod tests { let apply = std::thread::spawn(move || { let guard = super::super::guard::try_acquire(&ctx.config_root) .expect("synthetic apply owns the disruptive-operation guard"); - tx.send(run(&ctx, &guard)).expect("test receiver remains available"); + tx.send(run(&ctx, &guard, false)).expect("test receiver remains available"); }); assert!( rx.recv_timeout(std::time::Duration::from_millis(100)).is_err(), diff --git a/rust/crates/spicetify/src/commands/mod.rs b/rust/crates/spicetify/src/commands/mod.rs index ac9f2959d9..1a699e2c65 100644 --- a/rust/crates/spicetify/src/commands/mod.rs +++ b/rust/crates/spicetify/src/commands/mod.rs @@ -22,7 +22,7 @@ pub enum ConfigAction { #[derive(Debug, Clone)] pub enum Command { - Apply, + Apply { no_cache: bool }, Config(ConfigAction), Daemon(DaemonAction), Dev, @@ -63,9 +63,9 @@ pub enum PkgAction { pub fn dispatch(cmd: &Command, ctx: &AppContext) -> Result<()> { match cmd { - Command::Apply => { + Command::Apply { no_cache } => { let guard = guard::try_acquire(&ctx.config_root)?; - apply::run(ctx, &guard) + apply::run(ctx, &guard, *no_cache) } Command::Config(action) => match action { ConfigAction::Show => config::run(ctx), diff --git a/rust/crates/spicetify/src/commands/protocol.rs b/rust/crates/spicetify/src/commands/protocol.rs index 7c99819f0b..78f4e8299f 100644 --- a/rust/crates/spicetify/src/commands/protocol.rs +++ b/rust/crates/spicetify/src/commands/protocol.rs @@ -176,7 +176,7 @@ fn perform(ctx: &AppContext, action: ProtocolAction, uri: &Url) -> Result<()> { // fire-and-forget rather than waiting on a response. ProtocolAction::Apply => { let guard = super::guard::try_acquire(&ctx.config_root)?; - super::apply::run(ctx, &guard) + super::apply::run(ctx, &guard, false) } ProtocolAction::BlockUpdates => { let _guard = super::guard::try_acquire(&ctx.config_root)?; diff --git a/rust/crates/spicetify/src/module/remote.rs b/rust/crates/spicetify/src/module/remote.rs index 1fa30c6868..28cd3fe713 100644 --- a/rust/crates/spicetify/src/module/remote.rs +++ b/rust/crates/spicetify/src/module/remote.rs @@ -79,11 +79,30 @@ pub(crate) fn indexed_classmap_file(config_root: &Path, key: &str) -> IndexedCla /// Downloads the classmap for `wanted_key`, or the newest published key below /// it sharing the same major.minor. Returns the key that was cached. -pub(crate) fn fetch_classmap(config_root: &Path, wanted_key: &str) -> Result { - let client = crate::http::blocking_client(20)?; +pub(crate) fn fetch_classmap( + config_root: &Path, + wanted_key: &str, + no_cache: bool, +) -> Result { + fetch_classmap_from(config_root, wanted_key, &base_url(), no_cache) +} - let index_bytes = client - .get(format!("{}/index.json", base_url())) +fn fetch_classmap_from( + config_root: &Path, + wanted_key: &str, + origin: &str, + no_cache: bool, +) -> Result { + let mut nonce = [0; 16]; + let cache_bust = if no_cache { + getrandom::fill(&mut nonce)?; + Some(hex::encode(nonce)) + } else { + None + }; + let download = Download { client: crate::http::blocking_client(20)?, origin, cache_bust }; + let index_bytes = download + .get("index.json")? .send() .and_then(reqwest::blocking::Response::error_for_status) .and_then(reqwest::blocking::Response::bytes) @@ -96,6 +115,13 @@ pub(crate) fn fetch_classmap(config_root: &Path, wanted_key: &str) -> Result Result Result bool { && Path::new(name).file_name().and_then(std::ffi::OsStr::to_str) == Some(name) } -/// `key` is the classmap key directory the file lives under; `None` is a file -/// published at the repo root (the exposure patch set). -fn cache_file( - client: &reqwest::blocking::Client, - key: Option<&str>, - file: &FileRef, - dest: &Path, -) -> Result<()> { - let path = dest.join(&file.file); - if path.is_file() - && std::fs::read(&path).is_ok_and(|bytes| digest(&bytes) == file.sha256.to_lowercase()) - { - return Ok(()); - } +#[derive(Debug)] +struct Download<'a> { + client: reqwest::blocking::Client, + origin: &'a str, + cache_bust: Option, +} - let url = match key { - Some(key) => format!("{}/{key}/{}", base_url(), file.file), - None => format!("{}/{}", base_url(), file.file), - }; - let bytes = client - .get(&url) - .send() - .and_then(reqwest::blocking::Response::error_for_status) - .and_then(reqwest::blocking::Response::bytes) - .map_err(|e| anyhow::anyhow!("cannot download {url}: {e}"))?; - - let actual = digest(&bytes); - if actual != file.sha256.to_lowercase() { - anyhow::bail!( - "checksum mismatch for {}: index says {}, download is {actual}", - file.file, - file.sha256 - ); +impl Download<'_> { + fn get(&self, path: &str) -> Result { + let mut url = url::Url::parse(&format!("{}/{path}", self.origin))?; + if let Some(nonce) = &self.cache_bust { + // A header alone does not bypass GitHub's raw-content CDN cache. + let _ = url.query_pairs_mut().append_pair("_spicetify_refresh", nonce); + } + let request = self.client.get(url); + Ok(if self.cache_bust.is_some() { + request.header(reqwest::header::CACHE_CONTROL, "no-cache") + } else { + request + }) } - std::fs::write(&path, &bytes)?; - if let Some(key) = key { - tracing::info!("cached classmap file {key}/{}", file.file); - } else { - tracing::info!("cached {}", file.file); + // A missing key addresses root-level exposure patches. + fn cache_file(&self, key: Option<&str>, file: &FileRef, dest: &Path) -> Result<()> { + let path = dest.join(&file.file); + if self.cache_bust.is_none() + && path.is_file() + && std::fs::read(&path).is_ok_and(|bytes| digest(&bytes) == file.sha256.to_lowercase()) + { + return Ok(()); + } + + let url = match key { + Some(key) => format!("{key}/{}", file.file), + None => file.file.clone(), + }; + let bytes = self + .get(&url)? + .send() + .and_then(reqwest::blocking::Response::error_for_status) + .and_then(reqwest::blocking::Response::bytes) + .map_err(|e| anyhow::anyhow!("cannot download {url}: {e}"))?; + + let actual = digest(&bytes); + if actual != file.sha256.to_lowercase() { + anyhow::bail!( + "checksum mismatch for {}: index says {}, download is {actual}", + file.file, + file.sha256 + ); + } + + std::fs::write(&path, &bytes)?; + if let Some(key) = key { + tracing::info!("cached classmap file {key}/{}", file.file); + } else { + tracing::info!("cached {}", file.file); + } + Ok(()) } - Ok(()) } pub(crate) fn digest(bytes: &[u8]) -> String { @@ -303,6 +350,163 @@ mod tests { dir } + fn serve( + replies: Vec<(&'static str, Vec)>, + ) -> (String, std::thread::JoinHandle>) { + use std::io::{Read, Write}; + use std::time::{Duration, Instant}; + let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind fixture"); + listener.set_nonblocking(true).expect("nonblocking fixture"); + let origin = format!("http://{}", listener.local_addr().expect("fixture address")); + let worker = std::thread::spawn(move || { + let mut requests = Vec::new(); + for (status, body) in replies { + let deadline = Instant::now() + Duration::from_secs(5); + let mut stream = loop { + match listener.accept() { + Ok((stream, _)) => break stream, + Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => { + assert!(Instant::now() < deadline, "missing fixture request"); + std::thread::sleep(Duration::from_millis(10)); + } + Err(e) => unreachable!("fixture accept failed: {e}"), + } + }; + stream.set_read_timeout(Some(Duration::from_secs(5))).expect("read timeout"); + let mut request = Vec::new(); + while !request.ends_with(b"\r\n\r\n") { + let mut byte = [0]; + stream.read_exact(&mut byte).expect("request headers"); + request.extend(byte); + } + requests.push(String::from_utf8(request).expect("HTTP headers")); + write!( + stream, + "HTTP/1.1 {status}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + body.len() + ) + .expect("response headers"); + stream.write_all(&body).expect("response body"); + } + requests + }); + (origin, worker) + } + + #[test] + fn no_cache_refreshes_the_index_and_every_compatibility_file() { + let root = scratch("refresh"); + let cache = root.join("classmaps"); + let selected = cache.join("1030000"); + std::fs::create_dir_all(&selected).expect("cache dir"); + let classmap = br#"{"main":{}}"#.to_vec(); + let meta = br#"{"status":"verified"}"#.to_vec(); + let expose = br#"{"patches":[]}"#.to_vec(); + let old_overlay = b"{}".to_vec(); + let overlay = br#"{"newHash":"playback-bar"}"#.to_vec(); + let file_ref = + |file: &str, bytes: &[u8]| serde_json::json!({"file": file, "sha256": digest(bytes)}); + let index = serde_json::json!({ + "expose": file_ref("expose.json", &expose), + "keys": {"1030000": { + "classmap": file_ref("classmap.json", &classmap), + "meta": file_ref("META.json", &meta), + "cssMapOverlay": file_ref("css-map.json", &overlay) + }} + }); + let mut old_index = index.clone(); + *old_index.pointer_mut("/keys/1030000/cssMapOverlay").expect("overlay entry") = + file_ref("css-map.json", &old_overlay); + for (path, body) in [ + (cache.join("expose.json"), &expose), + (selected.join("classmap.json"), &classmap), + (selected.join("META.json"), &meta), + (selected.join("css-map.json"), &old_overlay), + ] { + std::fs::write(path, body).expect("cached file"); + } + let (origin, worker) = serve(vec![ + ("200 OK", serde_json::to_vec(&old_index).expect("old index")), + ("200 OK", serde_json::to_vec(&index).expect("index")), + ("200 OK", expose), + ("200 OK", classmap), + ("200 OK", meta), + ("200 OK", overlay.clone()), + ]); + assert_eq!( + fetch_classmap_from(&root, "1030000", &origin, false).expect("normal cached apply"), + "1030000" + ); + assert_eq!(std::fs::read(selected.join("css-map.json")).expect("old overlay"), old_overlay); + assert_eq!( + fetch_classmap_from(&root, "1030000", &origin, true).expect("fresh apply"), + "1030000" + ); + assert_eq!(std::fs::read(selected.join("css-map.json")).expect("new overlay"), overlay); + assert_eq!( + serde_json::from_slice::( + &std::fs::read(cache.join("index.json")).expect("cached index") + ) + .expect("index JSON"), + index + ); + let requests = worker.join().expect("fixture completed"); + assert!( + requests.first().expect("normal index request").starts_with("GET /index.json HTTP/1.1") + ); + for (request, path) in requests.iter().skip(1).zip([ + "index.json", + "expose.json", + "1030000/classmap.json", + "1030000/META.json", + "1030000/css-map.json", + ]) { + assert!(request.starts_with(&format!("GET /{path}?_spicetify_refresh=")), "{request}"); + assert!(request.to_ascii_lowercase().contains("cache-control: no-cache"), "{request}"); + } + std::fs::remove_dir_all(root).expect("cleanup"); + } + + #[test] + fn no_cache_rejects_bad_downloads_and_retains_the_cached_file() { + let root = scratch("bad-download"); + let old = b"verified cached file"; + std::fs::write(root.join("css-map.json"), old).expect("old file"); + let (origin, worker) = serve(vec![("200 OK", b"stale CDN response".to_vec())]); + let download = Download { + client: crate::http::blocking_client(5).expect("client"), + origin: &origin, + cache_bust: Some("test-refresh".to_string()), + }; + let error = download + .cache_file( + None, + &FileRef { file: "css-map.json".to_string(), sha256: digest(old) }, + &root, + ) + .expect_err("bad digest"); + assert!(error.to_string().contains("checksum mismatch"), "{error}"); + assert_eq!(std::fs::read(root.join("css-map.json")).expect("retained cache"), old); + let _ = worker.join().expect("fixture completed"); + std::fs::remove_dir_all(root).expect("cleanup"); + } + + #[test] + fn no_cache_propagates_exposure_refresh_failure() { + let root = scratch("exposure-failure"); + let index = + serde_json::json!({"keys": {}, "expose": {"file": "expose.json", "sha256": "unused"}}); + let (origin, worker) = serve(vec![ + ("200 OK", serde_json::to_vec(&index).expect("index")), + ("503 Service Unavailable", Vec::new()), + ]); + let error = fetch_classmap_from(&root, "1030000", &origin, true) + .expect_err("fresh exposure required"); + assert!(error.to_string().contains("could not refresh the exposure patches"), "{error}"); + let _ = worker.join().expect("fixture completed"); + std::fs::remove_dir_all(root).expect("cleanup"); + } + #[test] fn rejects_keys_that_are_not_plain_numbers() { assert!(is_plain_key("1020094")); diff --git a/rust/crates/tui/src/components/menu_list.rs b/rust/crates/tui/src/components/menu_list.rs index 00979235e6..c6b55f8384 100644 --- a/rust/crates/tui/src/components/menu_list.rs +++ b/rust/crates/tui/src/components/menu_list.rs @@ -71,7 +71,7 @@ impl MenuAction { #[must_use] pub(crate) fn into_command(self) -> Command { match self { - Self::Apply => Command::Apply, + Self::Apply => Command::Apply { no_cache: false }, Self::Restore => Command::Restore, Self::Dev => Command::Dev, Self::Config => Command::Config(ConfigAction::Show),