From 0eebd58822ae1bfbb664e554e92ebd82252c1c12 Mon Sep 17 00:00:00 2001 From: Afonso Jorge Ramos Date: Thu, 17 Sep 2026 02:14:02 +0200 Subject: [PATCH] feat(rust): run managed Spotify updates through the daemon --- docs/supported-versions.md | 34 ++- rust/Cargo.lock | 8 +- rust/Cargo.toml | 2 +- rust/README.md | 21 +- rust/crates/cli/src/main.rs | 12 +- rust/crates/daemon/src/main.rs | 2 + rust/crates/daemon/src/managed_spotify.rs | 178 +++++++++++++ rust/crates/daemon/src/routes.rs | 74 ++++++ rust/crates/daemon/src/server.rs | 4 + rust/crates/daemon/src/watcher.rs | 137 +++++++++- rust/crates/spicetify/src/commands/apply.rs | 8 +- rust/crates/spicetify/src/commands/spotify.rs | 243 ++++++++++++++++-- rust/crates/spicetify/src/module/stage.rs | 39 ++- rust/crates/spicetify/src/process.rs | 18 +- src/jsHelper/modularLoader/types.ts | 1 + src/jsHelper/spicetifyWrapper/platform.js | 2 + .../spicetifyWrapper/shared/managedSpotify.js | 20 ++ .../shared/managedSpotify.test.mts | 41 +++ 18 files changed, 795 insertions(+), 49 deletions(-) create mode 100644 rust/crates/daemon/src/managed_spotify.rs create mode 100644 src/jsHelper/spicetifyWrapper/shared/managedSpotify.js create mode 100644 src/jsHelper/spicetifyWrapper/shared/managedSpotify.test.mts diff --git a/docs/supported-versions.md b/docs/supported-versions.md index 327d03126a..7ad715968d 100644 --- a/docs/supported-versions.md +++ b/docs/supported-versions.md @@ -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. @@ -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 diff --git a/rust/Cargo.lock b/rust/Cargo.lock index ca105ea2ba..7fe2d2fb32 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -433,7 +433,7 @@ checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" [[package]] name = "cli" -version = "3.0.0-beta.17" +version = "3.0.0-beta.18" dependencies = [ "anyhow", "clap", @@ -662,7 +662,7 @@ dependencies = [ [[package]] name = "daemon" -version = "3.0.0-beta.17" +version = "3.0.0-beta.18" dependencies = [ "anyhow", "axum", @@ -3117,7 +3117,7 @@ dependencies = [ [[package]] name = "spicetify" -version = "3.0.0-beta.17" +version = "3.0.0-beta.18" dependencies = [ "aho-corasick", "anyhow", @@ -3717,7 +3717,7 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "tui" -version = "3.0.0-beta.17" +version = "3.0.0-beta.18" dependencies = [ "anyhow", "crossterm", diff --git a/rust/Cargo.toml b/rust/Cargo.toml index e5c9647f1a..4eb800a3ce 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -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" diff --git a/rust/README.md b/rust/README.md index 0599f54a2a..ac30bb33f3 100644 --- a/rust/README.md +++ b/rust/README.md @@ -66,8 +66,8 @@ 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. @@ -75,19 +75,28 @@ 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 diff --git a/rust/crates/cli/src/main.rs b/rust/crates/cli/src/main.rs index 66f4381eba..65f57e4b9f 100644 --- a/rust/crates/cli/src/main.rs +++ b/rust/crates/cli/src/main.rs @@ -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, }, #[command(about = "Update the Spotify installation managed by Spicetify")] Update { @@ -196,7 +200,9 @@ impl From 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, }) diff --git a/rust/crates/daemon/src/main.rs b/rust/crates/daemon/src/main.rs index b04dbb46f1..16b3f47acd 100644 --- a/rust/crates/daemon/src/main.rs +++ b/rust/crates/daemon/src/main.rs @@ -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; diff --git a/rust/crates/daemon/src/managed_spotify.rs b/rust/crates/daemon/src/managed_spotify.rs new file mode 100644 index 0000000000..0420d458d7 --- /dev/null +++ b/rust/crates/daemon/src/managed_spotify.rs @@ -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>, + shared: Arc, +} + +#[derive(Debug, Serialize)] +pub struct Snapshot { + installation: spotify::InstallationStatus, + job: Job, +} + +impl Handle { + pub fn new(shared: Arc) -> anyhow::Result { + 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 { + 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 { + 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 { .. } + )); + } +} diff --git a/rust/crates/daemon/src/routes.rs b/rust/crates/daemon/src/routes.rs index 7b92615fbd..8df20c33a5 100644 --- a/rust/crates/daemon/src/routes.rs +++ b/rust/crates/daemon/src/routes.rs @@ -108,6 +108,9 @@ pub fn build(state: Arc) -> Router { .route("/shutdown", post(shutdown_handler)) .route("/jobs/update-and-apply", get(update_job_status).post(update_job_admit)) .route("/jobs/update-and-apply/event", post(update_job_event)) + .route("/spotify", get(managed_spotify_status)) + .route("/spotify/check", post(managed_spotify_check)) + .route("/spotify/update", post(managed_spotify_update)) .route("/proxy", get(proxy::status)) .route("/proxy/", get(proxy::status)) .route("/proxy/{*url}", any(proxy::handler)) @@ -115,6 +118,72 @@ pub fn build(state: Arc) -> Router { .with_state(state) } +async fn managed_spotify_status( + State(state): State>, + headers: HeaderMap, +) -> axum::response::Response { + if !authorized(&state, &headers) { + return (StatusCode::FORBIDDEN, "invalid daemon token").into_response(); + } + #[cfg(target_os = "linux")] + return match tokio::task::spawn_blocking(move || state.managed_spotify.snapshot()).await { + Ok(Ok(snapshot)) => Json(snapshot).into_response(), + Ok(Err(error)) => ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("cannot read managed Spotify status: {error:#}"), + ) + .into_response(), + Err(error) => (StatusCode::INTERNAL_SERVER_ERROR, error.to_string()).into_response(), + }; + #[cfg(not(target_os = "linux"))] + Json(serde_json::json!({"installation":{"kind":"external"},"job":{"kind":"idle"}})) + .into_response() +} + +async fn managed_spotify_check( + State(state): State>, + headers: HeaderMap, +) -> axum::response::Response { + if !authorized(&state, &headers) { + return (StatusCode::FORBIDDEN, "invalid daemon token").into_response(); + } + #[cfg(target_os = "linux")] + return match tokio::task::spawn_blocking(move || { + let ctx = state.ctx.load_full(); + let _guard = spicetify::commands::guard::try_acquire(&ctx.config_root)?; + spicetify::commands::spotify::check_update(&ctx) + }) + .await + { + Ok(Ok(available)) => Json(available).into_response(), + Ok(Err(error)) => { + (StatusCode::CONFLICT, format!("cannot check Spotify packages: {error:#}")) + .into_response() + } + Err(error) => (StatusCode::INTERNAL_SERVER_ERROR, error.to_string()).into_response(), + }; + #[cfg(not(target_os = "linux"))] + (StatusCode::NOT_IMPLEMENTED, "managed Spotify packages require Linux").into_response() +} + +async fn managed_spotify_update( + State(state): State>, + headers: HeaderMap, +) -> axum::response::Response { + if !authorized(&state, &headers) { + return (StatusCode::FORBIDDEN, "invalid daemon token").into_response(); + } + #[cfg(target_os = "linux")] + return match tokio::task::spawn_blocking(move || state.managed_spotify.admit()).await { + Ok(Ok(admission)) => (StatusCode::ACCEPTED, Json(admission)).into_response(), + Ok(Err(error)) => (StatusCode::CONFLICT, format!("cannot start Spotify update: {error:#}")) + .into_response(), + Err(error) => (StatusCode::INTERNAL_SERVER_ERROR, error.to_string()).into_response(), + }; + #[cfg(not(target_os = "linux"))] + (StatusCode::NOT_IMPLEMENTED, "managed Spotify packages require Linux").into_response() +} + async fn update_job_status( State(state): State>, headers: HeaderMap, @@ -246,6 +315,11 @@ async fn shutdown_handler( tracing::warn!("shutdown request rejected: missing or invalid daemon token"); return (StatusCode::FORBIDDEN, "invalid daemon token".to_string()).into_response(); } + #[cfg(target_os = "linux")] + if state.managed_spotify.running() { + return (StatusCode::CONFLICT, "a managed Spotify update is in progress".to_string()) + .into_response(); + } tracing::info!("{}", spicetify::fl!("shutdown-requested")); state.shutdown.notify_waiters(); (StatusCode::ACCEPTED, spicetify::fl!("daemon-stopping-resp")).into_response() diff --git a/rust/crates/daemon/src/server.rs b/rust/crates/daemon/src/server.rs index e6bcfc3394..19dca6b4d4 100644 --- a/rust/crates/daemon/src/server.rs +++ b/rust/crates/daemon/src/server.rs @@ -26,6 +26,8 @@ pub struct DaemonState { pub apps_watcher_active: Arc, pub config_watcher_active: Arc, pub update_job: update_job::UpdateJobHandle, + #[cfg(target_os = "linux")] + pub managed_spotify: crate::managed_spotify::Handle, } pub fn run() -> anyhow::Result<()> { @@ -48,6 +50,8 @@ fn start(ctx: AppContext) -> anyhow::Result<()> { let config_watcher_active = Arc::new(AtomicBool::new(false)); let update_job = update_job::spawn(Arc::clone(&shared)); let state = Arc::new(DaemonState { + #[cfg(target_os = "linux")] + managed_spotify: crate::managed_spotify::Handle::new(Arc::clone(&shared))?, ctx: Arc::clone(&shared), client: spicetify::http::proxy_client()?, shutdown: Arc::clone(&shutdown), diff --git a/rust/crates/daemon/src/watcher.rs b/rust/crates/daemon/src/watcher.rs index e28501229b..e4efe9c412 100644 --- a/rust/crates/daemon/src/watcher.rs +++ b/rust/crates/daemon/src/watcher.rs @@ -34,6 +34,49 @@ pub fn spawn_apps_watcher( shutdown: Arc, active: Arc, update_job: UpdateJobHandle, +) -> Option> { + Some(tokio::spawn(async move { + let mut tick = tokio::time::interval(Duration::from_secs(1)); + loop { + let path = shared.load().spotify_apps_path(); + let stop = Arc::new(Notify::new()); + let cancelled = Arc::new(AtomicBool::new(false)); + let task = spawn_apps_watcher_at( + Arc::clone(&shared), + Arc::clone(&stop), + Arc::clone(&active), + update_job.clone(), + Arc::clone(&cancelled), + ); + let quitting = loop { + tokio::select! { + () = shutdown.notified() => break true, + _ = tick.tick() => { + if shared.load().spotify_apps_path() != path || task.as_ref().is_none_or(tokio::task::JoinHandle::is_finished) { + break false; + } + } + } + }; + cancelled.store(true, Ordering::Release); + stop.notify_one(); + if let Some(task) = task { + let _ = task.await; + } + active.store(false, Ordering::Release); + if quitting { + break; + } + } + })) +} + +fn spawn_apps_watcher_at( + shared: Arc, + shutdown: Arc, + active: Arc, + update_job: UpdateJobHandle, + cancelled: Arc, ) -> Option> { let apps = (*shared.load_full()).spotify_apps_path(); @@ -64,12 +107,15 @@ pub fn spawn_apps_watcher( let nth = applies; let update_job = update_job.clone(); let ctx = shared.load_full(); + let cancelled = Arc::clone(&cancelled); async move { if update_job.status().owns_recovery() { update_job.nudge_apps_changed(); return; } - let joined = tokio::task::spawn_blocking(move || auto_apply(&ctx, nth)).await; + let joined = + tokio::task::spawn_blocking(move || auto_apply(&ctx, nth, &cancelled)) + .await; if joined.is_err() { tracing::error!("auto-apply task panicked"); } @@ -83,7 +129,7 @@ pub fn spawn_apps_watcher( } /// One auto-apply attempt, ordered after the updater's own restart cycle. -fn auto_apply(ctx: &AppContext, nth: u32) { +fn auto_apply(ctx: &AppContext, nth: u32, cancelled: &AtomicBool) { // A stock archive means an update already landed. Repair is needed even // when update protection is blocked or cannot be determined. if !ctx.spotify_apps_path().join("xpui.spa").is_file() { @@ -109,6 +155,7 @@ fn auto_apply(ctx: &AppContext, nth: u32) { CLIENT_EXIT_CEILING, || ctx.spotify_apps_path().join("xpui.spa").is_file(), || spicetify::lifecycle::is_running(ctx), + cancelled, ) { Ok(Some(guard)) => guard, Ok(None) => { @@ -120,7 +167,7 @@ fn auto_apply(ctx: &AppContext, nth: u32) { return; } }; - if !ctx.spotify_apps_path().join("xpui.spa").is_file() { + if cancelled.load(Ordering::Acquire) || !ctx.spotify_apps_path().join("xpui.spa").is_file() { tracing::info!("stock xpui.spa is no longer present; skipping auto-apply"); return; } @@ -134,10 +181,15 @@ fn wait_for_idle_guard( timeout: Duration, mut repair_pending: impl FnMut() -> bool, mut is_running: impl FnMut() -> bool, + cancelled: &AtomicBool, ) -> anyhow::Result> { let deadline = std::time::Instant::now() + timeout; let mut waited = false; loop { + anyhow::ensure!( + !cancelled.load(Ordering::Acquire), + "Spotify watch path changed; cancelling the old repair" + ); if !repair_pending() { return Ok(None); } @@ -187,7 +239,10 @@ pub fn spawn_config_watcher( tracing::warn!("failed to create config watcher"); return None; }; - if watcher.watch(&config_file, RecursiveMode::NonRecursive).is_err() { + // Atomic config replacement removes the watched inode. Watch its parent + // so later updates are still observed after an installer activation. + let config_parent = config_file.parent()?; + if watcher.watch(config_parent, RecursiveMode::NonRecursive).is_err() { tracing::warn!("failed to watch config file"); return None; } @@ -321,6 +376,7 @@ mod tests { } false }, + &AtomicBool::new(false), ); drop(competing); drop(result?.expect("repair remains pending")); @@ -350,6 +406,7 @@ mod tests { _ => false, } }, + &AtomicBool::new(false), ); drop(result?.expect("repair remains pending")); assert!(checks >= 5, "a restart must resume waiting, not abandon the repair"); @@ -361,19 +418,82 @@ mod tests { fn auto_apply_wait_has_one_deadline_and_preserves_filesystem_errors() -> anyhow::Result<()> { let root = scratch("deadline")?; let guard = commands::guard::try_acquire(&root)?; - let error = wait_for_idle_guard(&root, Duration::from_millis(20), || true, || false) - .expect_err("operation remains locked"); + let error = wait_for_idle_guard( + &root, + Duration::from_millis(20), + || true, + || false, + &AtomicBool::new(false), + ) + .expect_err("the operation guard is still held"); assert!(error.to_string().contains("still busy")); drop(guard); let file = root.join("not-a-directory"); std::fs::write(&file, "sentinel")?; - let error = wait_for_idle_guard(&file, Duration::from_mins(1), || true, || false) - .expect_err("lock path is not a directory"); + let error = wait_for_idle_guard( + &file, + Duration::from_secs(60), + || true, + || false, + &AtomicBool::new(false), + ) + .expect_err("the config root is a file"); assert!(error.downcast_ref::().is_some()); std::fs::remove_dir_all(root)?; Ok(()) } + #[test] + fn path_change_cancels_a_wait_even_with_a_running_client() -> anyhow::Result<()> { + let root = scratch("cancel")?; + let result = wait_for_idle_guard( + &root, + Duration::from_secs(1800), + || true, + || true, + &AtomicBool::new(true), + ); + assert!(result.expect_err("cancelled repair").to_string().contains("watch path changed")); + drop(commands::guard::try_acquire(&root)?); + std::fs::remove_dir_all(root)?; + Ok(()) + } + + #[tokio::test] + async fn private_stop_is_retained_while_a_trigger_is_busy() { + let (tx, rx) = mpsc::unbounded_channel(); + let stop = Arc::new(Notify::new()); + let entered = Arc::new(Notify::new()); + let release = Arc::new(Notify::new()); + let task = tokio::spawn(run_loop( + rx, + |_| true, + { + let entered = Arc::clone(&entered); + let release = Arc::clone(&release); + move || { + let entered = Arc::clone(&entered); + let release = Arc::clone(&release); + async move { + entered.notify_one(); + release.notified().await; + } + } + }, + Arc::clone(&stop), + )); + tx.send(event()).expect("trigger"); + tokio::time::timeout(Duration::from_secs(3), entered.notified()) + .await + .expect("trigger started"); + stop.notify_one(); + release.notify_one(); + tokio::time::timeout(Duration::from_secs(3), task) + .await + .expect("watcher stopped") + .expect("watcher task"); + } + #[test] fn auto_apply_stops_polling_when_another_apply_consumes_the_archive() -> anyhow::Result<()> { let root = scratch("completed-elsewhere")?; @@ -389,6 +509,7 @@ mod tests { std::fs::remove_file(&archive).expect("another apply consumes the archive"); true }, + &AtomicBool::new(false), )?; assert!(result.is_none(), "completed repair must not wait for Spotify to exit"); assert_eq!(process_checks, 1, "polling must stop even though Spotify is still running"); diff --git a/rust/crates/spicetify/src/commands/apply.rs b/rust/crates/spicetify/src/commands/apply.rs index e487fa146e..9a5531b85c 100644 --- a/rust/crates/spicetify/src/commands/apply.rs +++ b/rust/crates/spicetify/src/commands/apply.rs @@ -524,7 +524,12 @@ fn stage_modules(ctx: &AppContext, dest: &Path) -> Result<()> { // here rather than leaving them silently unprotected. super::updates::reassert_block(ctx); - let updates_blocked = super::updates::is_blocked(ctx).unwrap_or(false); + let updates_blocked = super::updates::is_blocked(ctx).ok(); + #[cfg(target_os = "linux")] + let managed_spotify = + super::spotify::managed_channel(ctx)?.map(|channel| channel.name().to_string()); + #[cfg(not(target_os = "linux"))] + let managed_spotify = None; match crate::module::stage::stage_modules( &ctx.config_root, @@ -533,6 +538,7 @@ fn stage_modules(ctx: &AppContext, dest: &Path) -> Result<()> { &version, env!("CARGO_PKG_VERSION"), updates_blocked, + managed_spotify, ) { Ok(0) => { tracing::warn!("no modules staged: the client will boot without them"); diff --git a/rust/crates/spicetify/src/commands/spotify.rs b/rust/crates/spicetify/src/commands/spotify.rs index 701553dadd..8445ed26d1 100644 --- a/rust/crates/spicetify/src/commands/spotify.rs +++ b/rust/crates/spicetify/src/commands/spotify.rs @@ -23,7 +23,7 @@ pub enum Channel { } impl Channel { - fn name(self) -> &'static str { + pub(crate) fn name(self) -> &'static str { match self { Self::Stable => "stable", Self::Testing => "testing", @@ -33,7 +33,7 @@ impl Channel { #[derive(Debug, Clone, Copy)] pub enum Action { - Install(Channel), + Install(Option), Update(Option), Status, } @@ -76,15 +76,49 @@ fn installed(ctx: &AppContext) -> Result> { Ok(Some(serde_json::from_slice(&record)?)) } +pub(crate) fn managed_channel(ctx: &AppContext) -> Result> { + Ok(installed(ctx)?.map(|record| record.channel)) +} + pub fn run(ctx: &AppContext, action: &Action) -> Result<()> { ensure!(std::env::consts::ARCH == "x86_64", "Spotify's Linux package requires x86_64"); if matches!(action, Action::Status) { return status(ctx); } let guard = super::guard::try_acquire(&ctx.config_root)?; + run_inner(ctx, *action, &guard, true, &mut |_| Ok(())) +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum Phase { + Checking, + Downloading, + Preparing, + Activating, +} + +/// The daemon keeps running while it owns the update and follows the new config. +pub fn update_managed( + ctx: &AppContext, + guard: &super::guard::DisruptiveOperationGuard, + mut progress: impl FnMut(Phase) -> Result<()>, +) -> Result<()> { + run_inner(ctx, Action::Update(None), guard, false, &mut progress) +} + +fn run_inner( + ctx: &AppContext, + action: Action, + guard: &super::guard::DisruptiveOperationGuard, + manage_daemon: bool, + progress: &mut impl FnMut(Phase) -> Result<()>, +) -> Result<()> { + progress(Phase::Checking)?; let current = installed(ctx)?; let channel = match action { - Action::Install(channel) => *channel, + Action::Install(channel) => channel + .unwrap_or_else(|| current.as_ref().map_or(Channel::Stable, |record| record.channel)), Action::Update(channel) => { let current = current.as_ref().context( "Spotify is not managed by Spicetify; run `spicetify spotify install` first", @@ -105,6 +139,7 @@ pub fn run(ctx: &AppContext, action: &Action) -> Result<()> { && current.sha256 == package.sha256 && actual_version.as_deref() == Some(package.version.as_str()) { + repair_launchers(ctx)?; tracing::info!("Spotify {} is already installed", current.version); // A channel change can point at exactly the same package. let record = Installation { @@ -116,12 +151,14 @@ pub fn run(ctx: &AppContext, action: &Action) -> Result<()> { return Ok(()); } verify_support(ctx, &package.version)?; + progress(Phase::Downloading)?; let archive = download(&package)?; let versions = install_root()?.join("versions"); fs::create_dir_all(&versions)?; let candidate = versions.join(format!("{}-{}", package.version, nonce()?)); fs::create_dir(&candidate)?; let result = (|| { + progress(Phase::Preparing)?; tracing::info!("Preparing Spotify {} from the {} channel", package.version, channel.name()); extract_deb(&archive, &candidate)?; check_dependencies(&candidate)?; @@ -135,7 +172,13 @@ pub fn run(ctx: &AppContext, action: &Action) -> Result<()> { config.spotify_data_dir = Some(candidate.clone()); config.mirror = false; let next = AppContext::from_config(ctx.config_root.clone(), &config)?; - super::apply::prepare(&next, &guard)?; + let record = Installation { + version: package.version.clone(), + channel, + sha256: package.sha256.clone(), + }; + fs::write(candidate.join(RECORD), serde_json::to_vec_pretty(&record)?)?; + super::apply::prepare(&next, guard)?; let manifest: serde_json::Value = serde_json::from_slice(&fs::read(candidate.join("Apps/xpui/modules/manifest.json"))?)?; ensure!( @@ -144,10 +187,8 @@ pub fn run(ctx: &AppContext, action: &Action) -> Result<()> { == Some(version_line(&package.version)?.as_str()), "prepared client did not use a verified classmap for this Spotify version" ); - let record = - Installation { version: package.version.clone(), channel, sha256: package.sha256 }; - fs::write(candidate.join(RECORD), serde_json::to_vec_pretty(&record)?)?; - activate(ctx, &next, &config, &candidate)?; + progress(Phase::Activating)?; + activate(ctx, &next, &config, &candidate, manage_daemon)?; tracing::info!("Spotify {} installed at {}", package.version, candidate.display()); tracing::info!( "Use `spicetify spotify update` for future package updates. Native updater blocking remains a separate check." @@ -161,6 +202,48 @@ pub fn run(ctx: &AppContext, action: &Action) -> Result<()> { result } +#[derive(Debug, Clone, Serialize)] +#[serde(tag = "kind", rename_all = "kebab-case", rename_all_fields = "camelCase")] +pub enum InstallationStatus { + External, + Unavailable { message: String }, + Managed { version: String, channel: Channel, native_blocked: Option }, +} + +pub fn installation_status(ctx: &AppContext) -> Result { + Ok(match installed(ctx)? { + None => InstallationStatus::External, + Some(record) => InstallationStatus::Managed { + version: executable_version(&ctx.spotify_exec)?, + channel: record.channel, + native_blocked: super::updates::is_blocked(ctx).ok(), + }, + }) +} + +#[derive(Debug, Clone, Serialize)] +#[serde(tag = "kind", rename_all = "kebab-case")] +pub enum AvailableUpdate { + Current { version: String }, + Ready { version: String }, + Unavailable { version: String, message: String }, +} + +pub fn check_update(ctx: &AppContext) -> Result { + let current = installed(ctx)?.context("Spotify is not managed by Spicetify")?; + let package = latest(current.channel)?; + let actual = executable_version(&ctx.spotify_exec)?; + if version_parts(&package.version)? <= version_parts(&actual)? { + return Ok(AvailableUpdate::Current { version: package.version }); + } + Ok(match verify_support(ctx, &package.version) { + Ok(()) => AvailableUpdate::Ready { version: package.version }, + Err(error) => { + AvailableUpdate::Unavailable { version: package.version, message: format!("{error:#}") } + } + }) +} + fn status(ctx: &AppContext) -> Result<()> { let current = installed(ctx)?; if let Some(current) = ¤t { @@ -541,10 +624,18 @@ fn recovery_error( } } -fn activate(old: &AppContext, next: &AppContext, config: &Config, candidate: &Path) -> Result<()> { +fn activate( + old: &AppContext, + next: &AppContext, + config: &Config, + candidate: &Path, + manage_daemon: bool, +) -> Result<()> { let launcher = base_dirs()?.data_dir().join("applications/spotify.desktop"); let previous_config = read_optional(&old.config_file)?; let previous_launcher = read_optional(&launcher)?; + let terminal = terminal_launcher()?; + let previous_terminal = read_launcher_link(&terminal)?; let desktop = desktop_entry(&next.spotify_exec, &candidate.join("icons/spotify-linux-128.png"))?; let config_bytes = toml::to_string_pretty(config)?.into_bytes(); @@ -557,10 +648,12 @@ fn activate(old: &AppContext, next: &AppContext, config: &Config, candidate: &Pa let was_running = crate::lifecycle::is_running(old); let daemon_running = crate::daemon::is_daemon_running(); let daemon_installed = crate::daemon::DaemonManager::create().is_installed(); - super::daemon::stop()?; + if manage_daemon { + super::daemon::stop()?; + } let result = (|| { ensure!( - !crate::daemon::is_daemon_running(), + !manage_daemon || !crate::daemon::is_daemon_running(), "daemon did not stop before changing Spotify paths" ); crate::lifecycle::stop(old)?; @@ -568,6 +661,7 @@ fn activate(old: &AppContext, next: &AppContext, config: &Config, candidate: &Pa replace_and_activate( &[(&old.config_file, &config_bytes), (&launcher, desktop.as_bytes())], || { + replace_launcher_link(&terminal, Some(&next.spotify_exec))?; super::updates::reassert_block(next); if let Ok(blocked) = super::updates::is_blocked(next) { ensure!( @@ -591,17 +685,21 @@ fn activate(old: &AppContext, next: &AppContext, config: &Config, candidate: &Pa ("stop candidate", crate::lifecycle::stop(next)), ("restore configuration", restore_file(&old.config_file, previous_config.as_deref())), ("restore launcher", restore_file(&launcher, previous_launcher.as_deref())), + ( + "restore terminal launcher", + replace_launcher_link(&terminal, previous_terminal.as_deref()), + ), ( "restart previous client", if was_running { crate::lifecycle::start(old) } else { Ok(()) }, ), ( "restore daemon service", - if daemon_installed { super::daemon::install() } else { Ok(()) }, + if manage_daemon && daemon_installed { super::daemon::install() } else { Ok(()) }, ), ( "restart daemon", - if daemon_running && !crate::daemon::is_daemon_running() { + if manage_daemon && daemon_running && !crate::daemon::is_daemon_running() { super::daemon::start() } else { Ok(()) @@ -610,18 +708,133 @@ fn activate(old: &AppContext, next: &AppContext, config: &Config, candidate: &Pa ]; return Err(recovery_error(error, recovery)); } - super::apply::ensure_daemon(next); - crate::platform::register_url_scheme(); + if manage_daemon { + super::apply::ensure_daemon(next); + crate::platform::register_url_scheme(); + } if let Some(directory) = launcher.parent() { let _ = Command::new("update-desktop-database").arg(directory).output(); } + check_terminal_path(next); + Ok(()) +} + +fn terminal_launcher() -> Result { + Ok(base_dirs()?.home_dir().join(".local/bin/spotify")) +} + +fn read_launcher_link(path: &Path) -> Result> { + match fs::read_link(path) { + Ok(target) => Ok(Some(target)), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(error) => Err(error).with_context(|| format!("cannot replace {}; move the existing file aside to use the managed Spotify launcher", path.display())), + } +} + +fn replace_launcher_link(path: &Path, target: Option<&Path>) -> Result<()> { + let Some(target) = target else { return restore_file(path, None) }; + let parent = path.parent().context("launcher has no parent")?; + fs::create_dir_all(parent)?; + let temporary = parent.join(format!(".spotify-{}", nonce()?)); + std::os::unix::fs::symlink(target, &temporary)?; + if let Err(error) = fs::rename(&temporary, path) { + let _ = fs::remove_file(temporary); + return Err(error.into()); + } + Ok(()) +} + +fn repair_launchers(ctx: &AppContext) -> Result<()> { + let terminal = terminal_launcher()?; + let previous = read_launcher_link(&terminal)?; + let desktop = base_dirs()?.data_dir().join("applications/spotify.desktop"); + let content = desktop_entry( + &ctx.spotify_exec, + &ctx.spotify_data_dir.join("icons/spotify-linux-128.png"), + )?; + let result = replace_and_activate(&[(&desktop, content.as_bytes())], || { + replace_launcher_link(&terminal, Some(&ctx.spotify_exec)) + }); + if let Err(error) = result { + return Err(recovery_error( + error, + [("restore terminal launcher", replace_launcher_link(&terminal, previous.as_deref()))], + )); + } + check_terminal_path(ctx); Ok(()) } +fn terminal_path_selects(path: &std::ffi::OsStr, executable: &Path) -> bool { + use std::os::unix::fs::PermissionsExt; + let Ok(expected) = executable.canonicalize() else { return false }; + let selected = + std::env::split_paths(path).map(|directory| directory.join("spotify")).find(|candidate| { + fs::metadata(candidate).is_ok_and(|metadata| { + metadata.is_file() && metadata.permissions().mode() & 0o111 != 0 + }) + }); + selected.and_then(|candidate| candidate.canonicalize().ok()) == Some(expected) +} + +fn check_terminal_path(ctx: &AppContext) { + if !std::env::var_os("PATH").is_some_and(|path| terminal_path_selects(&path, &ctx.spotify_exec)) + { + tracing::warn!( + "The terminal command does not select managed Spotify. Put ~/.local/bin before other Spotify directories in your shell's PATH and reopen your terminal. The desktop launcher already selects the managed copy." + ); + } +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn terminal_launcher_switch_and_rollback_preserve_the_previous_target() { + let dir = Fixture::new(); + let launcher = dir.0.join("bin/spotify"); + let first = dir.0.join("old client/spotify"); + let second = dir.0.join("new client/spotify"); + assert!(read_launcher_link(&launcher).expect("missing launcher").is_none()); + replace_launcher_link(&launcher, Some(&first)).expect("initial launcher"); + let previous = read_launcher_link(&launcher).expect("snapshot"); + replace_launcher_link(&launcher, Some(&second)).expect("switch launcher"); + assert_eq!(fs::read_link(&launcher).expect("target"), second); + replace_launcher_link(&launcher, previous.as_deref()).expect("restore launcher"); + assert_eq!(fs::read_link(&launcher).expect("restored target"), first); + replace_launcher_link(&launcher, None).expect("remove new launcher"); + fs::write(&launcher, b"user script").expect("user launcher"); + assert!(read_launcher_link(&launcher).is_err()); + assert_eq!(fs::read(&launcher).expect("user file preserved"), b"user script"); + } + + #[test] + fn terminal_path_check_detects_missing_or_shadowed_managed_launchers() { + use std::os::unix::fs::PermissionsExt; + let dir = Fixture::new(); + let user = dir.0.join("user-bin"); + let system = dir.0.join("system-bin"); + fs::create_dir_all(&user).expect("user bin"); + fs::create_dir_all(&system).expect("system bin"); + for parent in [&user, &system] { + let executable = parent.join("spotify"); + fs::write(&executable, b"#!/bin/sh\nexit 0\n").expect("executable"); + fs::set_permissions(&executable, fs::Permissions::from_mode(0o755)) + .expect("permissions"); + } + let target = user.join("spotify"); + assert!(terminal_path_selects( + &std::env::join_paths([&user, &system]).expect("PATH"), + &target + )); + assert!(!terminal_path_selects( + &std::env::join_paths([&system, &user]).expect("PATH"), + &target + )); + assert!(!terminal_path_selects(&std::env::join_paths([&system]).expect("PATH"), &target)); + } + struct Fixture(PathBuf); impl Fixture { fn new() -> Self { diff --git a/rust/crates/spicetify/src/module/stage.rs b/rust/crates/spicetify/src/module/stage.rs index a331e365fd..a5082afca6 100644 --- a/rust/crates/spicetify/src/module/stage.rs +++ b/rust/crates/spicetify/src/module/stage.rs @@ -81,8 +81,11 @@ pub(crate) struct ModulesManifest { #[serde(skip_serializing_if = "Option::is_none")] pub supported_spotify: Option, pub cli_version: String, - /// Read by the manager module's Updates panel. - pub updates_blocked: bool, + /// None means native updater protection could not be determined. + #[serde(skip_serializing_if = "Option::is_none")] + pub updates_blocked: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub managed_spotify: Option, pub classmap_fallback: bool, pub classmap: serde_json::Value, pub modules: Vec, @@ -323,7 +326,8 @@ pub(crate) fn stage_modules( xpui: &Path, spotify_version: &str, cli_version: &str, - updates_blocked: bool, + updates_blocked: Option, + managed_spotify: Option, ) -> Result { if !modules_root.is_dir() { tracing::info!("no modules directory at {}: nothing to stage", modules_root.display()); @@ -419,6 +423,7 @@ pub(crate) fn stage_modules( supported_spotify: support.latest_spotify, cli_version: cli_version.to_string(), updates_blocked, + managed_spotify, classmap_fallback, classmap, modules: staged, @@ -522,7 +527,8 @@ mod tests { classmap_verified: true, supported_spotify: Some("1.2.97".to_string()), cli_version: "3.0.0-beta.6".to_string(), - updates_blocked: false, + updates_blocked: Some(false), + managed_spotify: None, classmap_fallback: false, classmap: serde_json::json!({}), modules: Vec::new(), @@ -533,6 +539,31 @@ mod tests { assert_eq!(json["supportedSpotify"], "1.2.97"); } + #[test] + fn manifest_preserves_unknown_allowed_and_blocked_update_states() { + for state in [None, Some(false), Some(true)] { + let manifest = ModulesManifest { + spotify_version: "1.2.96".to_string(), + classmap_key: "1020096".to_string(), + classmap_spotify: None, + classmap_verified: false, + supported_spotify: None, + cli_version: "3.0.0-beta.17".to_string(), + updates_blocked: state, + managed_spotify: Some("testing".into()), + classmap_fallback: false, + classmap: serde_json::json!({}), + modules: Vec::new(), + }; + let json = serde_json::to_value(manifest).expect("manifest serializes"); + assert_eq!(json.get("managedSpotify"), Some(&serde_json::json!("testing"))); + match state { + Some(blocked) => assert_eq!(json.get("updatesBlocked"), Some(&blocked.into())), + None => assert!(json.get("updatesBlocked").is_none()), + } + } + } + #[test] fn rewrites_references_to_quoted_leaves() { let out = remap_source("const a = MAP.main.navbar.link;", &classmap(), &BTreeSet::new()) diff --git a/rust/crates/spicetify/src/process.rs b/rust/crates/spicetify/src/process.rs index 0a570f99da..1576fe2035 100644 --- a/rust/crates/spicetify/src/process.rs +++ b/rust/crates/spicetify/src/process.rs @@ -54,15 +54,29 @@ pub(crate) fn process_running(name: &str) -> bool { #[cfg(target_os = "linux")] fn linux_process_alive(pid: u32) -> bool { // pgrep includes unreaped children. They cannot exit again or serve a client. - std::fs::read_to_string(format!("/proc/{pid}/stat")).is_ok_and(|stat| { + let alive = std::fs::read_to_string(format!("/proc/{pid}/stat")).is_ok_and(|stat| { stat.rsplit_once(')') .and_then(|(_, rest)| rest.split_whitespace().next()) .is_some_and(|state| state != "Z" && state != "X") - }) + }); + alive + && std::fs::read(format!("/proc/{pid}/cmdline")).is_ok_and(|args| !is_version_probe(&args)) +} + +#[cfg(target_os = "linux")] +fn is_version_probe(args: &[u8]) -> bool { + args.split(|byte| *byte == 0).skip(1).any(|arg| arg == b"--version") } #[cfg(all(test, target_os = "linux"))] mod linux_tests { + #[test] + fn version_probes_do_not_count_as_a_running_client() { + assert!(super::is_version_probe(b"/opt/spotify/spotify\0--version\0")); + assert!(!super::is_version_probe(b"/opt/spotify/spotify\0--remote-debugging-port=9229\0")); + assert!(!super::is_version_probe(b"/opt/spotify/spotify\0spotify:track:version\0")); + } + #[test] fn an_unreaped_child_is_not_a_running_client() { let mut child = std::process::Command::new("sleep").arg("30").spawn().unwrap(); diff --git a/src/jsHelper/modularLoader/types.ts b/src/jsHelper/modularLoader/types.ts index e40a0e02d8..819152a8d8 100644 --- a/src/jsHelper/modularLoader/types.ts +++ b/src/jsHelper/modularLoader/types.ts @@ -39,6 +39,7 @@ export interface ModulesManifest { // Apply-time environment facts (absent in manifests from older CLIs). cliVersion?: string; updatesBlocked?: boolean; + managedSpotify?: "stable" | "testing"; classmapSpotify?: string; classmapVerified?: boolean; supportedSpotify?: string; diff --git a/src/jsHelper/spicetifyWrapper/platform.js b/src/jsHelper/spicetifyWrapper/platform.js index b540f781ee..951c7b5244 100644 --- a/src/jsHelper/spicetifyWrapper/platform.js +++ b/src/jsHelper/spicetifyWrapper/platform.js @@ -10,6 +10,7 @@ import { uninstallStaged, updateAndApplySupported as daemonUpdateAndApplySupported, } from "./shared/daemonRpc.js"; +import { managedSpotify } from "./shared/managedSpotify.js"; import { installUpdateJobBridge, updateAndApply, updateApiSupported } from "./shared/updateAndApply.js"; const updateAndApplySupported = async () => { @@ -36,6 +37,7 @@ Object.assign(Spicetify.Daemon, { uninstallStaged, updateAndApplySupported, updateAndApply, + managedSpotify, }); installUpdateJobBridge(() => Spicetify.Platform); diff --git a/src/jsHelper/spicetifyWrapper/shared/managedSpotify.js b/src/jsHelper/spicetifyWrapper/shared/managedSpotify.js new file mode 100644 index 0000000000..5974da20cd --- /dev/null +++ b/src/jsHelper/spicetifyWrapper/shared/managedSpotify.js @@ -0,0 +1,20 @@ +const ROOT = "http://127.0.0.1:7967/spotify"; + +const request = async (suffix = "", method = "GET") => { + const token = globalThis.__SPICETIFY_DAEMON_TOKEN__; + if (!token) throw new Error("no daemon token: apply Spicetify first"); + const response = await fetch(`${ROOT}${suffix}`, { + method, + headers: { "x-spicetify-token": token }, + signal: AbortSignal.timeout(suffix === "/check" ? 90000 : 15000), + }); + if (response.status === 404 && method === "GET") return null; + if (!response.ok) throw new Error((await response.text()).trim() || "Spotify package request failed"); + return response.json(); +}; + +export const managedSpotify = { + status: () => request(), + check: () => request("/check", "POST"), + update: () => request("/update", "POST"), +}; diff --git a/src/jsHelper/spicetifyWrapper/shared/managedSpotify.test.mts b/src/jsHelper/spicetifyWrapper/shared/managedSpotify.test.mts new file mode 100644 index 0000000000..9f7c900dcd --- /dev/null +++ b/src/jsHelper/spicetifyWrapper/shared/managedSpotify.test.mts @@ -0,0 +1,41 @@ +import assert from "node:assert/strict"; +import { afterEach, test } from "node:test"; + +import { managedSpotify } from "./managedSpotify.js"; + +const originalFetch = globalThis.fetch; +afterEach(() => { + globalThis.fetch = originalFetch; + delete globalThis.__SPICETIFY_DAEMON_TOKEN__; +}); + +test("managed package calls authenticate and never use Spotify's native updater", async () => { + globalThis.__SPICETIFY_DAEMON_TOKEN__ = "test-token"; + const calls = []; + globalThis.fetch = async (url, options) => { + calls.push([String(url), options.method, options.headers["x-spicetify-token"]]); + return Response.json({ kind: "current", version: "1.2.96" }); + }; + await managedSpotify.status(); + await managedSpotify.check(); + await managedSpotify.update(); + assert.deepEqual(calls, [ + ["http://127.0.0.1:7967/spotify", "GET", "test-token"], + ["http://127.0.0.1:7967/spotify/check", "POST", "test-token"], + ["http://127.0.0.1:7967/spotify/update", "POST", "test-token"], + ]); +}); + +test("missing daemon capability degrades only the status probe", async () => { + globalThis.__SPICETIFY_DAEMON_TOKEN__ = "test-token"; + globalThis.fetch = async () => new Response("unsupported", { status: 404 }); + assert.equal(await managedSpotify.status(), null); + await assert.rejects(managedSpotify.update(), /unsupported/); +}); + +test("a missing token never sends an update request", async () => { + globalThis.fetch = async () => { + throw new Error("must not fetch"); + }; + await assert.rejects(managedSpotify.update(), /no daemon token/); +});