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
30 changes: 30 additions & 0 deletions docs/supported-versions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions rust/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
27 changes: 25 additions & 2 deletions rust/crates/cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down Expand Up @@ -121,7 +127,7 @@ enum CliPkgAction {
impl From<CliCommand> 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,
Expand Down Expand Up @@ -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());
}
}
2 changes: 1 addition & 1 deletion rust/crates/daemon/src/update_job.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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}"),
Expand Down
2 changes: 1 addition & 1 deletion rust/crates/daemon/src/watcher.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}
}
Expand Down
45 changes: 31 additions & 14 deletions rust/crates/spicetify/src/commands/apply.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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() {
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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 <xpui>/modules/manifest.json, which carries the
Expand Down Expand Up @@ -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(),
Expand Down
6 changes: 3 additions & 3 deletions rust/crates/spicetify/src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ pub enum ConfigAction {

#[derive(Debug, Clone)]
pub enum Command {
Apply,
Apply { no_cache: bool },
Config(ConfigAction),
Daemon(DaemonAction),
Dev,
Expand Down Expand Up @@ -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),
Expand Down
2 changes: 1 addition & 1 deletion rust/crates/spicetify/src/commands/protocol.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)?;
Expand Down
Loading