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
34 changes: 29 additions & 5 deletions docs/supported-versions.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,9 +83,13 @@ modifies the client.
match a verified entry in the consumed index.
- `supportedSpotify` is the newest verified Spotify version in that index.
- `classmapFallback` reports whether selection used an older patch.
- `updatesBlocked` reports the installed updater protection at apply time.
- `updatesBlocked` reports native updater protection at apply time. It is
omitted when protection cannot be determined; `false` means the native
updater is known to be unblocked. Managed package updates are separate.
- `managedSpotify` identifies a Spicetify-owned Linux installation and its
package channel, `stable` or `testing`.

Manager combines these local facts with the availability feed. Its
For native installations, Manager combines these local facts with the availability feed. Its
**supported** badge comes from `supportedSpotify`; its **available** badge
comes from the observed-version feed.

Expand Down Expand Up @@ -125,9 +129,29 @@ spicetify spotify-updates unblock
spicetify spotify-updates status
```

Current Windows clients protect the updater staging directory. macOS and Linux
patch the update endpoint in Spotify's binary; macOS also signs the changed app
bundle and applies a secondary update-cache lock.
Current Windows desktop clients protect the updater staging directory.
Microsoft Store updates must be managed through Microsoft Store. macOS patches
the update endpoint in Spotify's binary, signs the changed app bundle, and
applies a secondary update-cache lock.

On Linux, the binary block only works when its expected endpoint is present.
An unrecognized endpoint leaves protection unknown. The Linux managed installer
offers a separate path: `spicetify spotify install` installs a user-owned copy,
and `spicetify spotify update` explicitly downloads and applies a verified
package. System package managers do not own that copy. This does not establish
native updater protection or freeze other Spotify installations.

For managed installations, Manager checks Spotify's Linux package feed and
offers **Update Spotify & Apply** when a newer package has an exact verified
classmap. The daemon owns the job, so closing or restarting the renderer does
not cancel it. It prepares and patches a separate copy before switching the
configuration, desktop entry, and terminal launcher together. Update progress
and the final result remain available after Spotify restarts.

If the daemon itself stops during an update, the next start reports the
interrupted job. Run `spicetify spotify install` to prepare a fresh copy using
the installation's existing channel, then retry. Updates requested from the
terminal use the same installer.

`block` and `unblock` store the user's intent in `config.toml`. A successful
Spotify update can replace the installed protection, so `apply` reasserts a
Expand Down
8 changes: 4 additions & 4 deletions rust/Cargo.lock

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

2 changes: 1 addition & 1 deletion rust/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ members = ["crates/spicetify", "crates/cli", "crates/tui", "crates/daemon"]
resolver = "2"

[workspace.package]
version = "3.0.0-beta.17"
version = "3.0.0-beta.18"
edition = "2024"
rust-version = "1.95"
repository = "https://github.com/veryboringhwl/app"
Expand Down
21 changes: 15 additions & 6 deletions rust/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,28 +66,37 @@ or change system packages.

Installation requires a verified classmap for the exact Spotify version line.
Spicetify patches the candidate before switching its configuration and the
Spotify desktop launcher to it, then restarts Spotify and the daemon. A failed
activation restores the previous configuration and launcher. Previous client
Spotify desktop and terminal launchers to it, then restarts Spotify. A failed
activation restores the previous configuration and launchers. Previous client
files remain available; the candidate also retains `config-before.toml` and
`desktop-before.desktop` when those files existed.

Client files live under `$XDG_DATA_HOME/spicetify/spotify/versions`, normally
`~/.local/share/spicetify/spotify/versions`. Verified downloads are cached under
`$XDG_CACHE_HOME/spicetify/spotify`. These commands neither replace `/usr/bin/spotify`
nor manage installations owned by apt, pacman, Snap, or Flatpak. The desktop
launcher and Spicetify configuration select the managed client.
launcher and Spicetify configuration select the managed client. A symlink at
`~/.local/bin/spotify` selects the same executable from a terminal. Keep
`~/.local/bin` before system directories in `PATH`; the installer warns when
another executable takes precedence. Existing regular files at that path are
preserved, and installation stops with instructions to move them aside.

Stable is the default channel. Use `spotify install --channel testing` to opt
into Spotify's testing feed, or `spotify update --channel testing` to switch
an existing managed install. Subsequent updates retain that channel. Downgrades
an existing managed install. Subsequent updates and reinstalls retain that channel. Downgrades
are refused, including when switching back to an older stable release.
An explicit `spotify install` prepares a fresh patched copy even when the
package version is unchanged, so it can restore Spicetify after `spicetify restore`.

Package updates run only when requested. This does not prove that Spotify's
native self-updater is blocked. `spotify status` reports native block detection
separately; an unrecognized endpoint remains **unknown**. The daemon's in-client
**Update & Apply** transaction still uses Spotify's native updater.
separately; an unrecognized endpoint remains **unknown**.

Manager offers **Update Spotify & Apply** for managed installations. It checks
the selected Linux package feed rather than the global Spotify availability
feed. The daemon runs the same installer and keeps progress through renderer
restarts. A second request joins the running job. If the daemon itself stops,
the next start reports the interruption and offers the reinstall command.

## Restart the daemon after local changes

Expand Down
12 changes: 9 additions & 3 deletions rust/crates/cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -91,8 +91,12 @@ enum CliCommand {
enum CliSpotifyAction {
#[command(about = "Download Spotify, apply Spicetify, and add a desktop launcher")]
Install {
#[arg(long, value_enum, default_value = "stable")]
channel: SpotifyChannel,
#[arg(
long,
value_enum,
help = "Keep the installed channel; new installations default to stable"
)]
channel: Option<SpotifyChannel>,
},
#[command(about = "Update the Spotify installation managed by Spicetify")]
Update {
Expand Down Expand Up @@ -196,7 +200,9 @@ impl From<CliCommand> for Command {
CliCommand::Spotify { action } => {
use spicetify::commands::spotify::Action;
Command::Spotify(match action {
CliSpotifyAction::Install { channel } => Action::Install(channel.into()),
CliSpotifyAction::Install { channel } => {
Action::Install(channel.map(Into::into))
}
CliSpotifyAction::Update { channel } => Action::Update(channel.map(Into::into)),
CliSpotifyAction::Status => Action::Status,
})
Expand Down
2 changes: 2 additions & 0 deletions rust/crates/daemon/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ use i18n_embed_fl as _;

pub mod error;
pub mod health;
#[cfg(target_os = "linux")]
pub mod managed_spotify;
pub mod proxy;
pub mod routes;
pub mod server;
Expand Down
178 changes: 178 additions & 0 deletions rust/crates/daemon/src/managed_spotify.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
use std::path::Path;
use std::sync::{Arc, Mutex};

use crate::update_job::{Admission, AdmissionDisposition};
use anyhow::Context;
use serde::{Deserialize, Serialize};
use spicetify::commands::{guard, spotify};
use spicetify::context::{Config, SharedContext};

const STATE_FILE: &str = "managed-spotify-job.json";

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "kebab-case", rename_all_fields = "camelCase")]
pub enum Job {
Idle,
Running { job_id: String, phase: spotify::Phase },
Complete { job_id: String },
Failed { job_id: String, message: String },
}

impl Job {
pub fn running(&self) -> bool {
matches!(self, Self::Running { .. })
}
}

#[derive(Debug, Clone)]
pub struct Handle {
job: Arc<Mutex<Job>>,
shared: Arc<SharedContext>,
}

#[derive(Debug, Serialize)]
pub struct Snapshot {
installation: spotify::InstallationStatus,
job: Job,
}

impl Handle {
pub fn new(shared: Arc<SharedContext>) -> anyhow::Result<Self> {
let root = shared.load().config_root.clone();
let job = match std::fs::read(root.join(STATE_FILE)) {
Ok(bytes) => recover_job(serde_json::from_slice(&bytes)?),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Job::Idle,
Err(error) => return Err(error.into()),
};
persist(&root, &job)?;
Ok(Self { job: Arc::new(Mutex::new(job)), shared })
}

pub fn snapshot(&self) -> anyhow::Result<Snapshot> {
Ok(Snapshot {
installation: spotify::installation_status(&self.shared.load_full()).unwrap_or_else(
|error| spotify::InstallationStatus::Unavailable {
message: format!("Cannot inspect the current Spotify installation: {error:#}"),
},
),
job: self
.job
.lock()
.map_err(|_| anyhow::anyhow!("managed update lock poisoned"))?
.clone(),
})
}

pub fn running(&self) -> bool {
self.job.lock().is_ok_and(|job| job.running())
}

pub fn admit(&self) -> anyhow::Result<Admission> {
let mut job =
self.job.lock().map_err(|_| anyhow::anyhow!("managed update lock poisoned"))?;
if let Job::Running { job_id, .. } = &*job {
return Ok(Admission {
job_id: job_id.clone(),
disposition: AdmissionDisposition::Joined,
});
}
let ctx = self.shared.load_full();
let guard = guard::try_acquire(&ctx.config_root)?;
anyhow::ensure!(
matches!(
spotify::installation_status(&ctx)?,
spotify::InstallationStatus::Managed { .. }
),
"Spotify is not managed by Spicetify; run `spicetify spotify install` first"
);
let job_id = format!(
"{}-{}",
std::process::id(),
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH)?.as_nanos()
);
let accepted = Job::Running { job_id: job_id.clone(), phase: spotify::Phase::Checking };
persist(&ctx.config_root, &accepted)?;
*job = accepted;
let handle = self.clone();
let id = job_id.clone();
let spawned = std::thread::Builder::new().name("managed-spotify-update".into()).spawn(move || {
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
spotify::update_managed(&ctx, &guard, |phase| {
handle.publish(Job::Running { job_id: id.clone(), phase })
})
})).unwrap_or_else(|_| Err(anyhow::anyhow!("managed update worker panicked; run `spicetify spotify install` to repair the installation")));
// Activation changes the configured installation. Refresh even after
// an error: recovery may have restored the previous configuration.
let refreshed = Config::load(&ctx.config_file)
.and_then(|config| spicetify::context::AppContext::from_config(ctx.config_root.clone(), &config));
let result = result.and_then(|()| refreshed.as_ref().map(|_| ()).map_err(|error| anyhow::anyhow!("cannot reload Spotify configuration: {error:#}")));
if let Ok(next) = refreshed { handle.shared.store(next); }
let terminal = match result {
Ok(()) => Job::Complete { job_id: id },
Err(error) => Job::Failed { job_id: id, message: format!("{error:#}") },
};
if let Err(error) = handle.publish(terminal) {
tracing::error!(%error, "could not save managed Spotify update result");
}
drop(guard);
});
if let Err(error) = spawned {
*job = Job::Failed { job_id, message: error.to_string() };
persist(&self.shared.load().config_root, &job)?;
return Err(error.into());
}
Ok(Admission { job_id, disposition: AdmissionDisposition::Accepted })
}

fn publish(&self, next: Job) -> anyhow::Result<()> {
let mut job =
self.job.lock().map_err(|_| anyhow::anyhow!("managed update lock poisoned"))?;
let saved = persist(&self.shared.load().config_root, &next);
*job = next;
saved
}
}

fn recover_job(job: Job) -> Job {
match job {
Job::Running { job_id, .. } => Job::Failed {
job_id,
message: "The daemon stopped during this update. Run `spicetify spotify install` to repair the installation, then retry.".into(),
},
other => other,
}
}

fn persist(root: &Path, job: &Job) -> anyhow::Result<()> {
use std::io::Write;
let temporary = root.join("managed-spotify-job.json.tmp");
let mut file = std::fs::File::create(&temporary)?;
file.write_all(&serde_json::to_vec(job)?)?;
file.sync_all()?;
std::fs::rename(&temporary, root.join(STATE_FILE))
.context("cannot save managed update progress")?;
std::fs::File::open(root)?.sync_all()?;
Ok(())
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn restart_never_reports_an_interrupted_update_as_completed() {
for phase in [
spotify::Phase::Checking,
spotify::Phase::Downloading,
spotify::Phase::Preparing,
spotify::Phase::Activating,
] {
let restored = recover_job(Job::Running { job_id: "one".into(), phase });
assert!(matches!(restored, Job::Failed { job_id, .. } if job_id == "one"));
}
assert!(matches!(
recover_job(Job::Complete { job_id: "one".into() }),
Job::Complete { .. }
));
}
}
Loading