From 64ecd938352f16f9ded2c620725b01747eb1bd05 Mon Sep 17 00:00:00 2001 From: Afonso Jorge Ramos Date: Thu, 17 Sep 2026 02:24:23 +0200 Subject: [PATCH 01/19] feat(rust): manage Linux Spotify updates from the client (#3953) --- 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/); +}); From b50800b2d225ca4cc09d83b9e785723c88b9d8af Mon Sep 17 00:00:00 2001 From: Afonso Jorge Ramos Date: Thu, 17 Sep 2026 02:46:09 +0200 Subject: [PATCH 02/19] fix(rust): make Windows release test sockets blocking (#3954) --- rust/Cargo.lock | 8 ++++---- rust/Cargo.toml | 2 +- rust/crates/spicetify/src/module/remote.rs | 1 + 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 7fe2d2fb32..cafecf9e4a 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -433,7 +433,7 @@ checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" [[package]] name = "cli" -version = "3.0.0-beta.18" +version = "3.0.0-beta.19" dependencies = [ "anyhow", "clap", @@ -662,7 +662,7 @@ dependencies = [ [[package]] name = "daemon" -version = "3.0.0-beta.18" +version = "3.0.0-beta.19" dependencies = [ "anyhow", "axum", @@ -3117,7 +3117,7 @@ dependencies = [ [[package]] name = "spicetify" -version = "3.0.0-beta.18" +version = "3.0.0-beta.19" dependencies = [ "aho-corasick", "anyhow", @@ -3717,7 +3717,7 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "tui" -version = "3.0.0-beta.18" +version = "3.0.0-beta.19" dependencies = [ "anyhow", "crossterm", diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 4eb800a3ce..12a2992bd9 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.18" +version = "3.0.0-beta.19" edition = "2024" rust-version = "1.95" repository = "https://github.com/veryboringhwl/app" diff --git a/rust/crates/spicetify/src/module/remote.rs b/rust/crates/spicetify/src/module/remote.rs index 28cd3fe713..3b4db04056 100644 --- a/rust/crates/spicetify/src/module/remote.rs +++ b/rust/crates/spicetify/src/module/remote.rs @@ -372,6 +372,7 @@ mod tests { Err(e) => unreachable!("fixture accept failed: {e}"), } }; + stream.set_nonblocking(false).expect("blocking fixture stream"); 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") { From d1be9e918bf599c0cb1d88ff55d46feb6e053f80 Mon Sep 17 00:00:00 2001 From: Mralimoh Date: Thu, 17 Sep 2026 04:25:07 +0330 Subject: [PATCH 03/19] feat(pkg): add update subcommand for modules --- rust/crates/cli/src/main.rs | 6 ++ rust/crates/spicetify/src/commands/mod.rs | 3 + rust/crates/spicetify/src/commands/pkg.rs | 94 +++++++++++++++++++++++ 3 files changed, 103 insertions(+) diff --git a/rust/crates/cli/src/main.rs b/rust/crates/cli/src/main.rs index 66f4381eba..4021d74c16 100644 --- a/rust/crates/cli/src/main.rs +++ b/rust/crates/cli/src/main.rs @@ -164,6 +164,11 @@ enum CliPkgAction { Delete { id: String }, #[command(about = "Enable a package")] Enable { id: String }, + #[command(about = "Update installed modules")] + Update { + #[arg(help = "Module identifier to update (updates all if omitted)")] + id: Option, + }, } impl From for Command { @@ -224,6 +229,7 @@ impl From for PkgAction { CliPkgAction::Install { id, url } => PkgAction::Install { id, url }, CliPkgAction::Delete { id } => PkgAction::Delete { id }, CliPkgAction::Enable { id } => PkgAction::Enable { id }, + CliPkgAction::Update { id } => PkgAction::Update { id }, } } } diff --git a/rust/crates/spicetify/src/commands/mod.rs b/rust/crates/spicetify/src/commands/mod.rs index 9b0a1f9517..241de541f1 100644 --- a/rust/crates/spicetify/src/commands/mod.rs +++ b/rust/crates/spicetify/src/commands/mod.rs @@ -65,6 +65,7 @@ pub enum PkgAction { Install { id: String, url: Option }, Delete { id: String }, Enable { id: String }, + Update { id: Option }, } pub fn dispatch(cmd: &Command, ctx: &AppContext) -> Result<()> { @@ -112,6 +113,7 @@ pub fn dispatch(cmd: &Command, ctx: &AppContext) -> Result<()> { ), PkgAction::Delete { id } => crate::module::delete_module(&ctx.config_root, id), PkgAction::Enable { id } => crate::module::enable_module(&ctx.config_root, id), + PkgAction::Update { id } => pkg::update(ctx, id.as_deref()), } } Command::Protocol(uri) => protocol::run(ctx, uri), @@ -155,6 +157,7 @@ mod tests { PkgAction::Install { id: "module@1".to_string(), url: None }, PkgAction::Delete { id: "module@1".to_string() }, PkgAction::Enable { id: "module@1".to_string() }, + PkgAction::Update { id: Some("module@1".to_string()) }, ] { let error = dispatch(&Command::Pkg(action), &ctx).expect_err("competing mutation"); assert!(error.to_string().contains("already in progress"), "{error}"); diff --git a/rust/crates/spicetify/src/commands/pkg.rs b/rust/crates/spicetify/src/commands/pkg.rs index d1b9bbd0f6..d685399de7 100644 --- a/rust/crates/spicetify/src/commands/pkg.rs +++ b/rust/crates/spicetify/src/commands/pkg.rs @@ -356,6 +356,100 @@ pub(crate) fn install(ctx: &AppContext, identifier: &str) -> Result<()> { ) } +pub(crate) fn update(ctx: &AppContext, target_id: Option<&str>) -> Result<()> { + let vault = cached_vault(&ctx.config_root)?; + let paths = crate::module::ModulePaths::from_config_root(&ctx.config_root); + let installed_mods = installed(&ctx.config_root); + + if installed_mods.is_empty() { + tracing::info!("no modules installed"); + return Ok(()); + } + + if let Some(target) = target_id { + let Some((_, current_version)) = installed_mods.iter().find(|(id, _)| id == target) else { + anyhow::bail!("module '{target}' is not installed"); + }; + + let Some(module) = vault.modules.get(target) else { + anyhow::bail!("module '{target}' is not in the vault"); + }; + + let target_version = resolve_version(module)?; + if !should_stage(true, Some(current_version), &target_version, !module.enabled.is_empty()) { + tracing::info!("{target} is already up to date ({current_version})"); + return Ok(()); + } + + let Some(entry) = module.v.get(&target_version) else { + anyhow::bail!("{target}@{target_version} is not in the vault"); + }; + + update_single_module(ctx, &paths, target, current_version, &target_version, entry)?; + } else { + let mut updated_count = 0; + for (id, current_version) in &installed_mods { + let Some(module) = vault.modules.get(id) else { + continue; + }; + + let Ok(target_version) = resolve_version(module) else { + continue; + }; + + if should_stage(true, Some(current_version), &target_version, !module.enabled.is_empty()) + && let Some(entry) = module.v.get(&target_version) + { + tracing::info!("updating {id}: {current_version} -> {target_version}"); + if let Err(e) = update_single_module(ctx, &paths, id, current_version, &target_version, entry) { + tracing::warn!("failed to update {id}: {e}"); + } else { + updated_count += 1; + } + } + } + + if updated_count == 0 { + tracing::info!("all modules are up to date"); + } else { + tracing::info!("successfully updated {updated_count} module(s)"); + } + } + + Ok(()) +} + +fn update_single_module( + ctx: &AppContext, + paths: &crate::module::ModulePaths, + id: &str, + old_version: &str, + new_version: &str, + entry: &VaultVersion, +) -> Result<()> { + if entry.artifacts.is_empty() { + anyhow::bail!("{id}@{new_version} has no artifacts"); + } + + let tag = format!("{id}@{new_version}"); + crate::module::install_from_vault( + &ctx.config_root, + &tag, + entry.artifacts.clone(), + entry.checksum.clone(), + )?; + crate::module::enable_module(&ctx.config_root, &tag)?; + tracing::info!("updated {id} to {new_version}"); + + if old_version != new_version + && let Ok(superseded) = crate::module::vault::StoreIdentifier::parse(&format!("{id}@{old_version}")) + { + let _ = crate::module::delete(paths, &superseded); + } + + Ok(()) +} + #[cfg(test)] mod tests { use super::*; From b6828954905821e1f418296202b5b02ed229cefa Mon Sep 17 00:00:00 2001 From: Afonso Jorge Ramos Date: Tue, 29 Sep 2026 12:43:16 +0200 Subject: [PATCH 04/19] fix(rust): preserve native ARM64 and x64 Spotify on Windows (#3969) --- .github/workflows/rust-release.yml | 10 ++ .github/workflows/rust.yml | 26 ++- docs/windows-arm64-verification.md | 77 +++++++++ install.ps1 | 161 ++++++++++++++++-- rust/crates/cli/src/main.rs | 13 ++ rust/crates/spicetify/Cargo.toml | 2 +- rust/crates/spicetify/src/commands/mod.rs | 2 +- .../spicetify/src/commands/self_update.rs | 14 +- rust/crates/spicetify/src/update/mod.rs | 83 +++++++-- rust/crates/spicetify/src/update/release.rs | 132 +++++++++++++- scripts/setup-windows-arm64.ps1 | 15 ++ scripts/test-install-architecture.ps1 | 91 ++++++++++ 12 files changed, 588 insertions(+), 38 deletions(-) create mode 100644 docs/windows-arm64-verification.md create mode 100644 scripts/setup-windows-arm64.ps1 create mode 100644 scripts/test-install-architecture.ps1 diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml index 1d73b6617c..fb18ecd61a 100644 --- a/.github/workflows/rust-release.yml +++ b/.github/workflows/rust-release.yml @@ -38,6 +38,10 @@ jobs: target: x86_64-pc-windows-msvc asset_os: windows asset_arch: x86_64 + - os: windows-11-arm + target: aarch64-pc-windows-msvc + asset_os: windows + asset_arch: aarch64 steps: # A dispatch-supplied tag reaches checkout's ref, so it is constrained to # the shape this workflow publishes before anything acts on it. @@ -57,6 +61,12 @@ jobs: with: ref: ${{ inputs.tag || github.ref }} + - name: Configure native Windows ARM64 toolchain + if: runner.os == 'Windows' && runner.arch == 'ARM64' + shell: pwsh + working-directory: . + run: ./scripts/setup-windows-arm64.ps1 + - name: Setup Node and pnpm uses: jdx/mise-action@7e36c90d9ab29c415a2384db3006f3ec8a8cc654 # v4.2.4 diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 74eabac913..77417570ef 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -11,7 +11,10 @@ on: - "src/jsHelper/**" - "install.sh" - "install.ps1" - - "scripts/test-install-completions.*" + - "scripts/test-install-*.ps1" + - "scripts/test-install-completions.sh" + - "scripts/setup-windows-arm64.ps1" + - ".github/workflows/rust-release.yml" - "scripts/build-payload.mjs" - "package.json" - "pnpm-lock.yaml" @@ -26,7 +29,10 @@ on: - "src/jsHelper/**" - "install.sh" - "install.ps1" - - "scripts/test-install-completions.*" + - "scripts/test-install-*.ps1" + - "scripts/test-install-completions.sh" + - "scripts/setup-windows-arm64.ps1" + - ".github/workflows/rust-release.yml" - "scripts/build-payload.mjs" - "package.json" - "pnpm-lock.yaml" @@ -42,7 +48,7 @@ jobs: strategy: fail-fast: false matrix: - os: [macos-latest, ubuntu-latest, windows-latest] + os: [macos-latest, ubuntu-latest, windows-latest, windows-11-arm] defaults: run: working-directory: rust @@ -50,6 +56,12 @@ jobs: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Configure native Windows ARM64 toolchain + if: runner.os == 'Windows' && runner.arch == 'ARM64' + shell: pwsh + working-directory: . + run: ./scripts/setup-windows-arm64.ps1 + - name: Show toolchain run: rustup show active-toolchain @@ -62,7 +74,13 @@ jobs: if: runner.os == 'Windows' working-directory: . shell: pwsh - run: ./scripts/test-install-completions.ps1 + run: | + ./scripts/test-install-completions.ps1 + ./scripts/test-install-architecture.ps1 + & "$env:WINDIR\System32\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -ExecutionPolicy Bypass -File ./scripts/test-install-architecture.ps1 + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + & "$env:WINDIR\SysWOW64\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -ExecutionPolicy Bypass -File ./scripts/test-install-architecture.ps1 + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - name: Setup Node and pnpm uses: jdx/mise-action@7e36c90d9ab29c415a2384db3006f3ec8a8cc654 # v4.2.4 diff --git a/docs/windows-arm64-verification.md b/docs/windows-arm64-verification.md new file mode 100644 index 0000000000..1335b64995 --- /dev/null +++ b/docs/windows-arm64-verification.md @@ -0,0 +1,77 @@ +# Windows ARM64 installation verification, September 29, 2026 + +The installer selects a matching CLI and daemon for native ARM64 Spotify and +preserves x64 Spotify installations on ARM64 Windows. The native Windows +installation and daemon recovery checks passed with a local release package. +Published ARM64 release downloads remain unverified. + +## Environment and artifacts + +The native runtime checks used this configuration: + +- Windows 11 Pro ARM64, build 26100, in VMware Fusion. +- Desktop Spotify 1.3.1.234, executable machine `0xAA64`. +- Source `2409117615efa9c82341c5aadac6753a60122ee4`, based on beta.19 + (`b50800b2d225ca4cc09d83b9e785723c88b9d8af`). +- Rust 1.98.1, native ARM64 MSVC toolchain, Visual Studio Build Tools + 17.14.41, MSVC 14.44.35207, and clang-cl 19.1.5. +- Optimized CLI and daemon built together after rebuilding the embedded payload. +- Installed CLI: `C:\Users\test\AppData\Local\spicetify\spicetify.exe`. +- Both installed binaries have PE machine `0xAA64` and report beta.19. + The running daemon version was read from `/health`. + +The installed CLI SHA256 is +`A66F66D9CE6515D4835BAC5411D75A83ACED2273CAC739120E1277F0F6D9A0E4`. +The daemon SHA256 is +`452D5AEAC67E5DFF80D4DAD28BB54C7E52B3CEE92D5B1F3A2B8E53E21BF23FD5`. +These are local builds, not published beta.19 assets. + +## Installation and lifecycle results + +The full installer ran in the regular interactive desktop session. Only the +release archive and checksum responses were replaced with local fixtures. +Checksum verification, extraction, architecture selection, daemon shutdown, +binary replacement, shell completion, and Apply ran through the installer. + +| Check | Result | +| --- | --- | +| Native Windows PowerShell 5.1 and 32-bit PowerShell | ARM64 host detected in both; installer regression tests passed | +| Native Spotify | Selected ARM64; installed pair hashes match the optimized build | +| Configured x64 executable fixture | Actual staged ARM64 CLI selected x64; installer fetched the same version's x64 archive and installed both matching binaries | +| Store alias fixture | Unreadable-as-PE alias fell back to the configured data directory's real x64 executable | +| Missing second binary during replacement | Original CLI and daemon restored | +| Locked installed CLI | Replacement rejected; original pair preserved | +| Invalid download checksum | Installation rejected before replacing binaries | +| Enabled daemon stopped before same-version self-update | Real `self-update` restarted it and preserved HKCU Run registration | +| Intentionally disabled daemon | Real `self-update` left it stopped and unregistered | +| Final Apply | Restored normal autostart and URL registration; Spotify restarted normally | + +Installer replacement changed the interactive daemon from PID 1904 to 7460. +After the stop/recovery tests and final Apply, PID 4712 ran in session 2 with +both watchers active. The HKCU Run entry points to the installed daemon; +`spicetify://` points to the installed CLI. + +Native caption buttons were hidden in the running client. The subsequent +visual pass found that the module still reserved space at the right edge on +Spotify 1.3.1. That CSS selector issue belongs to the modules repository and is +separate from native helper architecture compatibility. + +## Build checks and remaining coverage + +The macOS workspace suite passed 180 tests, with three ignored. The repository's +CI lint command, `cargo clippy --workspace --locked -- -D warnings`, passed. +A broader `--all-targets` lint run reports test-only lint failures and does not +pass. Nine native Windows update tests and the optimized CLI/daemon build +passed. The ARM64 setup script successfully exported MSVC build variables and +selected the native Rust host in the VM. + +The live VM retained a local CSS-map override for the independent fallback +styling fix. Spotify 1.3.1 used the published 1.3.0 classmap fallback. These +substitutions do not establish full classmap compatibility. + +The x64 compatibility checks use real x64 PE binaries as fixtures; an actual +x64 Spotify installation was not run. The same-version release-selection test +covers architecture migration, but a complete old published x64 updater to new +published ARM64 release transaction still requires the release assets to exist. +Hosted ARM64 CI and release packaging must pass before release readiness can be +claimed. This work does not enable experimental Windows Spotify updates. diff --git a/install.ps1 b/install.ps1 index ee6368688b..d200ad36e2 100644 --- a/install.ps1 +++ b/install.ps1 @@ -60,20 +60,47 @@ function Move-OldSpicetifyFolder { } } +function Get-SpicetifyNativeMachine { + if (-not ('Spicetify.NativeArchitecture' -as [type])) { + Add-Type -TypeDefinition @' +using System; +using System.Runtime.InteropServices; +namespace Spicetify { + public static class NativeArchitecture { + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + public static extern bool IsWow64Process2(IntPtr process, out ushort processMachine, out ushort nativeMachine); + } +} +'@ + } + [System.UInt16]$processMachine = 0 + [System.UInt16]$nativeMachine = 0 + if (-not [Spicetify.NativeArchitecture]::IsWow64Process2( + [System.Diagnostics.Process]::GetCurrentProcess().Handle, + [ref]$processMachine, [ref]$nativeMachine)) { + throw 'Cannot determine the native Windows architecture.' + } + $nativeMachine +} + +function Get-SpicetifyArchitecture { + switch (Get-SpicetifyNativeMachine) { + 0xAA64 { return 'aarch64' } + 0x8664 { return 'x86_64' } + default { throw 'Spicetify v3 requires Windows x64 or ARM64.' } + } +} + function Get-Spicetify { [CmdletBinding()] - param () + param ( + [ValidateSet('', 'aarch64', 'x86_64')][string]$RequestedArchitecture = '', + [string]$RequestedVersion = '' + ) begin { if ($v3) { - # v3 asset names come from the Rust CLI's own target triple, so that - # `spicetify self-update` resolves the same file this script installs. - # Only x86_64 is built today. - if ($env:PROCESSOR_ARCHITECTURE -ne 'AMD64') { - Write-Warning -Message "v3 has no build for $env:PROCESSOR_ARCHITECTURE yet. Windows x86_64 is available." - Pause - exit - } - $architecture = 'x86_64' + $architecture = if ($RequestedArchitecture) { $RequestedArchitecture } else { Get-SpicetifyArchitecture } } elseif ($env:PROCESSOR_ARCHITECTURE -eq 'AMD64') { $architecture = 'x64' @@ -84,7 +111,10 @@ function Get-Spicetify { else { $architecture = 'x32' } - if ($v) { + if ($RequestedVersion) { + $targetVersion = $RequestedVersion + } + elseif ($v) { if ($v -match '^\d+\.\d+\.\d+') { $targetVersion = $v } @@ -125,6 +155,13 @@ function Get-Spicetify { OutFile = $archivePath } Invoke-WebRequest @Parameters + if ($v3) { + $checksum = (Invoke-RestMethod -Uri "$($Parameters.Uri).sha256").Trim().Split()[0] + if ($checksum -notmatch '^[0-9a-fA-F]{64}$' -or + (Get-FileHash -LiteralPath $archivePath -Algorithm SHA256).Hash -ne $checksum) { + throw 'The downloaded Spicetify archive failed checksum verification.' + } + } Write-Success } end { @@ -187,6 +224,56 @@ function Add-SpicetifyCompletion { } } +function Expand-SpicetifyPackage { + param([string]$Archive, [string]$Destination) + foreach ($name in @('spicetify.exe', 'spicetify-daemon.exe')) { + Remove-Item -LiteralPath (Join-Path $Destination $name) -Force -ErrorAction SilentlyContinue + } + Expand-Archive -Path $Archive -DestinationPath $Destination -Force + foreach ($name in @('spicetify.exe', 'spicetify-daemon.exe')) { + if (-not (Test-Path -LiteralPath (Join-Path $Destination $name) -PathType Leaf)) { + throw "The release archive is missing $name." + } + } +} + +function Install-SpicetifyBinaries { + param([string]$Source, [string]$Destination) + $names = @('spicetify.exe', 'spicetify-daemon.exe') + $backup = Join-Path $Destination ".install-backup-$([guid]::NewGuid())" + $saved = @() + $installed = @() + New-Item -ItemType Directory -Path $backup -Force | Out-Null + try { + foreach ($name in $names) { + $target = Join-Path $Destination $name + if (Test-Path -LiteralPath $target) { + Move-Item -LiteralPath $target -Destination (Join-Path $backup $name) + $saved += $name + } + # Record the target before copying, so rollback also removes a partial copy. + $installed += $name + Copy-Item -LiteralPath (Join-Path $Source $name) -Destination $target + } + } + catch { + foreach ($name in $installed) { + Remove-Item -LiteralPath (Join-Path $Destination $name) -Force -ErrorAction SilentlyContinue + } + foreach ($name in $saved) { + Move-Item -LiteralPath (Join-Path $backup $name) -Destination (Join-Path $Destination $name) -Force + } + throw + } + finally { + # Preserve originals if any rollback operation could not restore them. + if (-not (Get-ChildItem -LiteralPath $backup -ErrorAction SilentlyContinue)) { + Remove-Item -LiteralPath $backup -Force -ErrorAction SilentlyContinue + } + } + Remove-Item -LiteralPath $backup -Recurse -Force -ErrorAction SilentlyContinue +} + function Install-Spicetify { [CmdletBinding()] param () @@ -196,7 +283,57 @@ function Install-Spicetify { process { $archivePath = Get-Spicetify Write-Host -Object 'Extracting spicetify...' -NoNewline - Expand-Archive -Path $archivePath -DestinationPath $spicetifyFolderPath -Force + if ($v3) { + $staging = Join-Path ([System.IO.Path]::GetTempPath()) "spicetify-install-$([guid]::NewGuid())" + try { + Expand-SpicetifyPackage -Archive $archivePath -Destination $staging + if ((Get-SpicetifyArchitecture) -eq 'aarch64') { + # Let the staged CLI resolve configured, desktop, and Store Spotify paths. + $stagedCli = Join-Path $staging 'spicetify.exe' + $wanted = (& $stagedCli --print-install-architecture | Out-String).Trim() + if ($LASTEXITCODE -ne 0 -or $wanted -notin @('aarch64', 'x86_64')) { + throw 'Cannot determine the Spicetify build required by Spotify.' + } + if ($wanted -eq 'x86_64') { + $versionOutput = (& $stagedCli --version | Out-String).Trim() + if ($LASTEXITCODE -ne 0 -or $versionOutput -notmatch '^spicetify (\S+)$') { + throw 'Cannot determine the staged Spicetify version.' + } + $archivePath = Get-Spicetify -RequestedArchitecture $wanted -RequestedVersion $Matches[1] + Expand-SpicetifyPackage -Archive $archivePath -Destination $staging + } + } + $daemonPath = Join-Path $spicetifyFolderPath 'spicetify-daemon.exe' + # 32-bit PowerShell cannot read Get-Process.Path for a 64-bit daemon. + $running = @(Get-CimInstance Win32_Process -Filter "Name='spicetify-daemon.exe'" | + Where-Object { $_.ExecutablePath -eq $daemonPath } | + ForEach-Object { Get-Process -Id $_.ProcessId -ErrorAction SilentlyContinue }) + if ($running.Count) { + $token = (Get-Content -LiteralPath (Join-Path $spicetifyFolderPath 'daemon-token') -Raw).Trim() + Invoke-RestMethod -Uri 'http://127.0.0.1:7967/shutdown' -Method Post -TimeoutSec 5 -Headers @{ + 'x-spicetify-token' = $token + } | Out-Null + $running | Wait-Process -Timeout 10 + } + New-Item -ItemType Directory -Path $spicetifyFolderPath -Force | Out-Null + try { + Install-SpicetifyBinaries -Source $staging -Destination $spicetifyFolderPath + } + catch { + if ($running.Count) { Start-Process -FilePath $daemonPath } + throw + } + if ($running.Count) { + Start-Process -FilePath $daemonPath + } + } + finally { + Remove-Item -LiteralPath $staging -Recurse -Force -ErrorAction SilentlyContinue + } + } + else { + Expand-Archive -Path $archivePath -DestinationPath $spicetifyFolderPath -Force + } Write-Success Add-SpicetifyToPath } diff --git a/rust/crates/cli/src/main.rs b/rust/crates/cli/src/main.rs index 65f57e4b9f..9e38ffcdaf 100644 --- a/rust/crates/cli/src/main.rs +++ b/rust/crates/cli/src/main.rs @@ -25,6 +25,8 @@ struct SpicetifyCli { #[arg(long, global = true)] offline_bnk_dir: Option, + #[arg(long, hide = true)] + print_install_architecture: bool, #[command(subcommand)] command: Option, } @@ -250,6 +252,17 @@ fn main() { fn run() -> Result<()> { let cli = SpicetifyCli::parse(); + if cli.print_install_architecture { + let ctx = spicetify::context::build_context( + cli.mirror, + cli.spotify_data_dir.as_deref(), + cli.spotify_exec.as_deref(), + cli.offline_bnk_dir.as_deref(), + )?; + println!("{}", spicetify::update::release::platform_arch(&ctx)); + return Ok(()); + } + spicetify::update::startup_cleanup(); match cli.command { diff --git a/rust/crates/spicetify/Cargo.toml b/rust/crates/spicetify/Cargo.toml index ff8a39fa7b..63b3e602db 100644 --- a/rust/crates/spicetify/Cargo.toml +++ b/rust/crates/spicetify/Cargo.toml @@ -48,5 +48,5 @@ flate2 = { workspace = true } [target.'cfg(target_os = "windows")'.dependencies] junction = { workspace = true } -windows = { workspace = true } +windows = { workspace = true, features = ["Win32_System_SystemInformation"] } windows-registry = { workspace = true } diff --git a/rust/crates/spicetify/src/commands/mod.rs b/rust/crates/spicetify/src/commands/mod.rs index d236b9de4f..86da6a5c21 100644 --- a/rust/crates/spicetify/src/commands/mod.rs +++ b/rust/crates/spicetify/src/commands/mod.rs @@ -132,7 +132,7 @@ pub fn dispatch(cmd: &Command, ctx: &AppContext) -> Result<()> { let _guard = guard::try_acquire(&ctx.config_root)?; crate::lifecycle::restart(ctx) } - Command::SelfUpdate => self_update::run(), + Command::SelfUpdate => self_update::run(ctx), } } diff --git a/rust/crates/spicetify/src/commands/self_update.rs b/rust/crates/spicetify/src/commands/self_update.rs index 8f4d78c074..a663b888ac 100644 --- a/rust/crates/spicetify/src/commands/self_update.rs +++ b/rust/crates/spicetify/src/commands/self_update.rs @@ -3,8 +3,9 @@ use anyhow::Context; use crate::error::Result; use crate::{fl, update}; -pub(crate) fn run() -> Result<()> { +pub(crate) fn run(ctx: &crate::context::AppContext) -> Result<()> { let current_version = crate::VERSION; + let arch = update::release::platform_arch(ctx); tracing::info!("{}", fl!("self-update-checking")); let rt = tokio::runtime::Builder::new_current_thread() @@ -12,9 +13,16 @@ pub(crate) fn run() -> Result<()> { .build() .context("failed to create async runtime")?; - let release = rt.block_on(async { update::check_for_update().await })?; + let release = rt.block_on(async { update::check_for_update(arch).await })?; let Some(release) = release else { + #[cfg(windows)] + if crate::daemon::DaemonManager::create().is_installed() + && !crate::daemon::is_daemon_running() + { + crate::daemon::process::spawn() + .context("failed to restart enabled daemon after update")?; + } tracing::info!("{}", fl!("self-update-up-to-date", version = current_version)); return Ok(()); }; @@ -25,7 +33,7 @@ pub(crate) fn run() -> Result<()> { ); rt.block_on(async { - let staged = update::download_update(&release, |downloaded, total| { + let staged = update::download_update(&release, arch, |downloaded, total| { if total > 0 { tracing::info!(downloaded, total, "downloading..."); } diff --git a/rust/crates/spicetify/src/update/mod.rs b/rust/crates/spicetify/src/update/mod.rs index 07a0b93231..ce36fd1bbc 100644 --- a/rust/crates/spicetify/src/update/mod.rs +++ b/rust/crates/spicetify/src/update/mod.rs @@ -18,7 +18,7 @@ use crate::error::Result; // "already up to date". install.sh lists releases for the same reason. const GITHUB_API: &str = "https://api.github.com/repos/spicetify/cli/releases?per_page=30"; -pub async fn check_for_update() -> Result> { +pub async fn check_for_update(arch: &'static str) -> Result> { let client = crate::http::github_client()?; let response = client .get(GITHUB_API) @@ -30,7 +30,7 @@ pub async fn check_for_update() -> Result> { let releases: Vec = response.json().await.context("failed to parse release JSON")?; let current = Version::parse(crate::VERSION).context("invalid current version")?; - Ok(newest_release(releases, ¤t)) + Ok(newest_release(releases, ¤t, arch != std::env::consts::ARCH, arch)) } /// The newest release by semver precedence when it is newer than `current`. @@ -38,18 +38,29 @@ pub async fn check_for_update() -> Result> { /// semver already ranks `3.0.0` above `3.0.0-beta.*`, so once a stable v3 /// exists it wins naturally, and the v2 era's tags lose on major. /// Unparsable tags are skipped. -fn newest_release(releases: Vec, current: &Version) -> Option { +fn newest_release( + releases: Vec, + current: &Version, + migrate_arch: bool, + arch: &str, +) -> Option { releases .into_iter() .filter_map(|release| Version::parse(&release.version()).ok().map(|v| (v, release))) .max_by(|(a, _), (b, _)| a.cmp(b)) - .filter(|(version, _)| version > current) + .filter(|(version, release)| { + version > current + || (migrate_arch + && version == current + && release.find_platform_asset(arch).is_some()) + }) .map(|(_, release)| release) } #[derive(Debug)] pub struct StagedUpdate { pub version: String, + arch: &'static str, new_binary: PathBuf, daemon_binary: Option, staging_dir: PathBuf, @@ -57,10 +68,11 @@ pub struct StagedUpdate { pub async fn download_update( release: &ReleaseInfo, + arch: &'static str, on_progress: impl Fn(u64, u64) + Send + 'static, ) -> Result { - let asset = release.find_platform_asset().ok_or_else(|| { - let names = release::candidate_asset_names(&release.version()); + let asset = release.find_platform_asset(arch).ok_or_else(|| { + let names = release::candidate_asset_names(&release.version(), arch); anyhow::anyhow!("no release asset found for platform; tried: {}", names.join(", ")) })?; @@ -97,13 +109,18 @@ pub async fn download_update( if p.exists() { Some(p) } else { None } }; - Ok(StagedUpdate { version, new_binary, daemon_binary, staging_dir }) + Ok(StagedUpdate { version, arch, new_binary, daemon_binary, staging_dir }) } pub fn install_update(staged: &StagedUpdate) -> Result<()> { let current_exe = std::env::current_exe()?; let install_dir = current_exe.parent().context("exe has no parent dir")?; + // Older x64 updaters stop the daemon before launching this migration. + let restart_daemon = staged.daemon_binary.is_some() + && (crate::daemon::is_daemon_running() + || (staged.arch != std::env::consts::ARCH + && crate::daemon::DaemonManager::create().is_installed())); if let Some(daemon) = &staged.daemon_binary { let daemon_path = install_dir.join(crate::daemon::daemon_binary_name()); if daemon_path.exists() { @@ -121,6 +138,9 @@ pub fn install_update(staged: &StagedUpdate) -> Result<()> { } replace_binary(&staged.new_binary, ¤t_exe).context("failed to replace main binary")?; + if restart_daemon { + crate::daemon::process::spawn().context("failed to restart updated daemon")?; + } if let Err(e) = std::fs::remove_dir_all(&staged.staging_dir) { tracing::warn!(error = %e, "failed to remove staging dir"); @@ -354,6 +374,31 @@ mod tests { } } + #[test] + fn emulated_cli_can_migrate_at_the_current_version() { + let current = Version::parse("3.0.0-beta.20").unwrap(); + let mut published = release("v3.0.0-beta.20"); + published.assets.push(release::ReleaseAsset { + name: release::candidate_asset_names(&published.version(), std::env::consts::ARCH)[0] + .clone(), + browser_download_url: "https://example.com/native.zip".into(), + size: 1, + }); + assert!( + newest_release(vec![published.clone()], ¤t, true, std::env::consts::ARCH) + .is_some() + ); + assert!(newest_release(vec![published], ¤t, false, std::env::consts::ARCH).is_none()); + assert!( + newest_release(vec![release("v3.0.0-beta.20")], ¤t, true, std::env::consts::ARCH) + .is_none() + ); + assert!( + newest_release(vec![release("v3.0.0-beta.19")], ¤t, true, std::env::consts::ARCH) + .is_none() + ); + } + #[test] fn offers_the_newest_prerelease_over_a_stable_v2() { // The /releases/latest endpoint served v2.44.0 to v3 betas, which @@ -363,6 +408,8 @@ mod tests { let picked = newest_release( vec![release("v3.0.0-beta.7"), release("v2.44.0"), release("v3.0.0-beta.6")], ¤t, + false, + std::env::consts::ARCH, ) .expect("an update is offered"); assert_eq!(picked.tag_name, "v3.0.0-beta.7"); @@ -372,16 +419,30 @@ mod tests { fn stays_put_when_nothing_newer_exists() { let current = Version::parse("3.0.0-beta.7").expect("current"); assert!( - newest_release(vec![release("v3.0.0-beta.7"), release("v2.44.0")], ¤t).is_none() + newest_release( + vec![release("v3.0.0-beta.7"), release("v2.44.0")], + ¤t, + false, + std::env::consts::ARCH + ) + .is_none() + ); + assert!( + newest_release(vec![release("not-a-version")], ¤t, false, std::env::consts::ARCH) + .is_none() ); - assert!(newest_release(vec![release("not-a-version")], ¤t).is_none()); } #[test] fn a_stable_v3_wins_over_its_own_prereleases() { let current = Version::parse("3.0.0-beta.7").expect("current"); - let picked = newest_release(vec![release("v3.0.0"), release("v3.0.0-beta.9")], ¤t) - .expect("stable outranks beta"); + let picked = newest_release( + vec![release("v3.0.0"), release("v3.0.0-beta.9")], + ¤t, + false, + std::env::consts::ARCH, + ) + .expect("stable outranks beta"); assert_eq!(picked.tag_name, "v3.0.0"); } } diff --git a/rust/crates/spicetify/src/update/release.rs b/rust/crates/spicetify/src/update/release.rs index 4b58968889..deaada5105 100644 --- a/rust/crates/spicetify/src/update/release.rs +++ b/rust/crates/spicetify/src/update/release.rs @@ -34,8 +34,8 @@ impl ReleaseInfo { } #[must_use] - pub fn find_platform_asset(&self) -> Option<&ReleaseAsset> { - let candidates = candidate_asset_names(&self.version()); + pub fn find_platform_asset(&self, arch: &str) -> Option<&ReleaseAsset> { + let candidates = candidate_asset_names(&self.version(), arch); for name in &candidates { if let Some(asset) = self.assets.iter().find(|a| a.name == *name) { return Some(asset); @@ -57,10 +57,12 @@ impl ReleaseInfo { } #[must_use] -pub fn candidate_asset_names(version: &str) -> Vec { - let arch = std::env::consts::ARCH; - let os = std::env::consts::OS; - let ext = if cfg!(windows) { "zip" } else { "tar.zst" }; +pub fn candidate_asset_names(version: &str, arch: &str) -> Vec { + candidate_asset_names_for(version, std::env::consts::OS, arch) +} + +fn candidate_asset_names_for(version: &str, os: &str, arch: &str) -> Vec { + let ext = if os == "windows" { "zip" } else { "tar.zst" }; let mut names = Vec::new(); names.push(format!("spicetify-{version}-{os}-{arch}.{ext}")); @@ -75,6 +77,78 @@ pub fn candidate_asset_names(version: &str) -> Vec { names } +fn preferred_arch( + os: &str, + process_arch: &'static str, + native_machine: Option, + spotify_machine: Option, +) -> &'static str { + if os == "windows" && native_machine == Some(0xaa64) { + if spotify_machine == Some(0x8664) { "x86_64" } else { "aarch64" } + } else { + process_arch + } +} + +#[must_use] +pub fn platform_arch(ctx: &crate::context::AppContext) -> &'static str { + #[cfg(not(windows))] + let _ = ctx; + #[cfg(windows)] + let native_machine = { + use windows::Win32::System::SystemInformation::IMAGE_FILE_MACHINE; + use windows::Win32::System::Threading::{GetCurrentProcess, IsWow64Process2}; + let mut process = IMAGE_FILE_MACHINE::default(); + let mut native = IMAGE_FILE_MACHINE::default(); + // GetNativeSystemInfo reports the emulated architecture on Windows ARM64. + // SAFETY: the current-process pseudo-handle is valid and both outputs are writable. + #[allow(unsafe_code)] + match unsafe { + IsWow64Process2(GetCurrentProcess(), &raw mut process, Some(&raw mut native)) + } { + Ok(()) => Some(native.0), + Err(error) => { + tracing::warn!(%error, "could not detect native Windows architecture"); + None + } + } + }; + #[cfg(not(windows))] + let native_machine = None; + #[cfg(windows)] + let spotify_machine = if native_machine == Some(0xaa64) { + // Store's launch alias is not the PE file; its data directory holds the real binary. + [ctx.spotify_exec.clone(), ctx.spotify_data_dir.join("Spotify.exe")].iter().find_map( + |path| { + let mut file = std::fs::File::open(path).ok()?; + pe_machine(&mut file).ok() + }, + ) + } else { + None + }; + #[cfg(not(windows))] + let spotify_machine = None; + preferred_arch(std::env::consts::OS, std::env::consts::ARCH, native_machine, spotify_machine) +} + +#[cfg(any(windows, test))] +fn pe_machine(reader: &mut (impl std::io::Read + std::io::Seek)) -> std::io::Result { + let mut dos = [0; 64]; + reader.read_exact(&mut dos)?; + if &dos[..2] != b"MZ" { + return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "missing DOS header")); + } + let offset = u32::from_le_bytes(dos[60..64].try_into().expect("four-byte PE offset")); + let _ = reader.seek(std::io::SeekFrom::Start(u64::from(offset)))?; + let mut pe = [0; 6]; + reader.read_exact(&mut pe)?; + if &pe[..4] != b"PE\0\0" { + return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "missing PE header")); + } + Ok(u16::from_le_bytes([pe[4], pe[5]])) +} + #[must_use] pub fn short_arch_name(arch: &str) -> &str { match arch { @@ -116,6 +190,52 @@ pub enum ChecksumError { mod tests { use super::*; + #[test] + fn emulated_windows_selects_native_arm64_release_assets() { + let arch = preferred_arch("windows", "x86_64", Some(0xaa64), Some(0xaa64)); + let names = candidate_asset_names_for("3.0.0-beta.20", "windows", arch); + assert_eq!(names[0], "spicetify-3.0.0-beta.20-windows-aarch64.zip"); + assert!(!names.iter().any(|name| name.contains("x86_64") || name.contains("x64"))); + } + + #[test] + fn native_windows_and_other_platforms_keep_their_architecture() { + assert_eq!(preferred_arch("windows", "x86_64", Some(0x8664), Some(0x8664)), "x86_64"); + assert_eq!(preferred_arch("windows", "aarch64", Some(0xaa64), Some(0xaa64)), "aarch64"); + assert_eq!(preferred_arch("macos", "x86_64", None, None), "x86_64"); + assert_eq!(preferred_arch("linux", "aarch64", None, None), "aarch64"); + } + + #[test] + fn arm64_windows_preserves_x64_spotify_and_defaults_to_native_before_install() { + for process_arch in ["x86_64", "aarch64"] { + assert_eq!( + preferred_arch("windows", process_arch, Some(0xaa64), Some(0x8664)), + "x86_64" + ); + assert_eq!( + preferred_arch("windows", process_arch, Some(0xaa64), Some(0xaa64)), + "aarch64" + ); + assert_eq!(preferred_arch("windows", process_arch, Some(0xaa64), None), "aarch64"); + } + } + + #[test] + fn spotify_architecture_comes_from_the_pe_header() { + for machine in [0xaa64u16, 0x8664] { + let mut bytes = vec![0; 134]; + bytes[..2].copy_from_slice(b"MZ"); + bytes[60..64].copy_from_slice(&128u32.to_le_bytes()); + bytes[128..132].copy_from_slice(b"PE\0\0"); + bytes[132..134].copy_from_slice(&machine.to_le_bytes()); + assert_eq!(pe_machine(&mut std::io::Cursor::new(&bytes)).unwrap(), machine); + bytes[128] = 0; + assert!(pe_machine(&mut std::io::Cursor::new(&bytes)).is_err()); + } + assert!(pe_machine(&mut std::io::Cursor::new(b"not an executable")).is_err()); + } + #[test] fn deserializes_the_real_release_list_shape() { // Trimmed from the live GitHub API: workflow-cut releases carry diff --git a/scripts/setup-windows-arm64.ps1 b/scripts/setup-windows-arm64.ps1 new file mode 100644 index 0000000000..922b159d65 --- /dev/null +++ b/scripts/setup-windows-arm64.ps1 @@ -0,0 +1,15 @@ +$ErrorActionPreference = 'Stop' +$vswhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" +$installation = & $vswhere -latest -products '*' -requires Microsoft.VisualStudio.Component.VC.Tools.ARM64 -property installationPath +if (-not $installation) { throw 'Visual Studio ARM64 C++ tools are required.' } +$compiler = Join-Path $installation 'VC\Tools\Llvm\ARM64\bin\clang-cl.exe' +if (-not (Test-Path -LiteralPath $compiler)) { throw 'Install the Visual Studio C++ Clang compiler for ARM64.' } +$developerShell = Join-Path $installation 'Common7\Tools\VsDevCmd.bat' +$buildEnvironment = & cmd.exe /d /s /c "call `"$developerShell`" -arch=arm64 -host_arch=arm64 >nul && set" +if ($LASTEXITCODE -ne 0) { throw 'Failed to configure the ARM64 C++ environment.' } +$buildEnvironment | Where-Object { $_ -match '^(INCLUDE|LIB|LIBPATH|PATH)=' } | + Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 +# AWS-LC needs clang-cl for ARM assembly; the window-control helper uses MSVC C++. +"CC=$compiler" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 +& rustup set default-host aarch64-pc-windows-msvc +if ($LASTEXITCODE -ne 0) { throw 'Failed to select the native Rust host.' } diff --git a/scripts/test-install-architecture.ps1 b/scripts/test-install-architecture.ps1 new file mode 100644 index 0000000000..1d62d97742 --- /dev/null +++ b/scripts/test-install-architecture.ps1 @@ -0,0 +1,91 @@ +$ErrorActionPreference = 'Stop' +$RepoRoot = Split-Path -Parent $PSScriptRoot +$Installer = Get-Content -LiteralPath "$RepoRoot\install.ps1" -Raw +. ([scriptblock]::Create($Installer.Substring(0, $Installer.IndexOf('#region Main')))) + +# Check the actual Windows API before replacing it with deterministic fixtures. +$native = Get-SpicetifyNativeMachine +if ($native -notin @(0x8664, 0xAA64)) { throw "Unexpected native machine: $native" } +if ($env:RUNNER_ARCH -eq 'ARM64' -and $native -ne 0xAA64) { throw 'Emulation hid the ARM64 host' } +Write-Host "Native release architecture: $(Get-SpicetifyArchitecture)" + +function Get-SpicetifyNativeMachine { 0xAA64 } +if ((Get-SpicetifyArchitecture) -ne 'aarch64') { throw 'ARM64 must select the native release' } +function Get-SpicetifyNativeMachine { 0x8664 } +if ((Get-SpicetifyArchitecture) -ne 'x86_64') { throw 'x64 must keep the x64 release' } +function Get-SpicetifyNativeMachine { 0x014c } +$rejected = $false +try { Get-SpicetifyArchitecture } catch { $rejected = $true } +if (-not $rejected) { throw 'Unsupported native architectures must fail explicitly' } +Write-Host 'Native Windows installer architecture selection passed.' + +# Exercise the real download selection and checksum check without network access. +function Get-SpicetifyNativeMachine { 0xAA64 } +function Invoke-WebRequest { + param($Uri, $OutFile, [switch]$UseBasicParsin) + if ($Uri -notlike '*-windows-aarch64.zip') { throw "Wrong architecture URL: $Uri" } + [IO.File]::WriteAllText($OutFile, 'archive fixture') +} +function Invoke-RestMethod { + param($Uri) + if ($Uri -notlike '*-windows-aarch64.zip.sha256') { throw "Wrong checksum URL: $Uri" } + (Get-FileHash -LiteralPath ([IO.Path]::Combine([IO.Path]::GetTempPath(), 'spicetify.zip')) -Algorithm SHA256).Hash +} +$v3 = $true +$v = '3.0.0-beta.20' +$download = $null +try { + $download = Get-Spicetify + if (-not (Test-Path -LiteralPath $download)) { throw 'Verified archive was not returned' } + function Invoke-RestMethod { param($Uri) '0' * 64 } + $rejected = $false + try { Get-Spicetify | Out-Null } catch { $rejected = $true } + if (-not $rejected) { throw 'A mismatched archive checksum must fail before installation' } +} +finally { + if ($download) { Remove-Item -LiteralPath $download -Force -ErrorAction SilentlyContinue } +} +Write-Host 'ARM64 release download and checksum rejection passed.' + +$fixture = Join-Path ([IO.Path]::GetTempPath()) "spicetify-replace-$([guid]::NewGuid())" +$source = Join-Path $fixture 'source' +$destination = Join-Path $fixture 'installed' +try { + New-Item -ItemType Directory -Path $source -Force | Out-Null + foreach ($name in @('spicetify.exe', 'spicetify-daemon.exe')) { + [IO.File]::WriteAllText((Join-Path $source $name), 'first') + } + Install-SpicetifyBinaries -Source $source -Destination $destination + foreach ($name in @('spicetify.exe', 'spicetify-daemon.exe')) { + if ((Get-Content -LiteralPath (Join-Path $destination $name) -Raw) -ne 'first') { + throw 'Fresh installation did not install both binaries.' + } + [IO.File]::WriteAllText((Join-Path $source $name), 'second') + } + Install-SpicetifyBinaries -Source $source -Destination $destination + Remove-Item -LiteralPath (Join-Path $source 'spicetify-daemon.exe') + [IO.File]::WriteAllText((Join-Path $source 'spicetify.exe'), 'third') + $rejected = $false + try { Install-SpicetifyBinaries -Source $source -Destination $destination } catch { $rejected = $true } + if (-not $rejected) { throw 'Incomplete replacement must fail.' } + foreach ($name in @('spicetify.exe', 'spicetify-daemon.exe')) { + if ((Get-Content -LiteralPath (Join-Path $destination $name) -Raw) -ne 'second') { + throw 'Failed replacement did not restore the original binary pair.' + } + } + [IO.File]::WriteAllText((Join-Path $source 'spicetify-daemon.exe'), 'third') + $locked = [IO.File]::Open((Join-Path $destination 'spicetify.exe'), 'Open', 'Read', 'Read') + try { + $rejected = $false + try { Install-SpicetifyBinaries -Source $source -Destination $destination } catch { $rejected = $true } + if (-not $rejected) { throw 'A locked installation must fail without replacing the daemon.' } + } + finally { $locked.Dispose() } + foreach ($name in @('spicetify.exe', 'spicetify-daemon.exe')) { + if ((Get-Content -LiteralPath (Join-Path $destination $name) -Raw) -ne 'second') { + throw 'Locked-file failure changed an installed binary.' + } + } +} +finally { Remove-Item -LiteralPath $fixture -Recurse -Force -ErrorAction SilentlyContinue } +Write-Host 'Fresh installation, pair replacement, rollback, and locked-file recovery passed.' From f330d10ec8802d6e08fb4de502775e933fac1e30 Mon Sep 17 00:00:00 2001 From: Afonso Jorge Ramos Date: Tue, 29 Sep 2026 17:14:55 +0100 Subject: [PATCH 05/19] test: remove tests that do not exercise behaviour (#3970) --- rust/crates/daemon/src/update_job_tests.rs | 8 -------- src/jsHelper/modularLoader/boot-order.test.mts | 14 -------------- 2 files changed, 22 deletions(-) diff --git a/rust/crates/daemon/src/update_job_tests.rs b/rust/crates/daemon/src/update_job_tests.rs index 8d9ee37642..b46b0fa627 100644 --- a/rust/crates/daemon/src/update_job_tests.rs +++ b/rust/crates/daemon/src/update_job_tests.rs @@ -50,14 +50,6 @@ fn spotify_lines_ignore_the_build_component() { assert!(version_line("UNKNOWN").is_none()); } -#[test] -fn update_and_apply_support_matches_the_compiled_platform() { - assert_eq!( - supported_on_this_platform(), - cfg!(target_os = "macos") || cfg!(all(windows, feature = "experimental-windows-updates")) - ); -} - #[cfg(not(any(target_os = "macos", all(windows, feature = "experimental-windows-updates"))))] #[test] fn unsupported_platform_refuses_admission_before_mutating_state() { diff --git a/src/jsHelper/modularLoader/boot-order.test.mts b/src/jsHelper/modularLoader/boot-order.test.mts index 046c9f07f0..5418464424 100644 --- a/src/jsHelper/modularLoader/boot-order.test.mts +++ b/src/jsHelper/modularLoader/boot-order.test.mts @@ -1,22 +1,10 @@ import assert from "node:assert/strict"; -import { readFileSync } from "node:fs"; import { describe, it } from "node:test"; -const loaderSource = readFileSync(new URL("./index.ts", import.meta.url), "utf8"); const captureModule = await import("./webpackCapture.ts").catch(() => undefined); const queueModule = await import("../shared/webpackChunkQueue.js"); describe("modular loader boot order", () => { - it("waits for webpack capture before loading modules", () => { - assert.match(loaderSource, /await captureWebpackRequire\(\);\s*await registry\.runLoads\(report\);/); - }); - - it("boots Spotify 1.3 from its direct xpui bundle", () => { - assert.match(loaderSource, /__SPICETIFY_CLIENT_BUNDLE_MODE__/); - assert.match(loaderSource, /kind:\s*["']direct["']/); - assert.match(loaderSource, /bundle:\s*["']\/xpui\.js["']/); - }); - it("selects only a queue whose runtime is ready", () => { const unready = []; const scoped = []; @@ -31,7 +19,6 @@ describe("modular loader boot order", () => { }); it("reports success only after the rspack callback supplies webpack require", async () => { - assert.equal(typeof captureModule?.captureWebpackRequire, "function"); let now = 0; let runtime: ((require: unknown) => unknown) | undefined; let captured: unknown; @@ -56,7 +43,6 @@ describe("modular loader boot order", () => { }); it("times out when queue push never invokes the runtime callback", async () => { - assert.equal(typeof captureModule?.captureWebpackRequire, "function"); let now = 0; const ok = await captureModule!.captureWebpackRequire({ maxWaitMs: 20, From b56fd6bf7bc6490ce311037588d3df0f490649f0 Mon Sep 17 00:00:00 2001 From: Afonso Jorge Ramos Date: Wed, 30 Sep 2026 00:12:29 +0100 Subject: [PATCH 06/19] chore: follow the spicetify/classmaps schema 2 layout (#3972) --- expose.json | 82 ++++++++++++++++++++++++++----------- scripts/classmap_capture.py | 2 +- scripts/classmap_e2e.sh | 2 +- 3 files changed, 61 insertions(+), 25 deletions(-) diff --git a/expose.json b/expose.json index e10398fba3..d87a2e77c6 100644 --- a/expose.json +++ b/expose.json @@ -1,94 +1,130 @@ { - "$comment": "Apply-time source patches that expose Spotify internals on the Spicetify global, applied to the xpui-modules.js extracted from the v8 snapshot. Fetched by the CLI through index.json; the CLI embeds a copy as its offline baseline. `replace` is a capture-group template (${0} whole match, ${N} group N; `$$` for a literal dollar). `once` stops after the first match. `onMiss` is `warn` (a miss means a Spotify update moved the code) or `quiet` (a miss is expected on some supported builds). `note` records the measured hit count per build.", "version": 1, "patches": [ { "name": "Expose PlatformAPI", - "note": "1.2.84: 1, 1.2.96: 1.", "pattern": "((?:setTitlebarHeight|registerFactory)[\\w(){}<>:.,&$!=;\"\"?!#%/\\- ]+)(\\{version:[a-zA-Z_\\$][\\w\\$]*,)", "replace": "${1}Spicetify._platform=${2}", "once": false, - "onMiss": "warn" + "onMiss": "warn", + "hits": { + "1.2.84": 1, + "1.2.96": 1 + } }, { "name": "Redux store", - "note": "1.2.84: 0, 1.2.96: 1. The store is created inside an IIFE invoked with the session/features/seoExperiment object; this anchors on the only `return (0,createStore)((0,combineReducers)({` in that IIFE and routes the created store through the global. 1.2.84 builds the store another way and has no seoExperiment key at all, so ReduxStore is not exposed there yet.", "pattern": "return(\\(0,[\\w$]+\\.[\\w$]+\\)\\(\\(0,[\\w$]+\\.[\\w$]+\\)\\(\\{(?s:.{0,1500}?)\\)\\}\\(\\{session:[\\w$]+,features:[\\w$]+,seoExperiment:[\\w$]+\\},\\{platform:)", "replace": "return Spicetify.Platform.ReduxStore=${1}", "once": true, - "onMiss": "warn" + "onMiss": "warn", + "hits": { + "1.2.84": 0, + "1.2.96": 1 + }, + "note": "The store is created inside an IIFE invoked with the session/features/seoExperiment object; this anchors on the only `return (0,createStore)((0,combineReducers)({` in that IIFE and routes the created store through the global. 1.2.84 builds the store another way and has no seoExperiment key at all, so ReduxStore is not exposed there yet." }, { "name": "React Component: Platform Provider", - "note": "1.2.84: 0, 1.2.96: 2.", "pattern": "(,[$\\w]+=)((function\\([\\w$]{1}\\)\\{var [\\w$]+=[\\w$]+\\.platform,[\\w$]+=[\\w$]+\\.children,)|(\\(\\{platform:[\\w$]+,children:[\\w$]+\\}\\)=>\\{))", "replace": "${1}Spicetify.ReactComponent.PlatformProvider=${2}", "once": false, - "onMiss": "warn" + "onMiss": "warn", + "hits": { + "1.2.84": 0, + "1.2.96": 2 + } }, { "name": "Spotify Custom Snackbar Interface", - "note": "1.2.84: 1, 1.2.96: 1.", "pattern": "\\b\\w\\s*\\(\\)\\s*[^;,]*enqueueCustomSnackbar:\\s*(\\w)\\s*[^;]*;", "replace": "${0}Spicetify.Snackbar.enqueueCustomSnackbar=${1};", "once": false, - "onMiss": "warn" + "onMiss": "warn", + "hits": { + "1.2.84": 1, + "1.2.96": 1 + } }, { "name": "Spotify Image Snackbar Interface", - "note": "1.2.84: 1, 1.2.96: 1. The enqueue arrow is returned through useCallback rather than assigned.", "pattern": "return(\\(0,[\\w$]+\\.useCallback\\)\\(\\(\\{message:[\\w$]+,\\s*imageSrc)", "replace": "return Spicetify.Snackbar.enqueueImageSnackbar=${1}", "once": false, - "onMiss": "warn" + "onMiss": "warn", + "hits": { + "1.2.84": 1, + "1.2.96": 1 + }, + "note": "The enqueue arrow is returned through useCallback rather than assigned." }, { "name": "React Component: Navigation for navLinks", - "note": "1.2.84: 1, 1.2.96: 0. The wrapper finds Navigation through webpack at runtime (react-components.js), so a miss is covered.", "pattern": "(;const [\\w\\d]+=)((?:\\(0,[\\w\\d]+\\.memo\\))[\\(\\d,\\w\\.\\){:}=]+=[\\d\\w]+\\.[\\d\\w]+\\.getLocaleForURLPath\\(\\))", "replace": "${1}Spicetify.ReactComponent.Navigation=${2}", "once": true, - "onMiss": "quiet" + "onMiss": "quiet", + "hits": { + "1.2.84": 1, + "1.2.96": 0 + }, + "note": "The wrapper finds Navigation through webpack at runtime (react-components.js), so a miss is covered." }, { "name": "Context Menu V2", - "note": "1.2.84: 1, 1.2.96: 1.", "pattern": "(\"Menu\".+?children:)([\\w$][\\w$\\d]*)", "replace": "${1}[Spicetify.ContextMenuV2.renderItems(),${2}].flat()", "once": false, - "onMiss": "warn" + "onMiss": "warn", + "hits": { + "1.2.84": 1, + "1.2.96": 1 + } }, { "name": "Map styled-components classes", - "note": "1.2.84: 1, 1.2.96: 1.", "pattern": "(\\w+ [\\w$_]+)=[\\w$_]+\\([\\w$_]+>>>0\\)", "replace": "${1}=Spicetify._getStyledClassName(arguments,this)", "once": false, - "onMiss": "warn" + "onMiss": "warn", + "hits": { + "1.2.84": 1, + "1.2.96": 1 + } }, { "name": "Tippy.js", - "note": "1.2.84: 2, 1.2.96: 2.", "pattern": "([\\w\\$_]+)\\.setDefaultProps=", "replace": "Spicetify.Tippy=${1};${0}", "once": false, - "onMiss": "warn" + "onMiss": "warn", + "hits": { + "1.2.84": 2, + "1.2.96": 2 + } }, { "name": "Flipper components", - "note": "1.2.84: 1, 1.2.96: 0. react-flip-toolkit left the client in 1.2.9x; nothing remains to expose there.", "pattern": "([\\w$]+)=((?:function|\\()([\\w$.,{}()= ]+(?:springConfig|overshootClamping)){2})", "replace": "${1}=Spicetify.ReactFlipToolkit.spring=${2}", "once": false, - "onMiss": "quiet" + "onMiss": "quiet", + "hits": { + "1.2.84": 1, + "1.2.96": 0 + }, + "note": "react-flip-toolkit left the client in 1.2.9x; nothing remains to expose there." }, { "name": "Snackbar", - "note": "1.2.84: 1, 1.2.96: 1.", "pattern": "\\w+\\s*=\\s*\\w\\.call\\(this,[^)]+\\)\\s*\\|\\|\\s*this\\)\\.enqueueSnackbar", "replace": "Spicetify.Snackbar=${0}", "once": false, - "onMiss": "warn" + "onMiss": "warn", + "hits": { + "1.2.84": 1, + "1.2.96": 1 + } } ] } diff --git a/scripts/classmap_capture.py b/scripts/classmap_capture.py index dd2fed812f..ad9c35bbd6 100644 --- a/scripts/classmap_capture.py +++ b/scripts/classmap_capture.py @@ -20,7 +20,7 @@ Examples -------- python3 scripts/classmap_capture.py migrate \\ - --base-classmap ../classmaps/1020040/classmap-190747c4b8f.json \\ + --base-classmap ../classmaps/1020040/classmap.json \\ --base-css-dir ../xpui-archive/1.2.40.599 \\ --target-spa "/Applications/Spotify.app/Contents/Resources/Apps/xpui.spa" \\ --css-map css-map.json \\ diff --git a/scripts/classmap_e2e.sh b/scripts/classmap_e2e.sh index 9926b7d2b5..d118620858 100755 --- a/scripts/classmap_e2e.sh +++ b/scripts/classmap_e2e.sh @@ -74,7 +74,7 @@ STOCK_SPA_CANDIDATES=( "/Applications/Spotify.app/Contents/Resources/Apps/xpui.spa" "/Applications/Spotify.app/Contents/Resources/Apps/xpui.spa.bak" ) -BASE_CLASSMAP="${BASE_CLASSMAP:-$ROOT/../classmaps/1020040/classmap-190747c4b8f.json}" +BASE_CLASSMAP="${BASE_CLASSMAP:-$ROOT/../classmaps/1020040/classmap.json}" BASE_CSS_DIR="${BASE_CSS_DIR:-$ROOT/../xpui-archive/1.2.40.599}" OUT_DIR="${OUT_DIR:-$ROOT/classmaps/1020092}" CSS_MAP="${CSS_MAP:-$ROOT/css-map.json}" From 7f7cbd87f609f3ee785d18b6e15b14a38df54be9 Mon Sep 17 00:00:00 2001 From: Afonso Jorge Ramos Date: Wed, 30 Sep 2026 00:13:25 +0100 Subject: [PATCH 07/19] refactor(scripts): port the classmap capture tooling to TypeScript (#3973) --- .github/workflows/build.yml | 2 +- scripts/classmap-capture.test.mts | 265 ++++ scripts/classmap-capture.ts | 1061 +++++++++++++++++ ...cdp_verify.mjs => classmap-cdp-verify.mjs} | 8 +- ...erify.mjs => classmap-cdp-verify.test.mjs} | 4 +- scripts/{classmap_e2e.sh => classmap-e2e.sh} | 91 +- scripts/classmap_capture.py | 1036 ---------------- scripts/test_classmap_capture.py | 271 ----- src/utils/classmap.go | 2 +- 9 files changed, 1379 insertions(+), 1361 deletions(-) create mode 100644 scripts/classmap-capture.test.mts create mode 100644 scripts/classmap-capture.ts rename scripts/{classmap_cdp_verify.mjs => classmap-cdp-verify.mjs} (98%) rename scripts/{test_classmap_cdp_verify.mjs => classmap-cdp-verify.test.mjs} (95%) rename scripts/{classmap_e2e.sh => classmap-e2e.sh} (81%) delete mode 100644 scripts/classmap_capture.py delete mode 100644 scripts/test_classmap_capture.py diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 05f9261ebe..cbe4c00cec 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -51,7 +51,7 @@ jobs: run: node --test "src/jsHelper/**/*.test.mts" - name: Run classmap tooling tests - run: python3 -m unittest scripts.test_classmap_capture + run: node --test "scripts/*.test.mts" "scripts/*.test.mjs" - name: Format run: | diff --git a/scripts/classmap-capture.test.mts b/scripts/classmap-capture.test.mts new file mode 100644 index 0000000000..275e6dfdc4 --- /dev/null +++ b/scripts/classmap-capture.test.mts @@ -0,0 +1,265 @@ +import assert from "node:assert/strict"; +import crypto from "node:crypto"; +import { describe, test } from "node:test"; + +import { + allRules, + classSignatures, + confidenceLabel, + flattenClassmap, + isHashLike, + migrateClassmap, + pyRound, + semanticFit, + splitRules, + verifyClassmap, + versionToKey, +} from "./classmap-capture.ts"; + +const map = (entries: Record) => new Map(Object.entries(entries)); + +describe("isHashLike", () => { + test("accepts real hashes", () => { + // Short real hash from the 1.2.45 fixture (play button). + for (const token of ["cLkUmr", "AbCdEfGh", "Xy1_abC", "aB3d"]) assert.ok(isHashLike(token), token); + }); + + test("rejects semantic and Encore classes", () => { + for (const token of ["main-topbar", "spotify-play-button", "encore-text", "abc", "alllower", "A".repeat(26)]) { + assert.ok(!isHashLike(token), token); + } + }); +}); + +describe("rules and signatures", () => { + const css = ` + .cLkUmr, .other_Class { color: red; padding: 4px; } + /* comment { ignored } */ + .main-topbar { --custom: 1; background: blue; } + @media (min-width: 100px) { .ignoredAtRule { color: green; } } + `; + + test("splits rules and skips at-rules", () => { + const selectors = splitRules(css).map(([selector]) => selector); + assert.ok(selectors.some((s) => s.includes("cLkUmr"))); + assert.ok(!selectors.some((s) => s.startsWith("@"))); + }); + + test("finds rules nested in grouping at-rules, but not in keyframes", () => { + const layered = + "@layer encore;@layer encore{.e-1-button{color:red}@media (min-width:1px){.e-1-icon{margin:0}}}@keyframes spin{from{opacity:0}}.after{color:blue}"; + assert.deepEqual( + allRules(layered).map(([selector]) => selector), + [".e-1-button", ".e-1-icon", ".after"], + ); + }); + + test("verify finds a class that only appears inside @layer", () => { + const { rows } = verifyClassmap({ settings: { text_input: "e-10860-form-input" } }, "@layer encore{.e-10860-form-input{color:red}}", new Map()); + assert.equal(rows[0].in_target_css, true); + }); + + test("signatures skip CSS variables", () => { + const sigs = classSignatures(".foo { --x: 1; color: red; }"); + const props = [...(sigs.get("foo")?.values() ?? [])].flatMap((sig) => [...sig].map((pair) => pair.split("\0")[0])); + assert.deepEqual(props, ["color"]); + }); +}); + +describe("semanticFit false friends", () => { + test("rejects actionBar for the playbar", () => assert.equal(semanticFit(["main", "playbar", "controls"], "actionBar"), 0)); + test("accepts nowPlayingBar for the playbar", () => assert.ok(semanticFit(["main", "playbar", "controls"], "nowPlayingBar") > 0)); + test("a close leaf requires close", () => { + assert.equal(semanticFit(["main", "topbar", "close"], "topBarButton"), 0); + assert.ok(semanticFit(["main", "topbar", "close"], "topBarCloseBtn") > 0); + }); + test("an empty semantic scores zero", () => assert.equal(semanticFit(["a", "b"], ""), 0)); +}); + +test("versionToKey pads minor and patch", () => { + assert.equal(versionToKey("1.2.45"), "1020045"); + assert.equal(versionToKey("1.2.93"), "1020093"); + assert.equal(versionToKey("1.2.8"), "1020008"); + assert.equal(versionToKey("1.3.1.234"), "1030001"); +}); + +test("pyRound matches Python's round on exact binary halves", () => { + assert.equal(pyRound(0.03125, 4), 0.0312); + assert.equal(pyRound(0.09375, 4), 0.0938); + assert.equal(pyRound(1 / 3, 4), 0.3333); + assert.equal(pyRound(0.00015, 4), 0.0001); +}); + +describe("verifyClassmap", () => { + test("a multi-class leaf is present when every token exists", () => { + const { rows } = verifyClassmap( + { settings: { text_input: "formInputAA formControlBB encore-text-body-medium" } }, + ".formInputAA { color: red; } .formControlBB { color: blue; } .encore-text-body-medium { font-size: 1rem; }", + new Map(), + ); + assert.equal(rows[0].in_target_css, true); + assert.deepEqual(rows[0].missing_classes, []); + assert.equal(rows[0].selector_hits, 3); + }); + + test("a multi-class leaf reports only the missing tokens", () => { + const { rows } = verifyClassmap({ settings: { text_input: "formInputAA missingControlBB" } }, ".formInputAA { color: red; }", new Map()); + assert.equal(rows[0].in_target_css, false); + assert.deepEqual(rows[0].missing_classes, ["missingControlBB"]); + assert.equal(rows[0].verdict, "missing_in_css"); + }); + + test("a multi-class leaf reports each token's semantic", () => { + const { rows } = verifyClassmap( + { settings: { text_input: "formInputAA formControlBB" } }, + ".formInputAA { color: red; } .formControlBB { color: blue; }", + map({ formInputAA: "x-settings-input", formControlBB: "x-settings-control" }), + ); + assert.equal(rows[0].in_css_map, true); + assert.deepEqual(rows[0].semantics, ["x-settings-input", "x-settings-control"]); + }); + + test("the static report records the target build and CSS digest", () => { + const css = ".topbarHashAA { color: red; }"; + const { target } = verifyClassmap({ main: { topbar: { wrapper: "topbarHashAA" } } }, css, new Map(), null, "1.2.96.518"); + assert.equal(target.spotify_version, "1.2.96.518"); + assert.equal(target.css_sha256, crypto.createHash("sha256").update(css).digest("hex")); + }); +}); + +describe("migrateClassmap", () => { + const baseCss = ".oldHashAA { color: red; padding: 4px; } .keepMe99 { margin: 0; }"; + const targetCss = ".newHashBB { color: red; padding: 4px; } .keepMe99 { margin: 0; }"; + const migrate = (baseMap: Record, cssMap: Map) => + migrateClassmap(baseMap, classSignatures(baseCss), classSignatures(targetCss), cssMap, 0.5); + + test("keeps a class that survived unchanged", () => { + const [out, report] = migrate({ main: { widget: "keepMe99" } }, new Map()); + assert.deepEqual(out, { main: { widget: "keepMe99" } }); + assert.equal(report.stats.identity, 1); + assert.equal(report.identity[0].method, "identity"); + assert.equal(confidenceLabel(report.identity[0]), "high"); + }); + + test("follows CSS evidence to the renamed class", () => { + const [out, report] = migrate({ main: { widget: "oldHashAA" } }, new Map()); + assert.deepEqual(out, { main: { widget: "newHashBB" } }); + assert.equal(report.stats.matched, 1); + }); + + test("marks an unmatched leaf stale and keeps its old hash", () => { + const [out, report] = migrate({ main: { widget: "notInBase1" } }, new Map()); + // Old hash kept for incremental re-runs, but explicitly marked stale. + assert.deepEqual(out, { main: { widget: "notInBase1" } }); + assert.equal(report.stats.unmatched, 1); + assert.equal(report.stats.stale_kept, 1); + assert.equal(report.unmatched[0].stale, true); + assert.equal(report.unmatched[0].kept, "notInBase1"); + }); + + test("a semantic-only match requires the class in the target CSS", () => { + // css-map claims a great semantic fit, but the class does not exist in the target CSS at all. + const [out, report] = migrate({ main: { playbar: { controls: "notInBase1" } } }, map({ ghostHash1: "nowPlayingBarControls" })); + assert.deepEqual(out, { main: { playbar: { controls: "notInBase1" } } }); + assert.equal(report.stats.matched, 0); + }); + + test("rejects a CSS match that ties exactly with another class", () => { + const [out, report] = migrateClassmap( + { main: { widget: "oldHashAA" } }, + classSignatures(baseCss), + classSignatures(".newHashBB { color: red; padding: 4px; } .twinHashCC { color: red; padding: 4px; }"), + new Map(), + 0.5, + ); + assert.deepEqual(out, { main: { widget: "oldHashAA" } }); + const best = report.unmatched[0].best as Record; + assert.equal(best.reason, "tied with 1 other candidate(s)"); + assert.deepEqual(best.tied, ["twinHashCC"]); + }); + + test("rejects a semantic-only match that ties with another class", () => { + const [out, report] = migrateClassmap( + { main: { playbar: { controls: "notInBase1" } } }, + new Map(), + classSignatures(".newHashBB { color: red; } .twinHashCC { margin: 0; }"), + map({ newHashBB: "nowPlayingBarControls", twinHashCC: "nowPlayingBarControlsAlt" }), + 0.5, + ); + assert.deepEqual(out, { main: { playbar: { controls: "notInBase1" } } }); + assert.deepEqual(report.unmatched[0].tied, ["newHashBB", "twinHashCC"]); + assert.equal(report.unmatched[0].stale, true); + }); + + test("a semantic-only match has low confidence", () => { + const [out, report] = migrateClassmap( + { main: { playbar: { controls: "notInBase1" } } }, + new Map(), + classSignatures(".newHashBB { color: red; }"), + map({ newHashBB: "nowPlayingBarControls" }), + 0.5, + ); + assert.deepEqual(out, { main: { playbar: { controls: "newHashBB" } } }); + assert.equal(report.matched[0].method, "semantic-only"); + assert.equal(confidenceLabel(report.matched[0]), "low"); + }); +}); + +describe("flattenClassmap", () => { + const base = { main: { topbar: { wrapper: "oldHashAA11" } }, settings: { button: { wrapper: "oldHashBB22" } } }; + const cssMap = map({ oldHashAA11: "main-topBar-topbarContent", newHashCC33: "x-settings-button" }); + const report = { + matched: [{ path: "settings.button.wrapper", new: "newHashCC33", semantic: "x-settings-button" }], + unmatched: [{ path: "main.topbar.icon", old: "staleHash99", stale: true }], + identity: [], + }; + + test("the base hash's css-map name wins", () => { + const [overlay, skipped] = flattenClassmap({ main: { topbar: { wrapper: "newHashAA44" } } }, base, cssMap, null, null); + assert.deepEqual(overlay, { newHashAA44: "main-topBar-topbarContent" }); + assert.deepEqual(skipped, []); + }); + + test("skips a hash the global css-map already renames", () => { + const [overlay] = flattenClassmap({ settings: { button: { wrapper: "newHashCC33" } } }, base, cssMap, report, null); + assert.deepEqual(overlay, {}); + }); + + test("falls back to the report's semantic for hand-edited leaves", () => { + const handEdited = { matched: [{ path: "settings.header.container", semantic: "x-settings-header" }], unmatched: [], identity: [] }; + const [overlay] = flattenClassmap({ settings: { header: { container: "newHashHH77" } } }, base, cssMap, handEdited, null); + assert.deepEqual(overlay, { newHashHH77: "x-settings-header" }); + }); + + test("excludes stale leaves from the report and META", () => { + assert.deepEqual(flattenClassmap({ main: { topbar: { icon: "staleHash99" } } }, base, cssMap, report, null)[0], {}); + const meta = { stale_leaves: ["main.topbar.wrapper"] }; + assert.deepEqual(flattenClassmap({ main: { topbar: { wrapper: "newHashAA44" } } }, base, cssMap, null, meta)[0], {}); + }); + + test("skips an unresolvable leaf with a warning", () => { + const [overlay, skipped] = flattenClassmap({ main: { mystery: { leaf: "newHashZZ99" } } }, base, cssMap, null, null); + assert.deepEqual(overlay, {}); + assert.equal(skipped.length, 1); + }); + + test("excludes unverified leaves and warns about an unverified required path", () => { + const meta = { unverified_leaves: ["settings.button.wrapper"], required_paths: { "settings.button.wrapper": "unverified" } }; + const semantic = { matched: [{ path: "settings.button.wrapper", semantic: "x-settings-button" }], unmatched: [], identity: [] }; + const [overlay, skipped] = flattenClassmap({ settings: { button: { wrapper: "newHashUU55" } } }, base, cssMap, semantic, meta); + assert.deepEqual(overlay, {}); + assert.ok(skipped.some((s) => s.includes("required path"))); + }); + + test("keeps the first of two conflicting names and warns", () => { + const [overlay, skipped] = flattenClassmap( + { a: { leaf: "sharedHash1" }, b: { leaf: "sharedHash1" } }, + { a: { leaf: "oldA1" }, b: { leaf: "oldB1" } }, + map({ oldA1: "name-a", oldB1: "name-b" }), + null, + null, + ); + assert.deepEqual(overlay, { sharedHash1: "name-a" }); + assert.ok(skipped.some((s) => s.includes("conflicting"))); + }); +}); diff --git a/scripts/classmap-capture.ts b/scripts/classmap-capture.ts new file mode 100644 index 0000000000..65a9adc116 --- /dev/null +++ b/scripts/classmap-capture.ts @@ -0,0 +1,1061 @@ +#!/usr/bin/env node +/** + * Capture / migrate / verify Spicetify classmaps between Spotify CSS builds. + * + * Requires Node.js >= 24 (runs as TypeScript through type stripping). + * + * Commands + * -------- + * inventory List class tokens from CSS/spa + * migrate Migrate a classmap (CSS signatures + optional css-map.json) + * verify Score a classmap or migrate report against css-map + live CSS + * flatten Bridge a classmap into a flat css-map overlay + * key Print classmap folder key for a version + * devtools Print DevTools snippets to manually verify matched paths + * + * css-map.json signal + * ------------------- + * The CLI css-map maps *current* hashed classes -> stable semantic names + * (e.g. main-topBar-container). During migrate we: + * 1. Prefer target candidates that appear as keys in css-map + * 2. Boost candidates whose semantic name token-overlaps the classmap path + * 3. Penalize / reject high CSS similarity when semantic tokens conflict + * + * Examples + * -------- + * node scripts/classmap-capture.ts migrate \ + * --base-classmap ../classmaps/1020040/classmap.json \ + * --base-css-dir ../xpui-archive/1.2.40.599 \ + * --target-spa "/Applications/Spotify.app/Contents/Resources/Apps/xpui.spa" \ + * --css-map css-map.json \ + * --out classmaps/1020092/classmap.json \ + * --report classmaps/1020092/report.json \ + * --allow-partial + * + * node scripts/classmap-capture.ts verify \ + * --classmap classmaps/1020092/classmap.json \ + * --report classmaps/1020092/report.json \ + * --css-map css-map.json \ + * --target-spa "/Applications/Spotify.app/Contents/Resources/Apps/xpui.spa" \ + * --target-version 1.2.92.148 \ + * --out classmaps/1020092/verify.json + * + * node scripts/classmap-capture.ts devtools --report classmaps/1020092/report.json + */ + +import crypto from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import { parseArgs } from "node:util"; +import zlib from "node:zlib"; + +type Json = null | boolean | number | string | Json[] | { [key: string]: Json }; +type Classmap = { [key: string]: Classmap | string }; +type Row = Record; +type Report = { matched?: Row[]; identity?: Row[]; unmatched?: Row[]; stats?: Row }; +/** A rule body as a set of `prop\0value` pairs. */ +type Signature = Set; +/** Class -> its distinct signatures, keyed by their canonical form. */ +type Signatures = Map>; + +const PROP_RE = /([a-zA-Z-]+)\s*:\s*([^;]+);/g; +const CLASS_RE = /\.([A-Za-z0-9_-]+)/g; +const SKIPPED_PROPS = new Set(["content", "src", "animation-name"]); + +/** Python's round(): the exact binary value, halves to even. */ +export function pyRound(x: number, digits: number): number { + // toFixed accepts up to 100 digits, enough for the exact decimal expansion that decides a half. + // oxlint-disable-next-line number-arg-out-of-range + const [int, frac] = Math.abs(x).toFixed(100).split("."); + let n = BigInt(int + frac.slice(0, digits)); + const rest = frac.slice(digits); + const half = "5".padEnd(rest.length, "0"); + if (rest > half || (rest === half && n % 2n === 1n)) n += 1n; + const out = Number(n) / 10 ** digits; + return x < 0 ? -out : out; +} + +function isLowerCased(token: string): boolean { + return token.toLowerCase() === token && token.toUpperCase() !== token; +} + +export function isHashLike(token: string): boolean { + // Min length 4: real Spotify hashes can be short (e.g. the 1.2.45 + // play button class "cLkUmr" is 6 chars). Semantic/Encore classes are + // filtered by the lowercase-dash and prefix rules below, and matchers + // still require CSS or semantic evidence, so a low bound is safe here. + const length = Array.from(token).length; + if (length < 4 || length > 25) return false; + if (token.includes("-") && isLowerCased(token)) return false; + if (token.startsWith("spotify") || token.startsWith("encore")) return false; + const hasUpper = /\p{Lu}/u.test(token); + const hasLower = /\p{Ll}/u.test(token); + const hasDigit = /\p{Nd}/u.test(token); + if (hasUpper && hasLower) return true; + return token.includes("_") && (hasDigit || hasUpper); +} + +/** Decodes UTF-8 dropping invalid bytes, as Python's errors="ignore" does. */ +function decodeIgnoringErrors(buf: Buffer): string { + const text = buf.toString("utf8"); + return buf.includes(Buffer.from([0xef, 0xbf, 0xbd])) ? text : text.replaceAll("�", ""); +} + +/** Reads every `.css` entry of a zip archive in central-directory order. */ +function readZipCss(file: string): string[] { + const zip = fs.readFileSync(file); + let end = zip.length - 22; + while (end >= 0 && zip.readUInt32LE(end) !== 0x06054b50) end--; + if (end < 0) throw new Error(`${file}: not a zip archive`); + const count = zip.readUInt16LE(end + 10); + let offset = zip.readUInt32LE(end + 16); + const chunks: string[] = []; + for (let i = 0; i < count; i++) { + if (zip.readUInt32LE(offset) !== 0x02014b50) throw new Error(`${file}: corrupt central directory`); + const method = zip.readUInt16LE(offset + 10); + const compressedSize = zip.readUInt32LE(offset + 20); + const nameLength = zip.readUInt16LE(offset + 28); + const extraLength = zip.readUInt16LE(offset + 30); + const commentLength = zip.readUInt16LE(offset + 32); + const localOffset = zip.readUInt32LE(offset + 42); + const name = zip.toString("utf8", offset + 46, offset + 46 + nameLength); + offset += 46 + nameLength + extraLength + commentLength; + if (!name.endsWith(".css")) continue; + const dataStart = localOffset + 30 + zip.readUInt16LE(localOffset + 26) + zip.readUInt16LE(localOffset + 28); + const data = zip.subarray(dataStart, dataStart + compressedSize); + if (method === 0) chunks.push(decodeIgnoringErrors(data)); + else if (method === 8) chunks.push(decodeIgnoringErrors(zlib.inflateRawSync(data))); + else throw new Error(`${file}: ${name} uses unsupported compression method ${method}`); + } + return chunks; +} + +/** Orders paths segment by segment, as Python sorts pathlib paths. */ +function comparePaths(a: string, b: string): number { + const pa = a.split(path.sep); + const pb = b.split(path.sep); + for (let i = 0; i < Math.min(pa.length, pb.length); i++) { + if (pa[i] !== pb[i]) return pa[i] < pb[i] ? -1 : 1; + } + return pa.length - pb.length; +} + +function cssFilesUnder(dir: string): string[] { + return (fs.readdirSync(dir, { recursive: true, encoding: "utf8" }) as string[]) + .filter((name) => name.endsWith(".css")) + .sort(comparePaths) + .map((name) => path.join(dir, name)) + .filter((file) => fs.statSync(file).isFile()); +} + +/** Reads a loose CSS file with the newline translation of Python's read_text. */ +function readTextFile(file: string): string { + return decodeIgnoringErrors(fs.readFileSync(file)).replace(/\r\n?/g, "\n"); +} + +export function readCssSources(spa?: string, cssDir?: string, cssFiles: string[] = []): string { + const chunks: string[] = []; + if (spa) chunks.push(...readZipCss(spa)); + if (cssDir) for (const file of cssFilesUnder(cssDir)) chunks.push(readTextFile(file)); + for (const file of cssFiles) chunks.push(readTextFile(file)); + if (!chunks.length) throw new Error("no CSS sources provided (use --spa, --css-dir, or --css)"); + return chunks.join("\n"); +} + +export function splitRules(source: string): [string, string][] { + const css = source.replace(/\/\*[\s\S]*?\*\//g, ""); + const rules: [string, string][] = []; + let i = 0; + const n = css.length; + while (i < n) { + const start = css.indexOf("{", i); + if (start < 0) break; + const selector = css.slice(i, start).trim(); + let depth = 1; + let j = start + 1; + while (j < n && depth) { + if (css[j] === "{") depth++; + else if (css[j] === "}") depth--; + j++; + } + const body = css.slice(start + 1, j - 1); + if (selector && !selector.startsWith("@")) rules.push([selector, body]); + i = j; + } + return rules; +} + +const GROUPING_AT_RULE = /^@(?:layer|media|supports|container|scope|document)\b/; + +/** + * Every style rule, including those nested in grouping at-rules such as + * `@layer encore { ... }`, where Spotify ships its Encore component styles. + * A statement at-rule (`@layer a, b;`) ends at its semicolon. + */ +export function allRules(source: string): [string, string][] { + const rules: [string, string][] = []; + for (const [prelude, body] of splitBlocks(source.replace(/\/\*[\s\S]*?\*\//g, ""))) { + if (GROUPING_AT_RULE.test(prelude)) rules.push(...allRules(body)); + else if (!prelude.startsWith("@")) rules.push([prelude, body]); + } + return rules; +} + +function splitBlocks(css: string): [string, string][] { + const blocks: [string, string][] = []; + let i = 0; + while (i < css.length) { + const start = css.indexOf("{", i); + if (start < 0) break; + const prelude = css.slice(i, start); + let depth = 1; + let j = start + 1; + while (j < css.length && depth) { + if (css[j] === "{") depth++; + else if (css[j] === "}") depth--; + j++; + } + const selector = prelude.slice(prelude.lastIndexOf(";") + 1).trim(); + if (selector) blocks.push([selector, css.slice(start + 1, j - 1)]); + i = j; + } + return blocks; +} + +function normalizeProps(body: string): Signature { + const props: Signature = new Set(); + for (const m of body.matchAll(PROP_RE)) { + const prop = m[1].trim().toLowerCase(); + const value = m[2].trim().toLowerCase().replace(/\s+/g, " "); + if (prop.startsWith("--") || SKIPPED_PROPS.has(prop)) continue; + props.add(`${prop}\0${value}`); + } + return props; +} + +const signatureKey = (sig: Signature) => [...sig].sort().join("\u0001"); + +export function classSignatures(css: string): Signatures { + const out: Signatures = new Map(); + for (const [selector, body] of splitRules(css)) { + const props = normalizeProps(body); + if (!props.size) continue; + const key = signatureKey(props); + for (const m of selector.matchAll(CLASS_RE)) { + let sigs = out.get(m[1]); + if (!sigs) out.set(m[1], (sigs = new Map())); + sigs.set(key, props); + } + } + return out; +} + +export function inventoryClasses(css: string): Map { + const counts = new Map(); + for (const [selector] of allRules(css)) { + for (const m of selector.matchAll(CLASS_RE)) counts.set(m[1], (counts.get(m[1]) ?? 0) + 1); + } + return new Map([...counts].sort((a, b) => b[1] - a[1] || (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))); +} + +function jaccard(a: Signature, b: Signature): number { + if (!a.size && !b.size) return 1; + if (!a.size || !b.size) return 0; + let inter = 0; + for (const item of a) if (b.has(item)) inter++; + const union = a.size + b.size - inter; + return union ? inter / union : 0; +} + +export function loadCssMap(file?: string | null): Map { + if (!file) return new Map(); + const data = JSON.parse(fs.readFileSync(file, "utf8")); + if (typeof data !== "object" || data === null || Array.isArray(data)) throw new Error("css-map must be a JSON object"); + return new Map(Object.entries(data).map(([k, v]) => [String(k), String(v)])); +} + +// Path segment -> semantic tokens expected in css-map values. +const PATH_HINTS: Record = { + topbar: ["topbar", "topbarcontent", "globalnav"], + playbar: ["nowplayingbar", "playercontrols", "playbackbar"], + navbar: ["navbar", "navlink", "navitem", "yourlibraryx"], + search_chips: ["searchcategory", "chip", "filterchips"], + search_box: ["search", "filterbox", "searchinput", "globalnav"], + widget_generator: ["embedwidgetgenerator", "embedwidget"], + track_credits: ["trackcredits"], + settings: ["settings", "desktopsettings"], + sort_box: ["sortbox", "sortdropdown", "sort"], + scrollable_text: ["marquee"], + context_menu: ["contextmenu"], + tracklist: ["tracklist"], + chip: ["chip"], + close: ["closebtn", "close"], + menu_item: ["menuitem", "contextmenu"], + expand_button: ["expandbutton", "searchinput"], + upgrade_button: ["upgradebutton", "upgrade"], + indicator: ["indicator"], +}; + +// Region segment -> required substrings in the lowercased full semantic name. +// Matching is against the full css-map value, not tokenized pieces, so +// "nowPlayingBar" and "actionBar" stay distinct. +const REGION_REQUIRED: Record = { + topbar: ["topbar", "globalnav"], + playbar: ["nowplayingbar", "player-controls", "playercontrols", "playbackbar"], + navbar: ["navbar", "navlink", "navitem", "yourlibraryx", "mainnav"], + track_credits: ["trackcredits"], + widget_generator: ["embedwidget"], + settings: ["settings"], + tracklist: ["tracklist"], + search_chips: ["search", "chip", "filterchip"], + search_box: ["search", "filterbox", "globalnav"], + context_menu: ["contextmenu"], + sort_box: ["sort"], +}; + +// If path contains the region, reject semantics that match these substrings. +const REGION_FORBIDDEN: Record = { + playbar: ["actionbar", "tracklist", "watchfeed", "entityheader", "rowplay", "rowimage"], + topbar: ["actionbar", "entityheader", "watchfeed"], + settings: ["equalizer", "nowplayingview", "actionbar"], + widget_generator: ["watchfeed", "filterbox", "sortbox"], + search_chips: ["nowplaying", "actionbar", "tracklist"], + search_box: ["yourlibrary", "actionbar", "tracklist"], + navbar: ["topbar", "nowplaying", "actionbar"], + tracklist: ["actionbar", "nowplayingbar", "globalnav"], + context_menu: ["settings", "equalizer", "sortbox"], +}; + +// Leaf segment requirements against full semantic string. +const LEAF_REQUIRED: Record = { + close: ["close"], + content: ["content", "body", "iframe", "code"], + header: ["header", "title"], + chip: ["chip"], + indicator: ["indicator", "active", "playing"], + expand_button: ["expand", "search", "filter", "clear"], +}; + +// Leaf forbidden when this leaf is the path tail. +const LEAF_FORBIDDEN: Record = { + content: ["close"], // content container must not resolve to closeBtn only +}; + +function lookup(table: Record, key: string): T | undefined { + return Object.hasOwn(table, key) ? table[key] : undefined; +} + +const GENERIC_SEGMENTS = new Set(["main", "wrapper", "container", "button", "left", "right", "list", "button_t"]); + +/** Lowercase and strip separators so nowPlayingBar -> nowplayingbar. */ +function normalizeSemantic(name: string): string { + return name.replace(/[-_.\s]+/g, "").toLowerCase(); +} + +function pathSegments(parts: string[]): string[] { + return parts.map((seg) => seg.split("__")[0]); +} + +/** + * Return 0..1 agreement between a nested classmap path and a css-map name. + * + * Uses full-string substring checks (not bag-of-tokens) so ActionBar cannot + * satisfy a playbar requirement meant for nowPlayingBar. + */ +export function semanticFit(parts: string[], semantic: string): number { + if (!semantic) return 0; + const segs = pathSegments(parts); + const full = normalizeSemantic(semantic); + const rawLower = semantic.toLowerCase(); + + for (const seg of segs) { + const forbidden = lookup(REGION_FORBIDDEN, seg); + if (forbidden?.some((f) => full.includes(f))) return 0; + const required = lookup(REGION_REQUIRED, seg); + if (required && !required.some((r) => full.includes(r.replaceAll("-", "")) || rawLower.includes(r))) return 0; + } + + for (const [i, seg] of segs.entries()) { + const isTail = i === segs.length - 1; + // For non-tail "content", still require content-ish names + if (seg === "content") { + if (!["content", "body", "iframe", "code"].some((x) => full.includes(x))) return 0; + // pure close button is never a content node + if (full.includes("close") && !full.includes("content")) return 0; + } + if (isTail) { + const required = lookup(LEAF_REQUIRED, seg); + if (required && !required.some((r) => full.includes(r))) return 0; + const forbidden = lookup(LEAF_FORBIDDEN, seg); + if (forbidden?.some((f) => full.includes(f)) && !(required ?? []).some((r) => full.includes(r))) return 0; + } + } + + // Score: count how many specific path segments are reflected in the name. + let specific = segs.filter((s) => !GENERIC_SEGMENTS.has(s)); + if (!specific.length) specific = segs.slice(-1); + + let hits = 0; + for (const s of specific) { + const hints = new Set([...(lookup(PATH_HINTS, s) ?? [s.replaceAll("_", "")]), s.replaceAll("_", ""), s]); + if ([...hints].some((h) => Array.from(h).length >= 3 && (full.includes(normalizeSemantic(h)) || rawLower.includes(h.toLowerCase())))) { + hits++; + } + } + if (!hits) return 0; + return Math.min(1, hits / Math.max(1, specific.length)); +} + +function pathHintTokens(parts: string[]): Set { + const hints = new Set(); + for (const seg of pathSegments(parts)) { + hints.add(seg.toLowerCase().replaceAll("-", "").replaceAll("_", "")); + for (const hint of lookup(PATH_HINTS, seg) ?? []) hints.add(hint); + } + return hints; +} + +function cssSimilarity(src?: Map, tgt?: Map): number { + if (!src?.size || !tgt?.size) return 0; + let best = 0; + for (const s of src.values()) for (const t of tgt.values()) best = Math.max(best, jaccard(s, t)); + return best; +} + +const hasStrictRegion = (parts: string[]) => pathSegments(parts).some((seg) => Object.hasOwn(REGION_REQUIRED, seg)); + +function scoreCandidate(parts: string[], cand: string, src: Map, target: Signatures, cssMap: Map): Row { + const cssScore = cssSimilarity(src, target.get(cand)); + const inMap = cssMap.has(cand); + const semantic = cssMap.get(cand) ?? ""; + const semScore = semantic ? semanticFit(parts, semantic) : 0; + + // Combined score: css similarity is base; css-map presence and semantic agreement boost. + let score = cssScore; + if (inMap && semScore > 0) score += 0.15; + if (semScore > 0) score += 0.45 * semScore; + else { + // css-map hit with zero semantic fit is usually a false friend. + if (inMap && cssScore >= 0.5) score -= 0.35; + // For strict regions (playbar/topbar/...), refuse pure CSS matches + // without semantic agreement — that is how actionBar leaks in. + if (hasStrictRegion(parts)) score = Math.min(score, cssScore * 0.25); + } + + return { + class: cand, + score: pyRound(Math.max(0, Math.min(score, 1.5)), 4), + css_score: pyRound(cssScore, 4), + semantic_score: pyRound(semScore, 4), + in_css_map: inMap, + semantic: semantic || null, + }; +} + +function bestMatch( + parts: string[], + src: Map, + target: Signatures, + targetIndex: Map>, + cssMap: Map, + threshold: number, +): Row | null { + if (!src.size) return null; + + const candidates = new Set(); + for (const key of src.keys()) for (const cls of targetIndex.get(key) ?? []) candidates.add(cls); + + // Always consider css-map keys that have decent semantic overlap as + // candidates, but only when they actually exist in the target CSS: + // a css-map entry with zero target presence has no evidence behind it. + if (pathHintTokens(parts).size && cssMap.size) { + for (const [h, sem] of cssMap) { + if (isHashLike(h) && target.has(h) && semanticFit(parts, sem) >= 0.5) candidates.add(h); + } + } + + // Candidates are visited in name order so ties rank the same on every run. + const pool = [...(candidates.size ? candidates : target.keys())].sort(); + const scored = pool.filter((cand) => isHashLike(cand) && target.has(cand)).map((cand) => scoreCandidate(parts, cand, src, target, cssMap)); + if (!scored.length) return null; + const num = (row: Row, field: string) => row[field] as number; + scored.sort( + (a, b) => num(b, "score") - num(a, "score") || num(b, "semantic_score") - num(a, "semantic_score") || num(b, "css_score") - num(a, "css_score"), + ); + const best = scored[0]; + // Require either solid css match or solid semantic agreement. + // Strict regions need semantic_score > 0 to avoid actionBar/etc. leaks. + if (num(best, "score") < threshold) return { ...best, rejected: true, next: scored.slice(1, 4) }; + if (hasStrictRegion(parts) && num(best, "semantic_score") <= 0) { + return { ...best, rejected: true, reason: "strict region requires semantic agreement", next: scored.slice(1, 4) }; + } + if (num(best, "semantic_score") <= 0 && num(best, "css_score") < 0.75) return { ...best, rejected: true, next: scored.slice(1, 4) }; + // An exact tie gives no evidence for either class, so the leaf is left for a human to decide. + const same = (row: Row) => ["score", "semantic_score", "css_score"].every((field) => row[field] === best[field]); + const tied = scored + .slice(1) + .filter(same) + .map((row) => row.class as string); + if (tied.length) return { ...best, rejected: true, reason: `tied with ${tied.length} other candidate(s)`, tied, next: scored.slice(1, 4) }; + return { ...best, alternatives: scored.slice(1, 4) }; +} + +function buildTargetIndex(target: Signatures): Map> { + const index = new Map>(); + for (const [cls, sigs] of target) { + for (const key of sigs.keys()) { + let classes = index.get(key); + if (!classes) index.set(key, (classes = new Set())); + classes.add(cls); + } + } + return index; +} + +export function iterLeaves(node: unknown, parts: string[] = []): [string[], string][] { + if (typeof node === "string") return [[parts, node]]; + if (typeof node !== "object" || node === null || Array.isArray(node)) return []; + return Object.entries(node).flatMap(([key, value]) => iterLeaves(value, [...parts, key])); +} + +function setLeaf(root: Classmap, parts: string[], value: string): void { + let cur = root; + for (const key of parts.slice(0, -1)) { + const next = cur[key]; + if (typeof next === "object") cur = next; + else cur = cur[key] = {}; + } + cur[parts.at(-1) as string] = value; +} + +export function migrateClassmap( + baseMap: Classmap, + baseSigs: Signatures, + targetSigs: Signatures, + cssMap: Map, + threshold: number, +): [Classmap, Required] { + const targetIndex = buildTargetIndex(targetSigs); + const out: Classmap = structuredClone(baseMap); + const report: Required = { matched: [], unmatched: [], identity: [], stats: {} }; + + const leaves = iterLeaves(baseMap); + let matched = 0; + let identity = 0; + for (const [parts, oldHash] of leaves) { + const dotted = parts.join("."); + + if (targetSigs.has(oldHash) && isHashLike(oldHash)) { + setLeaf(out, parts, oldHash); + report.identity.push({ + path: dotted, + class: oldHash, + in_css_map: cssMap.has(oldHash), + semantic: cssMap.get(oldHash) ?? null, + method: "identity", + }); + identity++; + matched++; + continue; + } + + const src = baseSigs.get(oldHash); + if (!src?.size) { + // No base CSS signature for the old class. Fall back to a + // semantic-only match via css-map, with a higher bar (0.75) + // because there is zero CSS similarity evidence behind it. + let bestSem: [string, string, number] | null = null; + let bestScore = 0; + let tied: string[] = []; + for (const [h, sem] of cssMap) { + if (!targetSigs.has(h) || !isHashLike(h)) continue; + const s = semanticFit(parts, sem); + if (s > bestScore) { + bestScore = s; + bestSem = [h, sem, s]; + tied = []; + } else if (s === bestScore && bestSem) { + tied.push(h); + } + } + if (bestSem && bestScore >= 0.75 && tied.length) { + report.unmatched.push({ + path: dotted, + old: oldHash, + reason: `${tied.length + 1} semantic-only candidates tie`, + tied: [bestSem[0], ...tied], + stale: true, + kept: oldHash, + }); + setLeaf(out, parts, oldHash); + continue; + } + if (bestSem && bestScore >= 0.75) { + const [newCls, sem, s] = bestSem; + setLeaf(out, parts, newCls); + report.matched.push({ + path: dotted, + old: oldHash, + new: newCls, + score: pyRound(0.2 + 0.5 * s, 4), + css_score: 0, + semantic_score: pyRound(s, 4), + in_css_map: true, + semantic: sem, + method: "semantic-only", + css_evidence: false, + }); + matched++; + continue; + } + report.unmatched.push({ path: dotted, old: oldHash, reason: "old class not found in base CSS", stale: true, kept: oldHash }); + setLeaf(out, parts, oldHash); + continue; + } + + const best = bestMatch(parts, src, targetSigs, targetIndex, cssMap, threshold); + if (!best || best.rejected) { + report.unmatched.push({ path: dotted, old: oldHash, reason: "no confident target", best, stale: true, kept: oldHash }); + setLeaf(out, parts, oldHash); + continue; + } + + setLeaf(out, parts, best.class as string); + report.matched.push({ + path: dotted, + old: oldHash, + new: best.class, + score: best.score, + css_score: best.css_score, + semantic_score: best.semantic_score, + in_css_map: best.in_css_map, + semantic: best.semantic, + method: "css+semantic", + alternatives: best.alternatives ?? [], + }); + matched++; + } + + report.stats = { + leaves: leaves.length, + matched, + identity, + migrated: matched - identity, + unmatched: leaves.length - matched, + stale_kept: leaves.length - matched, + match_rate: leaves.length ? pyRound(matched / leaves.length, 4) : 0, + threshold, + css_map_entries: cssMap.size, + matched_in_css_map: report.matched.filter((m) => m.in_css_map).length, + }; + return [out, report]; +} + +export function versionToKey(version: string): string { + const parts = version.trim().split("."); + if (parts.length < 3 || !parts.slice(0, 3).every((p) => /^\d+$/.test(p))) { + throw new Error(`need major.minor.patch, got ${JSON.stringify(version)}`); + } + const [major, minor, patch] = parts.slice(0, 3).map(Number); + return `${major}${String(minor).padStart(2, "0")}${String(patch).padStart(4, "0")}`; +} + +export function confidenceLabel(m: Row): string { + // Class survived unchanged in the target CSS: strongest evidence. + if (m.method === "identity") return "high"; + const sem = (m.semantic_score as number) || 0; + const css = (m.css_score as number) || 0; + const inMap = Boolean(m.in_css_map); + // No CSS similarity evidence at all (semantic-only guess). + if (css === 0) return "low"; + if (inMap && sem >= 0.5 && css >= 0.35) return "high"; + if (inMap && sem >= 0.5) return "medium-semantic"; + if (css >= 0.75 && isHashLike(((m.new || m.class) as string) || "")) return "medium-css"; + if (css >= 0.55) return "low"; + return "reject"; +} + +export function verifyClassmap( + classmap: Classmap, + targetCss: string, + cssMap: Map, + report: Report | null = null, + targetVersion: string | null = null, + classmapSha256: string | null = null, +): { target: Row; summary: Record; rows: Row[] } { + const targetSigs = classSignatures(targetCss); + const inventory = inventoryClasses(targetCss); + const matchedRows = report ? [...(report.matched ?? []), ...(report.identity ?? [])] : []; + + const rows = iterLeaves(classmap).map(([parts, cls]) => { + const dotted = parts.join("."); + const classes = cls.split(/\s+/).filter(Boolean); + const semantics = classes.filter((token) => cssMap.has(token)).map((token) => cssMap.get(token) as string); + const missingClasses = classes.filter((token) => !targetSigs.has(token) && !inventory.has(token)); + const row: Row = { + path: dotted, + class: cls, + classes, + missing_classes: missingClasses, + in_target_css: !missingClasses.length, + css_rule_count: classes.reduce((sum, token) => sum + (targetSigs.get(token)?.size ?? 0), 0), + selector_hits: classes.reduce((sum, token) => sum + (inventory.get(token) ?? 0), 0), + in_css_map: semantics.length > 0, + semantics, + semantic: cssMap.get(cls) || (semantics.length ? semantics.join(" ") : null), + semantic_score: pyRound(Math.max(0, ...semantics.map((name) => semanticFit(parts, name))), 4), + }; + if (report) { + const m = matchedRows.find((entry) => entry.path === dotted); + if (m) { + row.migrate_score = m.score ?? null; + row.migrate_confidence = (m.confidence as string) || confidenceLabel(m); + row.method = m.method ?? null; + } else { + // unmatched leaves keep old hash often + row.migrate_confidence = "unmatched-or-stale"; + } + } + const confidence = row.migrate_confidence as string | undefined; + if (row.in_css_map && (row.semantic_score as number) >= 0.5 && row.in_target_css && (confidence === "high" || confidence === "medium-semantic")) { + row.verdict = "likely_good"; + } else if (row.in_target_css && ["high", "medium-semantic", "medium-css"].includes(confidence ?? "")) { + row.verdict = "plausible"; + } else if (!row.in_target_css) { + row.verdict = "missing_in_css"; + } else { + row.verdict = "needs_manual_check"; + } + return row; + }); + + const summary: Record = {}; + for (const row of rows) summary[row.verdict as string] = (summary[row.verdict as string] ?? 0) + 1; + return { + target: { + spotify_version: targetVersion, + css_sha256: crypto.createHash("sha256").update(targetCss, "utf8").digest("hex"), + classmap_sha256: classmapSha256, + }, + summary, + rows, + }; +} + +/** + * Bridge a nested classmap into a flat css-map overlay (hash -> semantic). + * + * The semantic name for a leaf is the name themes already target, resolved + * in priority order: + * 1. css-map name of the BASE classmap's hash at the same path (the + * established, theme-facing name for that component), + * 2. the migrate report's semantic for the path. + * Leaves whose new hash is already in the global css-map are skipped: the + * global map already rewrites them, so the overlay only carries verified + * per-version corrections the global map lacks. Leaves with no resolvable + * semantic are skipped with a warning. Stale leaves and leaves not yet + * verified (per META.json) are skipped; an unverified required path is a + * loud warning. + */ +export function flattenClassmap( + classmap: Classmap, + baseMap: Classmap | null, + cssMap: Map, + report: Report | null, + meta: Record | null, +): [Record, string[]] { + const stale = new Set((meta?.stale_leaves as string[]) ?? []); + const unverified = new Set((meta?.unverified_leaves as string[]) ?? []); + for (const u of report?.unmatched ?? []) if (u.stale) stale.add((u.path as string) ?? ""); + + const baseAt = new Map(baseMap ? iterLeaves(baseMap).map(([parts, h]) => [parts.join("."), h]) : []); + const reportSemantic = new Map(); + for (const m of [...(report?.matched ?? []), ...(report?.identity ?? [])]) { + if (m.semantic) reportSemantic.set((m.path as string) ?? "", m.semantic as string); + } + + const overlay: Record = {}; + const skipped: string[] = []; + for (const [parts, h] of iterLeaves(classmap)) { + const dotted = parts.join("."); + if (stale.has(dotted) || unverified.has(dotted)) continue; + if (!isHashLike(h)) { + skipped.push(`${dotted}: '${h}' not hash-like`); + continue; + } + if (cssMap.has(h)) continue; + const semantic = cssMap.get(baseAt.get(dotted) ?? "") || reportSemantic.get(dotted); + if (!semantic) { + skipped.push(`${dotted}: no semantic name for ${h}`); + continue; + } + if (Object.hasOwn(overlay, h) && overlay[h] !== semantic) { + skipped.push(`${dotted}: ${h} already named '${overlay[h]}', ignoring conflicting '${semantic}'`); + continue; + } + overlay[h] = semantic; + } + + for (const [leaf, status] of Object.entries((meta?.required_paths as Record) ?? {})) { + if (!String(status).startsWith("verified")) skipped.push(`WARNING: required path ${leaf} is ${String(status)}`); + } + return [overlay, skipped]; +} + +function sortedKeys(value: Json): Json { + if (Array.isArray(value)) return value.map(sortedKeys); + if (value && typeof value === "object") { + return Object.fromEntries( + Object.keys(value) + .sort() + .map((key) => [key, sortedKeys(value[key])]), + ); + } + return value; +} + +function writeJson(file: string, value: unknown, sortKeys = false): void { + fs.mkdirSync(path.dirname(path.resolve(file)), { recursive: true }); + fs.writeFileSync(file, `${JSON.stringify(sortKeys ? sortedKeys(value as Json) : value, null, 2)}\n`); +} + +/** Formats a report value for console output. */ +function text(value: Json | undefined): string { + return typeof value === "object" && value !== null ? JSON.stringify(value) : String(value); +} + +const readJson = (file: string) => JSON.parse(fs.readFileSync(file, "utf8")); + +const cssMapPath = (value: string | undefined) => (value === "" ? null : value); + +const COMMANDS: Record number> = { + inventory(argv) { + const { values } = parseArgs({ + args: argv, + options: { + spa: { type: "string" }, + "css-dir": { type: "string" }, + css: { type: "string", multiple: true }, + top: { type: "string", default: "40" }, + out: { type: "string" }, + }, + }); + const counts = inventoryClasses(readCssSources(values.spa, values["css-dir"], values.css)); + const items = [...counts].filter(([cls]) => isHashLike(cls)); + console.log(`hash-like classes: ${items.length} (of ${counts.size} selector tokens)`); + for (const [cls, n] of items.slice(0, Number(values.top))) console.log(`${String(n).padStart(5)} ${cls}`); + if (values.out) { + writeJson(values.out, Object.fromEntries(items)); + console.log(`wrote ${values.out}`); + } + return 0; + }, + + migrate(argv) { + const { values } = parseArgs({ + args: argv, + options: { + "base-classmap": { type: "string" }, + "base-spa": { type: "string" }, + "base-css-dir": { type: "string" }, + "base-css": { type: "string", multiple: true }, + "target-spa": { type: "string" }, + "target-css-dir": { type: "string" }, + "target-css": { type: "string", multiple: true }, + "css-map": { type: "string", default: "css-map.json" }, + out: { type: "string" }, + report: { type: "string" }, + threshold: { type: "string", default: "0.5" }, + "allow-partial": { type: "boolean", default: false }, + }, + }); + if (!values["base-classmap"] || !values.out) throw new Error("migrate requires --base-classmap and --out"); + const baseMap: Classmap = readJson(values["base-classmap"]); + const baseCss = readCssSources(values["base-spa"], values["base-css-dir"], values["base-css"]); + const targetCss = readCssSources(values["target-spa"], values["target-css-dir"], values["target-css"]); + const cssMap = loadCssMap(cssMapPath(values["css-map"])); + + console.log("Parsing base CSS signatures…"); + const baseSigs = classSignatures(baseCss); + console.log(` base classes with signatures: ${baseSigs.size}`); + console.log("Parsing target CSS signatures…"); + const targetSigs = classSignatures(targetCss); + console.log(` target classes with signatures: ${targetSigs.size}`); + console.log(` css-map entries: ${cssMap.size}`); + + const [migrated, report] = migrateClassmap(baseMap, baseSigs, targetSigs, cssMap, Number(values.threshold)); + for (const m of report.matched) m.confidence = confidenceLabel(m); + + writeJson(values.out, migrated, true); + console.log(`wrote classmap ${values.out}`); + if (values.report) { + writeJson(values.report, report); + console.log(`wrote report ${values.report}`); + } + + const stats = report.stats; + console.log( + `stats: leaves=${text(stats.leaves)} matched=${text(stats.matched)} identity=${text(stats.identity)} migrated=${text(stats.migrated)} ` + + `unmatched=${text(stats.unmatched)} rate=${text(stats.match_rate)} in_css_map=${text(stats.matched_in_css_map)}`, + ); + if (stats.stale_kept) { + console.log(`warning: ${text(stats.stale_kept)} leaves kept their old (stale) hash; see report.unmatched entries marked stale=true`); + } + const confidence: Record = {}; + for (const m of report.matched) confidence[m.confidence as string] = (confidence[m.confidence as string] ?? 0) + 1; + console.log("confidence:", JSON.stringify(confidence)); + return stats.unmatched === 0 || values["allow-partial"] ? 0 : 1; + }, + + verify(argv) { + const { values } = parseArgs({ + args: argv, + options: { + classmap: { type: "string" }, + report: { type: "string" }, + "css-map": { type: "string", default: "css-map.json" }, + "target-spa": { type: "string" }, + "target-css-dir": { type: "string" }, + "target-css": { type: "string", multiple: true }, + out: { type: "string" }, + "target-version": { type: "string" }, + }, + }); + if (!values.classmap) throw new Error("verify requires --classmap"); + const cssMap = loadCssMap(cssMapPath(values["css-map"])); + const targetCss = readCssSources(values["target-spa"], values["target-css-dir"], values["target-css"]); + const report: Report | null = values.report ? readJson(values.report) : null; + const classmapBytes = fs.readFileSync(values.classmap); + const out = verifyClassmap( + JSON.parse(classmapBytes.toString("utf8")), + targetCss, + cssMap, + report, + values["target-version"] ?? null, + crypto.createHash("sha256").update(classmapBytes).digest("hex"), + ); + if (values.out) { + writeJson(values.out, out); + console.log(`wrote ${values.out}`); + } + + console.log("verify summary:", JSON.stringify(out.summary)); + console.log(); + console.log(`${"VERDICT".padEnd(20)} ${"CONF".padEnd(16)} ${"PATH".padEnd(45)} CLASS -> semantic`); + const sortKey = (r: Row) => [text(r.verdict), text(r.path)]; + const byVerdictThenPath = (a: Row, b: Row) => { + const [ka, kb] = [sortKey(a), sortKey(b)]; + return ka[0] !== kb[0] ? (ka[0] < kb[0] ? -1 : 1) : ka[1] < kb[1] ? -1 : ka[1] > kb[1] ? 1 : 0; + }; + for (const r of [...out.rows].sort(byVerdictThenPath)) { + console.log( + `${text(r.verdict).padEnd(20)} ${text(r.migrate_confidence ?? "-").padEnd(16)} ${text(r.path).padEnd(45)} ` + + `${text(r.class)} -> ${text(r.semantic || "-")} (sem=${text(r.semantic_score)}, css_hits=${text(r.selector_hits)})`, + ); + } + return 0; + }, + + flatten(argv) { + const { values } = parseArgs({ + args: argv, + options: { + classmap: { type: "string" }, + "base-classmap": { type: "string" }, + "css-map": { type: "string", default: "css-map.json" }, + report: { type: "string" }, + meta: { type: "string" }, + out: { type: "string" }, + "allow-partial": { type: "boolean", default: false }, + }, + }); + if (!values.classmap || !values.out) throw new Error("flatten requires --classmap and --out"); + const [overlay, skipped] = flattenClassmap( + readJson(values.classmap), + values["base-classmap"] ? readJson(values["base-classmap"]) : null, + loadCssMap(cssMapPath(values["css-map"])), + values.report ? readJson(values.report) : null, + values.meta ? readJson(values.meta) : null, + ); + writeJson(values.out, overlay, true); + console.log(`wrote ${values.out} (${Object.keys(overlay).length} overlay entries)`); + for (const s of skipped) console.log(` skipped: ${s}`); + return skipped.length && !values["allow-partial"] ? 1 : 0; + }, + + devtools(argv) { + const { values } = parseArgs({ args: argv, options: { report: { type: "string" } } }); + if (!values.report) throw new Error("devtools requires --report"); + const report: Report = readJson(values.report); + const matched = [...(report.matched ?? []), ...(report.identity ?? [])]; + if (!matched.length) { + console.log("no matched entries in report"); + return 1; + } + console.log("// Paste into Spotify DevTools console (Ctrl+Shift+I / Cmd+Opt+I)"); + console.log("// Requires spicetify enable-devtools or employee/devtools flags."); + console.log("(function verifyClassmapMatches() {"); + console.log(" const checks = ["); + for (const m of matched) { + const cls = JSON.stringify(m.new || m.class); + const confidence = JSON.stringify((m.confidence as string) || confidenceLabel(m)); + console.log(` { path: ${JSON.stringify(m.path)}, cls: ${cls}, confidence: ${confidence} },`); + } + console.log(" ];"); + console.log(` const rows = checks.map(({path, cls, confidence}) => { + const nodes = document.getElementsByClassName(cls); + let sample = null; + if (nodes[0]) { + const el = nodes[0]; + sample = { + tag: el.tagName, + id: el.id || null, + aria: el.getAttribute('aria-label') || el.getAttribute('aria-labelledby') || null, + text: (el.textContent || '').trim().slice(0, 60), + parent: el.parentElement && el.parentElement.className, + }; + } + return { path, cls, confidence, count: nodes.length, sample }; + }); + console.table(rows.map(r => ({ + path: r.path, + cls: r.cls, + confidence: r.confidence, + count: r.count, + tag: r.sample && r.sample.tag, + text: r.sample && r.sample.text, + }))); + console.log('Full detail', rows); + return rows; +})();`); + console.log(); + console.log("// Tip: count===0 means the class is not in the current DOM (may be route-specific)."); + console.log("// Tip: open Home / Now Playing / Settings / a modal before running."); + return 0; + }, + + key(argv) { + const { positionals } = parseArgs({ args: argv, allowPositionals: true, options: {} }); + if (positionals.length !== 1) throw new Error("key requires one Spotify version"); + console.log(versionToKey(positionals[0])); + return 0; + }, +}; + +export function main(argv: string[]): number { + const [command, ...rest] = argv; + const run = command ? COMMANDS[command] : undefined; + if (!run) { + const header = fs.readFileSync(import.meta.filename, "utf8").split("*/")[0]; + console.log(header.replace(/^#!.*\n\/\*\*\n/, "").replace(/^ \* ?/gm, "")); + return command && command !== "--help" && command !== "-h" ? 2 : 0; + } + try { + return run(rest); + } catch (e) { + console.error(`error: ${(e as Error).message}`); + return 1; + } +} + +if (import.meta.main) process.exitCode = main(process.argv.slice(2)); diff --git a/scripts/classmap_cdp_verify.mjs b/scripts/classmap-cdp-verify.mjs similarity index 98% rename from scripts/classmap_cdp_verify.mjs rename to scripts/classmap-cdp-verify.mjs index 35363885b2..f7069cef89 100644 --- a/scripts/classmap_cdp_verify.mjs +++ b/scripts/classmap-cdp-verify.mjs @@ -16,10 +16,10 @@ * * Usage * ----- - * node scripts/classmap_cdp_verify.mjs --out-dir classmaps/1020092 - * node scripts/classmap_cdp_verify.mjs --port 9222 --mode both --out-dir classmaps/1020092 - * node scripts/classmap_cdp_verify.mjs --report --classmap --out - * CLASSMAP_OUT_DIR=classmaps/1020092 node scripts/classmap_cdp_verify.mjs --navigate --min-hit-rate 0.3 + * node scripts/classmap-cdp-verify.mjs --out-dir classmaps/1020092 + * node scripts/classmap-cdp-verify.mjs --port 9222 --mode both --out-dir classmaps/1020092 + * node scripts/classmap-cdp-verify.mjs --report --classmap --out + * CLASSMAP_OUT_DIR=classmaps/1020092 node scripts/classmap-cdp-verify.mjs --navigate --min-hit-rate 0.3 * * Paths * ----- diff --git a/scripts/test_classmap_cdp_verify.mjs b/scripts/classmap-cdp-verify.test.mjs similarity index 95% rename from scripts/test_classmap_cdp_verify.mjs rename to scripts/classmap-cdp-verify.test.mjs index 9f515bfe8b..8d2b4611f0 100644 --- a/scripts/test_classmap_cdp_verify.mjs +++ b/scripts/classmap-cdp-verify.test.mjs @@ -6,8 +6,8 @@ import { join } from "node:path"; import { test } from "node:test"; import { fileURLToPath } from "node:url"; -const source = readFileSync(new URL("./classmap_cdp_verify.mjs", import.meta.url), "utf8"); -const verifier = fileURLToPath(new URL("./classmap_cdp_verify.mjs", import.meta.url)); +const source = readFileSync(new URL("./classmap-cdp-verify.mjs", import.meta.url), "utf8"); +const verifier = fileURLToPath(new URL("./classmap-cdp-verify.mjs", import.meta.url)); test("CDP reports bind deep mode and the exact classmap digest", () => { assert.match(source, /deep:\s*args\.deep/); diff --git a/scripts/classmap_e2e.sh b/scripts/classmap-e2e.sh similarity index 81% rename from scripts/classmap_e2e.sh rename to scripts/classmap-e2e.sh index d118620858..126bef5290 100755 --- a/scripts/classmap_e2e.sh +++ b/scripts/classmap-e2e.sh @@ -2,12 +2,13 @@ # Full classmap pipeline: migrate → static verify → CDP e2e DOM verify. # # Usage: -# ./scripts/classmap_e2e.sh -# ./scripts/classmap_e2e.sh --skip-migrate --deep -# SPOTIFY_SPA=/path/to/xpui.spa ./scripts/classmap_e2e.sh +# ./scripts/classmap-e2e.sh +# ./scripts/classmap-e2e.sh --skip-migrate --deep +# SPOTIFY_SPA=/path/to/xpui.spa ./scripts/classmap-e2e.sh # # Env: # SPOTIFY_SPA Path to xpui.spa (default: macOS Spotify.app) +# SPOTIFY_VERSION Exact Spotify version recorded in the static report # BASE_CLASSMAP Base classmap JSON (default: ../classmaps/1020040/...) # BASE_CSS_DIR Base CSS dir from xpui-archive # OUT_DIR Output dir (default: classmaps/1020092) @@ -80,7 +81,7 @@ OUT_DIR="${OUT_DIR:-$ROOT/classmaps/1020092}" CSS_MAP="${CSS_MAP:-$ROOT/css-map.json}" CDP_PORT="${CDP_PORT:-9222}" SPICETIFY="${SPICETIFY:-$HOME/.spicetify/spicetify}" -PYTHON="${PYTHON:-python3}" +SPOTIFY_VERSION="${SPOTIFY_VERSION:-}" NODE="${NODE:-node}" CLASSMAP_OUT="$OUT_DIR/classmap.json" @@ -188,45 +189,41 @@ ensure_cdp() { CFG_PATH="$cfg" CFG_BACKUP="$(mktemp -t spicetify-config-xpui)" cp "$cfg" "$CFG_BACKUP" - "$PYTHON" - "$cfg" "$CDP_PORT" <<'PY' -import sys -from pathlib import Path + "$NODE" - "$cfg" "$CDP_PORT" <<'JS' +const fs = require("node:fs"); -cfg, port = Path(sys.argv[1]), sys.argv[2] -debug_flag = f"--remote-debugging-port={port}" +const [cfg, port] = process.argv.slice(2); +const debugFlag = `--remote-debugging-port=${port}`; -def merge(value: str) -> str: - # Preserve existing flags; only append the debug port. No - # --remote-allow-origins=*: the verifier connects without an Origin - # header, so a wildcard that exposes the logged-in client to any - # local web page is unnecessary. - parts = [p for p in value.split("|") if p.strip()] - if not any("remote-debugging-port" in p for p in parts): - parts.append(debug_flag) - return "|".join(parts) +// Preserve existing flags; only append the debug port. No +// --remote-allow-origins=*: the verifier connects without an Origin +// header, so a wildcard that exposes the logged-in client to any +// local web page is unnecessary. +function merge(value) { + const parts = value.split("|").filter((p) => p.trim()); + if (!parts.some((p) => p.includes("remote-debugging-port"))) parts.push(debugFlag); + return parts.join("|"); +} -lines = [] -found = False -for line in cfg.read_text().splitlines(): - stripped = line.strip() - if stripped.startswith("spotify_launch_flags"): - _, _, value = stripped.partition("=") - lines.append(f"spotify_launch_flags = {merge(value.strip())}") - found = True - else: - lines.append(line) -if not found: - out = [] - inserted = False - for line in lines: - out.append(line) - if line.strip() == "[Setting]" and not inserted: - out.append(f"spotify_launch_flags = {debug_flag}") - inserted = True - lines = out if inserted else lines + [f"spotify_launch_flags = {debug_flag}"] -cfg.write_text("\n".join(lines) + "\n") -print("updated", cfg) -PY +let found = false; +let lines = fs.readFileSync(cfg, "utf8").split(/\r?\n/); +if (lines.at(-1) === "") lines.pop(); +lines = lines.map((line) => { + const stripped = line.trim(); + if (!stripped.startsWith("spotify_launch_flags")) return line; + found = true; + const value = stripped.slice(stripped.indexOf("=") + 1).trim(); + return `spotify_launch_flags = ${merge(stripped.includes("=") ? value : "")}`; +}); +if (!found) { + const section = lines.findIndex((line) => line.trim() === "[Setting]"); + const entry = `spotify_launch_flags = ${debugFlag}`; + if (section >= 0) lines.splice(section + 1, 0, entry); + else lines.push(entry); +} +fs.writeFileSync(cfg, `${lines.join("\n")}\n`); +console.log("updated", cfg); +JS fi fi @@ -254,7 +251,7 @@ if [[ "$SKIP_MIGRATE" -eq 0 ]]; then exit 1 fi resolve_target_css_args 1 - "$PYTHON" scripts/classmap_capture.py migrate \ + "$NODE" scripts/classmap-capture.ts migrate \ --base-classmap "$BASE_CLASSMAP" \ --base-css-dir "$BASE_CSS_DIR" \ "${TARGET_ARGS[@]}" \ @@ -270,13 +267,16 @@ fi if [[ "$SKIP_STATIC" -eq 0 ]]; then log "2/4 static verify" resolve_target_css_args 1 || resolve_target_css_args 0 - "$PYTHON" scripts/classmap_capture.py verify \ + VERSION_ARGS=() + [[ -n "$SPOTIFY_VERSION" ]] && VERSION_ARGS=(--target-version "$SPOTIFY_VERSION") + "$NODE" scripts/classmap-capture.ts verify \ --classmap "$CLASSMAP_OUT" \ --report "$REPORT_OUT" \ --css-map "$CSS_MAP" \ "${TARGET_ARGS[@]}" \ + "${VERSION_ARGS[@]+${VERSION_ARGS[@]}}" \ --out "$VERIFY_OUT" - "$PYTHON" scripts/classmap_capture.py devtools \ + "$NODE" scripts/classmap-capture.ts devtools \ --report "$REPORT_OUT" > "$OUT_DIR/devtools-snippet.js" || true else log "2/4 static verify skipped" @@ -295,10 +295,9 @@ if [[ "$SKIP_CDP" -eq 0 ]]; then if [[ "$RESTART" -eq 1 ]]; then RESTART_FLAG=(--restart) fi - "$NODE" scripts/classmap_cdp_verify.mjs \ + "$NODE" scripts/classmap-cdp-verify.mjs \ --port "$CDP_PORT" \ --mode both \ - --report "$REPORT_OUT" \ --classmap "$CLASSMAP_OUT" \ --css-map "$CSS_MAP" \ --out "$CDP_OUT" \ @@ -319,7 +318,7 @@ if [[ "$SKIP_FLATTEN" -eq 0 ]]; then done META_ARGS=() [[ -f "$OUT_DIR/META.json" ]] && META_ARGS=(--meta "$OUT_DIR/META.json") - "$PYTHON" scripts/classmap_capture.py flatten \ + "$NODE" scripts/classmap-capture.ts flatten \ --classmap "$CLASSMAP_OUT" \ --base-classmap "$BASE_CLASSMAP" \ --css-map "$CSS_MAP" \ diff --git a/scripts/classmap_capture.py b/scripts/classmap_capture.py deleted file mode 100644 index ad9c35bbd6..0000000000 --- a/scripts/classmap_capture.py +++ /dev/null @@ -1,1036 +0,0 @@ -#!/usr/bin/env python3 -"""Capture / migrate / verify Spicetify classmaps between Spotify CSS builds. - -Commands --------- - inventory List class tokens from CSS/spa - migrate Migrate a classmap (CSS signatures + optional css-map.json) - verify Score a classmap or migrate report against css-map + live CSS - key Print classmap folder key for a version - devtools Print DevTools snippets to manually verify matched paths - -css-map.json signal -------------------- -The CLI css-map maps *current* hashed classes -> stable semantic names -(e.g. main-topBar-container). During migrate we: - 1. Prefer target candidates that appear as keys in css-map - 2. Boost candidates whose semantic name token-overlaps the classmap path - 3. Penalize / reject high CSS similarity when semantic tokens conflict - -Examples --------- - python3 scripts/classmap_capture.py migrate \\ - --base-classmap ../classmaps/1020040/classmap.json \\ - --base-css-dir ../xpui-archive/1.2.40.599 \\ - --target-spa "/Applications/Spotify.app/Contents/Resources/Apps/xpui.spa" \\ - --css-map css-map.json \\ - --out classmaps/1020092/classmap.json \\ - --report classmaps/1020092/report.json \\ - --allow-partial - - python3 scripts/classmap_capture.py verify \\ - --classmap classmaps/1020092/classmap.json \\ - --report classmaps/1020092/report.json \\ - --css-map css-map.json \\ - --target-spa "/Applications/Spotify.app/Contents/Resources/Apps/xpui.spa" \\ - --out classmaps/1020092/verify.json - - python3 scripts/classmap_capture.py devtools \\ - --report classmaps/1020092/report.json -""" - -from __future__ import annotations - -import argparse -import hashlib -import json -import re -import sys -import zipfile -from collections import defaultdict -from pathlib import Path -from typing import Any - -PROP_RE = re.compile(r"([a-zA-Z-]+)\s*:\s*([^;]+);") - - -def is_hash_like(token: str) -> bool: - # Min length 4: real Spotify hashes can be short (e.g. the 1.2.45 - # play button class "cLkUmr" is 6 chars). Semantic/Encore classes are - # filtered by the lowercase-dash and prefix rules below, and matchers - # still require CSS or semantic evidence, so a low bound is safe here. - if len(token) < 4 or len(token) > 25: - return False - if "-" in token and token.islower(): - return False - if token.startswith("spotify") or token.startswith("encore"): - return False - has_upper = any(c.isupper() for c in token) - has_lower = any(c.islower() for c in token) - has_digit = any(c.isdigit() for c in token) - if has_upper and has_lower: - return True - if "_" in token and (has_digit or has_upper): - return True - return False - - -def read_css_sources(spa: str | None, css_dir: str | None, css_files: list[str] | None) -> str: - chunks: list[str] = [] - if spa: - with zipfile.ZipFile(spa) as zf: - for name in zf.namelist(): - if name.endswith(".css"): - chunks.append(zf.read(name).decode("utf-8", errors="ignore")) - if css_dir: - for path in sorted(Path(css_dir).rglob("*.css")): - chunks.append(path.read_text(encoding="utf-8", errors="ignore")) - for f in css_files or []: - chunks.append(Path(f).read_text(encoding="utf-8", errors="ignore")) - if not chunks: - raise SystemExit("no CSS sources provided (use --spa, --css-dir, or --css)") - return "\n".join(chunks) - - -def split_rules(css: str) -> list[tuple[str, str]]: - css = re.sub(r"/\*.*?\*/", "", css, flags=re.S) - rules: list[tuple[str, str]] = [] - i, n = 0, len(css) - while i < n: - start = css.find("{", i) - if start < 0: - break - selector = css[i:start].strip() - depth = 1 - j = start + 1 - while j < n and depth: - if css[j] == "{": - depth += 1 - elif css[j] == "}": - depth -= 1 - j += 1 - body = css[start + 1 : j - 1] - if selector and not selector.lstrip().startswith("@"): - rules.append((selector, body)) - i = j - return rules - - -def normalize_props(body: str) -> frozenset[tuple[str, str]]: - props = [] - for m in PROP_RE.finditer(body): - prop = m.group(1).strip().lower() - val = re.sub(r"\s+", " ", m.group(2).strip().lower()) - if prop.startswith("--") or prop in {"content", "src", "animation-name"}: - continue - props.append((prop, val)) - return frozenset(props) - - -def class_signatures(css: str) -> dict[str, set[frozenset[tuple[str, str]]]]: - out: dict[str, set[frozenset[tuple[str, str]]]] = defaultdict(set) - for selector, body in split_rules(css): - props = normalize_props(body) - if not props: - continue - for cls in re.findall(r"\.([A-Za-z0-9_-]+)", selector): - out[cls].add(props) - return out - - -def inventory_classes(css: str) -> dict[str, int]: - counts: dict[str, int] = defaultdict(int) - for selector, _ in split_rules(css): - for cls in re.findall(r"\.([A-Za-z0-9_-]+)", selector): - counts[cls] += 1 - return dict(sorted(counts.items(), key=lambda kv: (-kv[1], kv[0]))) - - -def jaccard(a: frozenset, b: frozenset) -> float: - if not a and not b: - return 1.0 - if not a or not b: - return 0.0 - inter = len(a & b) - union = len(a | b) - return inter / union if union else 0.0 - - -def load_css_map(path: str | None) -> dict[str, str]: - if not path: - return {} - data = json.loads(Path(path).read_text()) - if not isinstance(data, dict): - raise SystemExit("css-map must be a JSON object") - return {str(k): str(v) for k, v in data.items()} - - -def tokenize_semantic(name: str) -> set[str]: - parts = re.split(r"[-_.\s]+", name) - tokens: set[str] = set() - for p in parts: - if not p: - continue - sub = re.findall(r"[A-Z]?[a-z]+|[A-Z]+(?![a-z])|\d+", p) - if not sub: - tokens.add(p.lower()) - else: - for s in sub: - tokens.add(s.lower()) - return tokens - - -# Path segment -> semantic tokens expected in css-map values. -PATH_HINTS: dict[str, set[str]] = { - "topbar": {"topbar", "topbarcontent", "globalnav"}, - "playbar": {"nowplayingbar", "playercontrols", "playbackbar"}, - "navbar": {"navbar", "navlink", "navitem", "yourlibraryx"}, - "search_chips": {"searchcategory", "chip", "filterchips"}, - "search_box": {"search", "filterbox", "searchinput", "globalnav"}, - "widget_generator": {"embedwidgetgenerator", "embedwidget"}, - "track_credits": {"trackcredits"}, - "settings": {"settings", "desktopsettings"}, - "sort_box": {"sortbox", "sortdropdown", "sort"}, - "scrollable_text": {"marquee"}, - "context_menu": {"contextmenu"}, - "tracklist": {"tracklist"}, - "chip": {"chip"}, - "close": {"closebtn", "close"}, - "menu_item": {"menuitem", "contextmenu"}, - "expand_button": {"expandbutton", "searchinput"}, - "upgrade_button": {"upgradebutton", "upgrade"}, - "indicator": {"indicator"}, -} - -# Region segment -> required substrings in the lowercased full semantic name. -# Matching is against the full css-map value, not tokenized pieces, so -# "nowPlayingBar" and "actionBar" stay distinct. -REGION_REQUIRED: dict[str, tuple[str, ...]] = { - "topbar": ("topbar", "globalnav"), - "playbar": ("nowplayingbar", "player-controls", "playercontrols", "playbackbar"), - "navbar": ("navbar", "navlink", "navitem", "yourlibraryx", "mainnav"), - "track_credits": ("trackcredits",), - "widget_generator": ("embedwidget",), - "settings": ("settings",), - "tracklist": ("tracklist",), - "search_chips": ("search", "chip", "filterchip"), - "search_box": ("search", "filterbox", "globalnav"), - "context_menu": ("contextmenu",), - "sort_box": ("sort",), -} - -# If path contains the region, reject semantics that match these substrings. -REGION_FORBIDDEN: dict[str, tuple[str, ...]] = { - "playbar": ( - "actionbar", - "tracklist", - "watchfeed", - "entityheader", - "rowplay", - "rowimage", - ), - "topbar": ("actionbar", "entityheader", "watchfeed"), - "settings": ("equalizer", "nowplayingview", "actionbar"), - "widget_generator": ("watchfeed", "filterbox", "sortbox"), - "search_chips": ("nowplaying", "actionbar", "tracklist"), - "search_box": ("yourlibrary", "actionbar", "tracklist"), - "navbar": ("topbar", "nowplaying", "actionbar"), - "tracklist": ("actionbar", "nowplayingbar", "globalnav"), - "context_menu": ("settings", "equalizer", "sortbox"), -} - -# Leaf segment requirements against full semantic string. -LEAF_REQUIRED: dict[str, tuple[str, ...]] = { - "close": ("close",), - "content": ("content", "body", "iframe", "code"), - "header": ("header", "title"), - "chip": ("chip",), - "indicator": ("indicator", "active", "playing"), - "expand_button": ("expand", "search", "filter", "clear"), -} - -# Leaf forbidden when this leaf is the path tail. -LEAF_FORBIDDEN: dict[str, tuple[str, ...]] = { - "content": ("close",), # content container must not resolve to closeBtn only -} - - -def normalize_semantic(name: str) -> str: - """Lowercase and strip separators so nowPlayingBar -> nowplayingbar.""" - return re.sub(r"[-_.\s]+", "", name).lower() - - -def path_segments(path: tuple[str, ...]) -> list[str]: - return [seg.split("__")[0] for seg in path] - - -def semantic_fit(path: tuple[str, ...], semantic: str) -> float: - """Return 0..1 agreement between a nested classmap path and a css-map name. - - Uses full-string substring checks (not bag-of-tokens) so ActionBar cannot - satisfy a playbar requirement meant for nowPlayingBar. - """ - if not semantic: - return 0.0 - - segs = path_segments(path) - full = normalize_semantic(semantic) - raw_lower = semantic.lower() - - # Region required / forbidden - for seg in segs: - forb = REGION_FORBIDDEN.get(seg) - if forb and any(f in full for f in forb): - return 0.0 - req = REGION_REQUIRED.get(seg) - if req and not any(r.replace("-", "") in full or r in raw_lower for r in req): - return 0.0 - - # Intermediate + leaf structural roles - for i, seg in enumerate(segs): - is_tail = i == len(segs) - 1 - # For non-tail "content", still require content-ish names - if seg == "content": - if not any(x in full for x in ("content", "body", "iframe", "code")): - return 0.0 - # pure close button is never a content node - if "close" in full and "content" not in full: - return 0.0 - if is_tail: - req = LEAF_REQUIRED.get(seg) - if req and not any(r in full for r in req): - return 0.0 - forb = LEAF_FORBIDDEN.get(seg) - if forb and any(f in full for f in forb) and not any( - r in full for r in (LEAF_REQUIRED.get(seg) or ()) - ): - return 0.0 - - # Score: count how many specific path segments are reflected in the name. - specific = [ - s - for s in segs - if s - not in { - "main", - "wrapper", - "container", - "button", - "left", - "right", - "list", - "button_t", - } - ] - if not specific: - specific = segs[-1:] - - hits = 0 - for s in specific: - hints = PATH_HINTS.get(s, {s.replace("_", "")}) - hints = set(hints) | {s.replace("_", ""), s} - if any(normalize_semantic(h) in full or h.lower() in raw_lower for h in hints if len(h) >= 3): - hits += 1 - - if hits == 0: - return 0.0 - return min(1.0, hits / max(1, len(specific))) - - -def path_hint_tokens(path: tuple[str, ...]) -> set[str]: - """Legacy helper used by scoring diagnostics.""" - hints: set[str] = set() - for seg in path_segments(path): - hints.add(seg.lower().replace("-", "").replace("_", "")) - if seg in PATH_HINTS: - hints |= PATH_HINTS[seg] - return hints - - -def semantic_overlap(path: tuple[str, ...], semantic: str) -> float: - """Back-compat name used throughout scoring.""" - return semantic_fit(path, semantic) - - -def css_similarity( - src_sigs: set[frozenset[tuple[str, str]]], - tgt_sigs: set[frozenset[tuple[str, str]]], -) -> float: - if not src_sigs or not tgt_sigs: - return 0.0 - return max(jaccard(s, t) for s in src_sigs for t in tgt_sigs) - - -def score_candidate( - path: tuple[str, ...], - cand: str, - src_sigs: set[frozenset[tuple[str, str]]], - target_sigs: dict[str, set[frozenset[tuple[str, str]]]], - css_map: dict[str, str], -) -> dict[str, Any]: - css_score = css_similarity(src_sigs, target_sigs.get(cand, set())) - in_map = cand in css_map - semantic = css_map.get(cand, "") - sem_score = semantic_overlap(path, semantic) if semantic else 0.0 - - segs = set(path_segments(path)) - strict_region = bool(segs & set(REGION_REQUIRED)) - - # Combined score: css similarity is base; css-map presence and semantic agreement boost. - score = css_score - if in_map and sem_score > 0: - score += 0.15 - if sem_score > 0: - score += 0.45 * sem_score - else: - # css-map hit with zero semantic fit is usually a false friend. - if in_map and css_score >= 0.5: - score -= 0.35 - # For strict regions (playbar/topbar/...), refuse pure CSS matches - # without semantic agreement — that is how actionBar leaks in. - if strict_region: - score = min(score, css_score * 0.25) - - return { - "class": cand, - "score": round(max(0.0, min(score, 1.5)), 4), - "css_score": round(css_score, 4), - "semantic_score": round(sem_score, 4), - "in_css_map": in_map, - "semantic": semantic or None, - } - - -def best_match( - path: tuple[str, ...], - src_sigs: set[frozenset[tuple[str, str]]], - target_sigs: dict[str, set[frozenset[tuple[str, str]]]], - target_index: dict[frozenset[tuple[str, str]], set[str]], - css_map: dict[str, str], - threshold: float, -) -> dict[str, Any] | None: - if not src_sigs: - return None - - candidates: set[str] = set() - for sig in src_sigs: - candidates |= target_index.get(sig, set()) - - # Always consider css-map keys that have decent semantic overlap as - # candidates, but only when they actually exist in the target CSS: - # a css-map entry with zero target presence has no evidence behind it. - hints = path_hint_tokens(path) - if hints and css_map: - for h, sem in css_map.items(): - if not is_hash_like(h): - continue - if h not in target_sigs: - continue - if semantic_overlap(path, sem) >= 0.5: - candidates.add(h) - - scored: list[dict[str, Any]] = [] - pool = candidates if candidates else set(target_sigs.keys()) - for cand in pool: - if not is_hash_like(cand): - continue - if cand not in target_sigs: - continue - item = score_candidate(path, cand, src_sigs, target_sigs, css_map) - scored.append(item) - - if not scored: - return None - scored.sort(key=lambda x: (x["score"], x["semantic_score"], x["css_score"]), reverse=True) - best = scored[0] - segs = set(path_segments(path)) - strict_region = bool(segs & set(REGION_REQUIRED)) - # Require either solid css match or solid semantic agreement. - # Strict regions need semantic_score > 0 to avoid actionBar/etc. leaks. - if best["score"] < threshold: - best["rejected"] = True - best["next"] = scored[1:4] - return best - if strict_region and best["semantic_score"] <= 0: - best["rejected"] = True - best["reason"] = "strict region requires semantic agreement" - best["next"] = scored[1:4] - return best - if best["semantic_score"] <= 0 and best["css_score"] < 0.75: - best["rejected"] = True - best["next"] = scored[1:4] - return best - best["alternatives"] = scored[1:4] - return best - - -def build_target_index( - target_sigs: dict[str, set[frozenset[tuple[str, str]]]], -) -> dict[frozenset[tuple[str, str]], set[str]]: - index: dict[frozenset[tuple[str, str]], set[str]] = defaultdict(set) - for cls, sigs in target_sigs.items(): - for sig in sigs: - index[sig].add(cls) - return index - - -def iter_leaves(node: Any, path: tuple[str, ...] = ()) -> list[tuple[tuple[str, ...], str]]: - leaves: list[tuple[tuple[str, ...], str]] = [] - if isinstance(node, dict): - for k, v in node.items(): - leaves.extend(iter_leaves(v, path + (k,))) - elif isinstance(node, str): - leaves.append((path, node)) - return leaves - - -def set_leaf(root: dict, path: tuple[str, ...], value: str) -> None: - cur: Any = root - for key in path[:-1]: - nxt = cur.get(key) - if not isinstance(nxt, dict): - nxt = {} - cur[key] = nxt - cur = nxt - cur[path[-1]] = value - - -def migrate_classmap( - base_map: dict, - base_sigs: dict[str, set[frozenset[tuple[str, str]]]], - target_sigs: dict[str, set[frozenset[tuple[str, str]]]], - css_map: dict[str, str], - threshold: float, -) -> tuple[dict, dict]: - target_index = build_target_index(target_sigs) - out: dict = json.loads(json.dumps(base_map)) - report: dict[str, Any] = { - "matched": [], - "unmatched": [], - "identity": [], - "stats": {}, - } - - leaves = iter_leaves(base_map) - matched = 0 - identity = 0 - for path, old_hash in leaves: - dotted = ".".join(path) - - if old_hash in target_sigs and is_hash_like(old_hash): - set_leaf(out, path, old_hash) - entry = { - "path": dotted, - "class": old_hash, - "in_css_map": old_hash in css_map, - "semantic": css_map.get(old_hash), - "method": "identity", - } - report["identity"].append(entry) - identity += 1 - matched += 1 - continue - - src = base_sigs.get(old_hash) - if not src: - # No base CSS signature for the old class. Fall back to a - # semantic-only match via css-map, with a higher bar (0.75) - # because there is zero CSS similarity evidence behind it. - best_sem = None - best_s = 0.0 - for h, sem in css_map.items(): - if h not in target_sigs or not is_hash_like(h): - continue - s = semantic_overlap(path, sem) - if s > best_s: - best_s = s - best_sem = (h, sem, s) - if best_sem and best_s >= 0.75: - new_cls, sem, s = best_sem - set_leaf(out, path, new_cls) - report["matched"].append( - { - "path": dotted, - "old": old_hash, - "new": new_cls, - "score": round(0.2 + 0.5 * s, 4), - "css_score": 0.0, - "semantic_score": round(s, 4), - "in_css_map": True, - "semantic": sem, - "method": "semantic-only", - "css_evidence": False, - } - ) - matched += 1 - continue - - report["unmatched"].append( - { - "path": dotted, - "old": old_hash, - "reason": "old class not found in base CSS", - "stale": True, - "kept": old_hash, - } - ) - set_leaf(out, path, old_hash) - continue - - best = best_match(path, src, target_sigs, target_index, css_map, threshold) - if not best or best.get("rejected"): - report["unmatched"].append( - { - "path": dotted, - "old": old_hash, - "reason": "no confident target", - "best": best, - "stale": True, - "kept": old_hash, - } - ) - set_leaf(out, path, old_hash) - continue - - set_leaf(out, path, best["class"]) - report["matched"].append( - { - "path": dotted, - "old": old_hash, - "new": best["class"], - "score": best["score"], - "css_score": best["css_score"], - "semantic_score": best["semantic_score"], - "in_css_map": best["in_css_map"], - "semantic": best["semantic"], - "method": "css+semantic", - "alternatives": best.get("alternatives", []), - } - ) - matched += 1 - - report["stats"] = { - "leaves": len(leaves), - "matched": matched, - "identity": identity, - "migrated": matched - identity, - "unmatched": len(leaves) - matched, - "stale_kept": len(leaves) - matched, - "match_rate": round(matched / len(leaves), 4) if leaves else 0.0, - "threshold": threshold, - "css_map_entries": len(css_map), - "matched_in_css_map": sum(1 for m in report["matched"] if m.get("in_css_map")), - } - return out, report - - -def version_to_key(version: str) -> str: - parts = version.strip().split(".") - if len(parts) < 3: - raise SystemExit(f"need major.minor.patch, got {version!r}") - major, minor, patch = int(parts[0]), int(parts[1]), int(parts[2]) - return f"{major}{minor:02d}{patch:04d}" - - -def confidence_label(m: dict[str, Any]) -> str: - if m.get("method") == "identity": - # Class survived unchanged in the target CSS: strongest evidence. - return "high" - sem = m.get("semantic_score") or 0 - css = m.get("css_score") or 0 - in_map = m.get("in_css_map") - if css == 0: - # No CSS similarity evidence at all (semantic-only guess). - return "low" - if in_map and sem >= 0.5 and css >= 0.35: - return "high" - if in_map and sem >= 0.5: - return "medium-semantic" - if css >= 0.75 and is_hash_like(m.get("new") or m.get("class") or ""): - return "medium-css" - if css >= 0.55 and sem >= 0.34: - return "low" - if css >= 0.55: - return "low" - return "reject" - - -def cmd_inventory(args: argparse.Namespace) -> int: - css = read_css_sources(args.spa, args.css_dir, args.css) - counts = inventory_classes(css) - items = [(c, n) for c, n in counts.items() if is_hash_like(c)] - print(f"hash-like classes: {len(items)} (of {len(counts)} selector tokens)") - for c, n in items[: args.top]: - print(f"{n:5d} {c}") - if args.out: - Path(args.out).write_text(json.dumps(dict(items), indent=2) + "\n") - print(f"wrote {args.out}") - return 0 - - -def cmd_migrate(args: argparse.Namespace) -> int: - base_map = json.loads(Path(args.base_classmap).read_text()) - base_css = read_css_sources(args.base_spa, args.base_css_dir, args.base_css) - target_css = read_css_sources(args.target_spa, args.target_css_dir, args.target_css) - css_map = load_css_map(args.css_map) - - print("Parsing base CSS signatures…") - base_sigs = class_signatures(base_css) - print(f" base classes with signatures: {len(base_sigs)}") - print("Parsing target CSS signatures…") - target_sigs = class_signatures(target_css) - print(f" target classes with signatures: {len(target_sigs)}") - print(f" css-map entries: {len(css_map)}") - - migrated, report = migrate_classmap( - base_map, base_sigs, target_sigs, css_map, args.threshold - ) - for m in report["matched"]: - m["confidence"] = confidence_label(m) - - out = Path(args.out) - out.parent.mkdir(parents=True, exist_ok=True) - out.write_text(json.dumps(migrated, indent=2) + "\n") - print(f"wrote classmap {out}") - - if args.report: - report_path = Path(args.report) - report_path.parent.mkdir(parents=True, exist_ok=True) - report_path.write_text(json.dumps(report, indent=2) + "\n") - print(f"wrote report {report_path}") - - stats = report["stats"] - print( - "stats: leaves={leaves} matched={matched} identity={identity} " - "migrated={migrated} unmatched={unmatched} rate={match_rate} " - "in_css_map={matched_in_css_map}".format(**stats) - ) - if stats["stale_kept"]: - print( - f"warning: {stats['stale_kept']} leaves kept their old (stale) hash; " - "see report.unmatched entries marked stale=true" - ) - # confidence breakdown - conf: dict[str, int] = defaultdict(int) - for m in report["matched"]: - conf[m.get("confidence", "?")] += 1 - print("confidence:", dict(conf)) - return 0 if stats["unmatched"] == 0 or args.allow_partial else 1 - - -def verify_classmap( - classmap: dict, - target_css: str, - css_map: dict[str, str], - report: dict | None = None, - target_version: str | None = None, - classmap_sha256: str | None = None, -) -> dict: - target_sigs = class_signatures(target_css) - inv = inventory_classes(target_css) - leaves = iter_leaves(classmap) - - rows = [] - for path, cls in leaves: - dotted = ".".join(path) - classes = cls.split() - semantics = [css_map[token] for token in classes if token in css_map] - semantic = css_map.get(cls) or (" ".join(semantics) if semantics else None) - missing_classes = [token for token in classes if token not in target_sigs and token not in inv] - row = { - "path": dotted, - "class": cls, - "classes": classes, - "missing_classes": missing_classes, - "in_target_css": not missing_classes, - "css_rule_count": sum(len(target_sigs.get(token, [])) for token in classes), - "selector_hits": sum(inv.get(token, 0) for token in classes), - "in_css_map": bool(semantics), - "semantics": semantics, - "semantic": semantic, - "semantic_score": round(max((semantic_overlap(path, name) for name in semantics), default=0.0), 4), - } - # attach migrate confidence if available - if report: - for m in report.get("matched", []) + report.get("identity", []): - if m.get("path") == dotted: - row["migrate_score"] = m.get("score") - row["migrate_confidence"] = m.get("confidence") or confidence_label(m) - row["method"] = m.get("method") - break - else: - # unmatched leaves keep old hash often - row["migrate_confidence"] = "unmatched-or-stale" - # final verdict - if ( - row["in_css_map"] - and row["semantic_score"] >= 0.5 - and row["in_target_css"] - and row.get("migrate_confidence") in {"high", "medium-semantic"} - ): - row["verdict"] = "likely_good" - elif row["in_target_css"] and row.get("migrate_confidence") in {"high", "medium-semantic", "medium-css"}: - row["verdict"] = "plausible" - elif not row["in_target_css"]: - row["verdict"] = "missing_in_css" - else: - row["verdict"] = "needs_manual_check" - rows.append(row) - - summary = defaultdict(int) - for r in rows: - summary[r["verdict"]] += 1 - - out = { - "target": { - "spotify_version": target_version, - "css_sha256": hashlib.sha256(target_css.encode()).hexdigest(), - "classmap_sha256": classmap_sha256, - }, - "summary": dict(summary), - "rows": rows, - } - return out - - -def cmd_verify(args: argparse.Namespace) -> int: - css_map = load_css_map(args.css_map) - target_css = read_css_sources(args.target_spa, args.target_css_dir, args.target_css) - report = json.loads(Path(args.report).read_text()) if args.report else None - classmap = json.loads(Path(args.classmap).read_text()) - classmap_sha256 = hashlib.sha256(Path(args.classmap).read_bytes()).hexdigest() - out = verify_classmap( - classmap, - target_css, - css_map, - report, - target_version=args.target_version, - classmap_sha256=classmap_sha256, - ) - - if args.out: - Path(args.out).parent.mkdir(parents=True, exist_ok=True) - Path(args.out).write_text(json.dumps(out, indent=2) + "\n") - print(f"wrote {args.out}") - - print("verify summary:", out["summary"]) - print() - print(f"{'VERDICT':<20} {'CONF':<16} {'PATH':<45} CLASS -> semantic") - for r in sorted(out["rows"], key=lambda x: (x["verdict"], x["path"])): - print( - f"{r['verdict']:<20} {str(r.get('migrate_confidence', '-')):<16} " - f"{r['path']:<45} {r['class']} -> {r.get('semantic') or '-'}" - f" (sem={r['semantic_score']}, css_hits={r['selector_hits']})" - ) - return 0 - - -def flatten_classmap( - classmap: dict, - base_map: dict | None, - css_map: dict[str, str], - report: dict | None, - meta: dict | None, -) -> tuple[dict[str, str], list[str]]: - """Bridge a nested classmap into a flat css-map overlay (hash -> semantic). - - The semantic name for a leaf is the name themes already target, resolved - in priority order: - 1. css-map name of the BASE classmap's hash at the same path (the - established, theme-facing name for that component), - 2. the migrate report's semantic for the path. - Leaves whose new hash is already in the global css-map are skipped: the - global map already rewrites them, so the overlay only carries verified - per-version corrections the global map lacks. Leaves with no resolvable - semantic are skipped with a warning. Stale leaves and leaves not yet - verified (per META.json) are skipped; an unverified required path is a - loud warning. - """ - stale: set[str] = set() - unverified: set[str] = set() - if meta: - stale |= set(meta.get("stale_leaves", [])) - unverified |= set(meta.get("unverified_leaves", [])) - if report: - for u in report.get("unmatched", []): - if u.get("stale"): - stale.add(u.get("path", "")) - - base_at: dict[str, str] = {} - if base_map: - for path, h in iter_leaves(base_map): - base_at[".".join(path)] = h - - report_sem: dict[str, str] = {} - if report: - for m in report.get("matched", []) + report.get("identity", []): - if m.get("semantic"): - report_sem[m.get("path", "")] = m["semantic"] - - overlay: dict[str, str] = {} - skipped: list[str] = [] - for path, h in iter_leaves(classmap): - dotted = ".".join(path) - if dotted in stale or dotted in unverified: - continue - if not is_hash_like(h): - skipped.append(f"{dotted}: {h!r} not hash-like") - continue - if h in css_map: - continue - semantic = css_map.get(base_at.get(dotted, "")) or report_sem.get(dotted) - if not semantic: - skipped.append(f"{dotted}: no semantic name for {h}") - continue - if h in overlay and overlay[h] != semantic: - skipped.append( - f"{dotted}: {h} already named {overlay[h]!r}, ignoring conflicting {semantic!r}" - ) - continue - overlay[h] = semantic - - if meta: - for path, status in (meta.get("required_paths") or {}).items(): - if not str(status).startswith("verified"): - skipped.append(f"WARNING: required path {path} is {status}") - return overlay, skipped - - -def cmd_flatten(args: argparse.Namespace) -> int: - classmap = json.loads(Path(args.classmap).read_text()) - base_map = json.loads(Path(args.base_classmap).read_text()) if args.base_classmap else None - css_map = load_css_map(args.css_map) - report = json.loads(Path(args.report).read_text()) if args.report else None - meta = json.loads(Path(args.meta).read_text()) if args.meta else None - - overlay, skipped = flatten_classmap(classmap, base_map, css_map, report, meta) - - out = Path(args.out) - out.parent.mkdir(parents=True, exist_ok=True) - out.write_text(json.dumps(overlay, indent=2, sort_keys=True) + "\n") - print(f"wrote {out} ({len(overlay)} overlay entries)") - for s in skipped: - print(f" skipped: {s}") - if skipped and not args.allow_partial: - return 1 - return 0 - - -def cmd_devtools(args: argparse.Namespace) -> int: - report = json.loads(Path(args.report).read_text()) - matched = report.get("matched", []) + report.get("identity", []) - if not matched: - print("no matched entries in report") - return 1 - - print("// Paste into Spotify DevTools console (Ctrl+Shift+I / Cmd+Opt+I)") - print("// Requires spicetify enable-devtools or employee/devtools flags.") - print("(function verifyClassmapMatches() {") - print(" const checks = [") - for m in matched: - cls = m.get("new") or m.get("class") - path = m.get("path") - conf = m.get("confidence") or confidence_label(m) - print(f" {{ path: {path!r}, cls: {cls!r}, confidence: {conf!r} }},") - print(" ];") - print(""" const rows = checks.map(({path, cls, confidence}) => { - const nodes = document.getElementsByClassName(cls); - let sample = null; - if (nodes[0]) { - const el = nodes[0]; - sample = { - tag: el.tagName, - id: el.id || null, - aria: el.getAttribute('aria-label') || el.getAttribute('aria-labelledby') || null, - text: (el.textContent || '').trim().slice(0, 60), - parent: el.parentElement && el.parentElement.className, - }; - } - return { path, cls, confidence, count: nodes.length, sample }; - }); - console.table(rows.map(r => ({ - path: r.path, - cls: r.cls, - confidence: r.confidence, - count: r.count, - tag: r.sample && r.sample.tag, - text: r.sample && r.sample.text, - }))); - console.log('Full detail', rows); - return rows; -})();""") - print() - print("// Tip: count===0 means the class is not in the current DOM (may be route-specific).") - print("// Tip: open Home / Now Playing / Settings / a modal before running.") - return 0 - - -def cmd_key(args: argparse.Namespace) -> int: - print(version_to_key(args.version)) - return 0 - - -def main(argv: list[str] | None = None) -> int: - parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) - sub = parser.add_subparsers(dest="cmd", required=True) - - p_inv = sub.add_parser("inventory", help="List class tokens from CSS/spa") - p_inv.add_argument("--spa") - p_inv.add_argument("--css-dir") - p_inv.add_argument("--css", action="append") - p_inv.add_argument("--top", type=int, default=40) - p_inv.add_argument("--out") - p_inv.set_defaults(func=cmd_inventory) - - p_mig = sub.add_parser("migrate", help="Migrate a classmap using CSS + css-map signals") - p_mig.add_argument("--base-classmap", required=True) - p_mig.add_argument("--base-spa") - p_mig.add_argument("--base-css-dir") - p_mig.add_argument("--base-css", action="append") - p_mig.add_argument("--target-spa") - p_mig.add_argument("--target-css-dir") - p_mig.add_argument("--target-css", action="append") - p_mig.add_argument("--css-map", default="css-map.json", help="Path to cli css-map.json (optional empty to disable)") - p_mig.add_argument("--out", required=True) - p_mig.add_argument("--report") - p_mig.add_argument("--threshold", type=float, default=0.50) - p_mig.add_argument("--allow-partial", action="store_true") - p_mig.set_defaults(func=cmd_migrate) - - p_ver = sub.add_parser("verify", help="Verify classmap leaves against css-map + target CSS") - p_ver.add_argument("--classmap", required=True) - p_ver.add_argument("--report", help="Optional migrate report for confidence labels") - p_ver.add_argument("--css-map", default="css-map.json") - p_ver.add_argument("--target-spa") - p_ver.add_argument("--target-css-dir") - p_ver.add_argument("--target-css", action="append") - p_ver.add_argument("--out") - p_ver.add_argument("--target-version", help="Exact Spotify version used for target provenance") - p_ver.set_defaults(func=cmd_verify) - - p_dt = sub.add_parser("devtools", help="Emit DevTools console snippet for matched paths") - p_dt.add_argument("--report", required=True) - p_dt.set_defaults(func=cmd_devtools) - - p_flat = sub.add_parser("flatten", help="Bridge a classmap into a flat css-map overlay") - p_flat.add_argument("--classmap", required=True) - p_flat.add_argument("--base-classmap", help="Base classmap used to derive theme-facing names") - p_flat.add_argument("--css-map", default="css-map.json") - p_flat.add_argument("--report", help="Migrate report (semantic fallbacks + stale flags)") - p_flat.add_argument("--meta", help="META.json with stale_leaves list") - p_flat.add_argument("--out", required=True) - p_flat.add_argument("--allow-partial", action="store_true") - p_flat.set_defaults(func=cmd_flatten) - - p_key = sub.add_parser("key", help="Print classmap key for a Spotify version") - p_key.add_argument("version") - p_key.set_defaults(func=cmd_key) - - args = parser.parse_args(argv) - if getattr(args, "css_map", None) == "": - args.css_map = None - return args.func(args) - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/scripts/test_classmap_capture.py b/scripts/test_classmap_capture.py deleted file mode 100644 index c66bee2204..0000000000 --- a/scripts/test_classmap_capture.py +++ /dev/null @@ -1,271 +0,0 @@ -"""Unit tests for classmap_capture.py. - -Run with: python3 -m unittest scripts.test_classmap_capture -v -(or: python3 scripts/test_classmap_capture.py) -""" - -from __future__ import annotations - -import hashlib -import sys -import unittest -from pathlib import Path - -sys.path.insert(0, str(Path(__file__).resolve().parent)) - -import classmap_capture as cc # noqa: E402 - - -class TestIsHashLike(unittest.TestCase): - def test_real_hashes(self): - # Short real hash from the 1.2.45 fixture (play button). - self.assertTrue(cc.is_hash_like("cLkUmr")) - self.assertTrue(cc.is_hash_like("AbCdEfGh")) - self.assertTrue(cc.is_hash_like("Xy1_abC")) - self.assertTrue(cc.is_hash_like("aB3d")) - - def test_rejects_semantic_and_encore(self): - self.assertFalse(cc.is_hash_like("main-topbar")) - self.assertFalse(cc.is_hash_like("spotify-play-button")) - self.assertFalse(cc.is_hash_like("encore-text")) - self.assertFalse(cc.is_hash_like("abc")) # too short - self.assertFalse(cc.is_hash_like("alllower")) - self.assertFalse(cc.is_hash_like("A" * 26)) # too long - - -class TestSplitRulesAndSignatures(unittest.TestCase): - CSS = """ - .cLkUmr, .other_Class { color: red; padding: 4px; } - /* comment { ignored } */ - .main-topbar { --custom: 1; background: blue; } - @media (min-width: 100px) { .ignoredAtRule { color: green; } } - """ - - def test_split_rules(self): - rules = cc.split_rules(self.CSS) - selectors = [s for s, _ in rules] - self.assertTrue(any("cLkUmr" in s for s in selectors)) - self.assertFalse(any(s.startswith("@") for s in selectors)) - - def test_signatures_skip_css_vars(self): - sigs = cc.class_signatures(".foo { --x: 1; color: red; }") - self.assertIn("foo", sigs) - flat = {p for sig in sigs["foo"] for p, _ in sig} - self.assertEqual(flat, {"color"}) - - -class TestSemanticFitFalseFriends(unittest.TestCase): - def test_actionbar_rejected_for_playbar(self): - path = ("main", "playbar", "controls") - self.assertEqual(cc.semantic_fit(path, "actionBar"), 0.0) - - def test_nowplayingbar_accepted_for_playbar(self): - path = ("main", "playbar", "controls") - self.assertGreater(cc.semantic_fit(path, "nowPlayingBar"), 0.0) - - def test_close_leaf_requires_close(self): - path = ("main", "topbar", "close") - self.assertEqual(cc.semantic_fit(path, "topBarButton"), 0.0) - self.assertGreater(cc.semantic_fit(path, "topBarCloseBtn"), 0.0) - - def test_empty_semantic(self): - self.assertEqual(cc.semantic_fit(("a", "b"), ""), 0.0) - - -class TestVersionToKey(unittest.TestCase): - def test_keys(self): - self.assertEqual(cc.version_to_key("1.2.45"), "1020045") - self.assertEqual(cc.version_to_key("1.2.93"), "1020093") - self.assertEqual(cc.version_to_key("1.2.8"), "1020008") - - -class TestVerifyClassmap(unittest.TestCase): - def test_multi_class_leaf_is_present_when_every_token_exists(self): - result = cc.verify_classmap( - {"settings": {"text_input": "formInputAA formControlBB encore-text-body-medium"}}, - ".formInputAA { color: red; } .formControlBB { color: blue; } " - ".encore-text-body-medium { font-size: 1rem; }", - {}, - ) - - row = result["rows"][0] - self.assertTrue(row["in_target_css"]) - self.assertEqual(row["missing_classes"], []) - self.assertEqual(row["selector_hits"], 3) - - def test_multi_class_leaf_reports_only_the_missing_tokens(self): - result = cc.verify_classmap( - {"settings": {"text_input": "formInputAA missingControlBB"}}, - ".formInputAA { color: red; }", - {}, - ) - - row = result["rows"][0] - self.assertFalse(row["in_target_css"]) - self.assertEqual(row["missing_classes"], ["missingControlBB"]) - self.assertEqual(row["verdict"], "missing_in_css") - - def test_multi_class_leaf_reports_token_semantics(self): - result = cc.verify_classmap( - {"settings": {"text_input": "formInputAA formControlBB"}}, - ".formInputAA { color: red; } .formControlBB { color: blue; }", - {"formInputAA": "x-settings-input", "formControlBB": "x-settings-control"}, - ) - - row = result["rows"][0] - self.assertTrue(row["in_css_map"]) - self.assertEqual( - row["semantics"], - ["x-settings-input", "x-settings-control"], - ) - - def test_static_report_includes_target_digest_provenance(self): - css = ".topbarHashAA { color: red; }" - result = cc.verify_classmap( - {"main": {"topbar": {"wrapper": "topbarHashAA"}}}, - css, - {}, - target_version="1.2.96.518", - ) - - self.assertEqual(result["target"]["spotify_version"], "1.2.96.518") - self.assertEqual(result["target"]["css_sha256"], hashlib.sha256(css.encode()).hexdigest()) - - -class TestMigrateClassmap(unittest.TestCase): - BASE_CSS = ".oldHashAA { color: red; padding: 4px; } .keepMe99 { margin: 0; }" - TARGET_CSS = ".newHashBB { color: red; padding: 4px; } .keepMe99 { margin: 0; }" - - def _migrate(self, base_map, css_map, threshold=0.5): - base_sigs = cc.class_signatures(self.BASE_CSS) - target_sigs = cc.class_signatures(self.TARGET_CSS) - return cc.migrate_classmap(base_map, base_sigs, target_sigs, css_map, threshold) - - def test_identity_kept(self): - base_map = {"main": {"widget": "keepMe99"}} - out, report = cc.migrate_classmap( - base_map, - cc.class_signatures(self.BASE_CSS), - cc.class_signatures(self.TARGET_CSS), - {}, - 0.5, - ) - self.assertEqual(out["main"]["widget"], "keepMe99") - self.assertEqual(report["stats"]["identity"], 1) - self.assertEqual(report["identity"][0]["method"], "identity") - self.assertEqual(cc.confidence_label(report["identity"][0]), "high") - - def test_css_evidence_match(self): - base_map = {"main": {"widget": "oldHashAA"}} - out, report = self._migrate(base_map, {}) - self.assertEqual(out["main"]["widget"], "newHashBB") - self.assertEqual(report["stats"]["matched"], 1) - - def test_unmatched_marks_stale_and_keeps_old_hash(self): - base_map = {"main": {"widget": "notInBase1"}} - out, report = self._migrate(base_map, {}) - # Old hash kept for incremental re-runs, but explicitly marked stale. - self.assertEqual(out["main"]["widget"], "notInBase1") - self.assertEqual(report["stats"]["unmatched"], 1) - self.assertEqual(report["stats"]["stale_kept"], 1) - entry = report["unmatched"][0] - self.assertTrue(entry["stale"]) - self.assertEqual(entry["kept"], "notInBase1") - - def test_semantic_only_requires_target_presence(self): - # css-map claims a great semantic fit, but the class does not exist - # in the target CSS at all: must not be matched. - base_map = {"main": {"playbar": {"controls": "notInBase1"}}} - css_map = {"ghostHash1": "nowPlayingBarControls"} - out, report = self._migrate(base_map, css_map) - self.assertEqual(out["main"]["playbar"]["controls"], "notInBase1") - self.assertEqual(report["stats"]["matched"], 0) - - def test_semantic_only_low_confidence(self): - # Old hash absent from base CSS; semantic-only fallback via css-map - # with the candidate present in target CSS: matched but low confidence. - target_sigs = cc.class_signatures(".newHashBB { color: red; }") - base_map = {"main": {"playbar": {"controls": "notInBase1"}}} - css_map = {"newHashBB": "nowPlayingBarControls"} - out, report = cc.migrate_classmap(base_map, {}, target_sigs, css_map, 0.5) - self.assertEqual(out["main"]["playbar"]["controls"], "newHashBB") - m = report["matched"][0] - self.assertEqual(m["method"], "semantic-only") - self.assertEqual(cc.confidence_label(m), "low") - - -if __name__ == "__main__": - unittest.main() - - -class TestFlattenClassmap(unittest.TestCase): - BASE = {"main": {"topbar": {"wrapper": "oldHashAA11"}}, "settings": {"button": {"wrapper": "oldHashBB22"}}} - CSS_MAP = {"oldHashAA11": "main-topBar-topbarContent", "newHashCC33": "x-settings-button"} - REPORT = { - "matched": [ - {"path": "settings.button.wrapper", "new": "newHashCC33", "semantic": "x-settings-button"} - ], - "unmatched": [{"path": "main.topbar.icon", "old": "staleHash99", "stale": True}], - "identity": [], - } - - def test_base_semantic_wins(self): - cm = {"main": {"topbar": {"wrapper": "newHashAA44"}}} - overlay, skipped = cc.flatten_classmap(cm, self.BASE, self.CSS_MAP, None, None) - # Name comes from the base hash's css-map entry, not the new hash. - self.assertEqual(overlay, {"newHashAA44": "main-topBar-topbarContent"}) - self.assertEqual(skipped, []) - - def test_hash_already_in_css_map_skipped(self): - # Global css-map already rewrites this hash; overlay must not rename it. - cm = {"settings": {"button": {"wrapper": "newHashCC33"}}} - overlay, _ = cc.flatten_classmap(cm, self.BASE, self.CSS_MAP, self.REPORT, None) - self.assertEqual(overlay, {}) - - def test_report_semantic_fallback_for_hand_edits(self): - # New hash unknown to css-map and base hash unnamed: report's - # path-level semantic is the last resort (covers hand-edited leaves). - cm = {"settings": {"header": {"container": "newHashHH77"}}} - report = {"matched": [{"path": "settings.header.container", "semantic": "x-settings-header"}], "unmatched": [], "identity": []} - overlay, _ = cc.flatten_classmap(cm, self.BASE, self.CSS_MAP, report, None) - self.assertEqual(overlay, {"newHashHH77": "x-settings-header"}) - - def test_stale_excluded(self): - cm = {"main": {"topbar": {"icon": "staleHash99"}}} - overlay, _ = cc.flatten_classmap(cm, self.BASE, self.CSS_MAP, self.REPORT, None) - self.assertEqual(overlay, {}) - - def test_meta_stale_excluded(self): - cm = {"main": {"topbar": {"wrapper": "newHashAA44"}}} - meta = {"stale_leaves": ["main.topbar.wrapper"]} - overlay, _ = cc.flatten_classmap(cm, self.BASE, self.CSS_MAP, None, meta) - self.assertEqual(overlay, {}) - - def test_unresolvable_skipped_with_warning(self): - cm = {"main": {"mystery": {"leaf": "newHashZZ99"}}} - overlay, skipped = cc.flatten_classmap(cm, self.BASE, self.CSS_MAP, None, None) - self.assertEqual(overlay, {}) - self.assertEqual(len(skipped), 1) - - def test_unverified_leaves_excluded(self): - cm = {"settings": {"button": {"wrapper": "newHashUU55"}}} - meta = { - "unverified_leaves": ["settings.button.wrapper"], - "required_paths": {"settings.button.wrapper": "unverified (in CSS)"}, - } - report = {"matched": [{"path": "settings.button.wrapper", "semantic": "x-settings-button"}], "unmatched": [], "identity": []} - overlay, skipped = cc.flatten_classmap(cm, self.BASE, self.CSS_MAP, report, meta) - self.assertEqual(overlay, {}) - self.assertTrue(any("required path" in s for s in skipped)) - - def test_conflicting_semantic_first_wins_with_warning(self): - cm = { - "a": {"leaf": "sharedHash1"}, - "b": {"leaf": "sharedHash1"}, - } - base = {"a": {"leaf": "oldA1"}, "b": {"leaf": "oldB1"}} - css_map = {"oldA1": "name-a", "oldB1": "name-b"} - overlay, skipped = cc.flatten_classmap(cm, base, css_map, None, None) - self.assertEqual(len(overlay), 1) - self.assertEqual(overlay["sharedHash1"], "name-a") - self.assertTrue(any("conflicting" in s for s in skipped)) diff --git a/src/utils/classmap.go b/src/utils/classmap.go index 7e5d596527..c2d42bb8a1 100644 --- a/src/utils/classmap.go +++ b/src/utils/classmap.go @@ -309,7 +309,7 @@ func LoadClassmapForKey(classmapKey string) (Classmap, string, error) { } // CssMapOverlayFileName is the flat hash -> semantic overlay generated from a -// classmap (scripts/classmap_capture.py flatten), stored next to it. +// classmap (scripts/classmap-capture.ts flatten), stored next to it. const CssMapOverlayFileName = "css-map.json" // FindCssMapOverlayIn searches roots in order for classmaps//css-map.json. From acaf762262592e0fefffa441008fa83d551a21aa Mon Sep 17 00:00:00 2001 From: Afonso Jorge Ramos Date: Wed, 30 Sep 2026 00:17:34 +0100 Subject: [PATCH 08/19] ci: test the classmap tooling on v3-beta pull requests (#3974) --- .github/workflows/classmap-tooling.yml | 51 ++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 .github/workflows/classmap-tooling.yml diff --git a/.github/workflows/classmap-tooling.yml b/.github/workflows/classmap-tooling.yml new file mode 100644 index 0000000000..5dc32bd44c --- /dev/null +++ b/.github/workflows/classmap-tooling.yml @@ -0,0 +1,51 @@ +name: Classmap tooling + +on: + pull_request: + branches: + - "main" + - "v3-beta" + - "*/main/*/**" + paths: + - "scripts/classmap-*" + - "css-map.json" + - "package.json" + - "pnpm-lock.yaml" + - ".github/workflows/classmap-tooling.yml" + push: + branches: + - "main" + - "v3-beta" + - "*/main/*/**" + paths: + - "scripts/classmap-*" + - "css-map.json" + - "package.json" + - "pnpm-lock.yaml" + - ".github/workflows/classmap-tooling.yml" + +permissions: + contents: read + +jobs: + test: + name: Test classmap tooling + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Setup Node and pnpm + uses: jdx/mise-action@7e36c90d9ab29c415a2384db3006f3ec8a8cc654 # v4.2.4 + + - name: Install Node dependencies + run: pnpm install --frozen-lockfile + + - name: Lint + run: pnpm exec oxlint scripts/classmap-capture.ts scripts/classmap-capture.test.mts scripts/classmap-cdp-verify.mjs scripts/classmap-cdp-verify.test.mjs + + - name: Check formatting + run: pnpm exec oxfmt --check scripts/classmap-capture.ts scripts/classmap-capture.test.mts scripts/classmap-cdp-verify.mjs scripts/classmap-cdp-verify.test.mjs + + - name: Test + run: node --test "scripts/classmap-*.test.mts" "scripts/classmap-*.test.mjs" From c1e8b9e6a5e68b07a78c6c4203a5f507bc0c0fa2 Mon Sep 17 00:00:00 2001 From: Afonso Jorge Ramos Date: Wed, 30 Sep 2026 00:23:43 +0100 Subject: [PATCH 09/19] feat(scripts): verify a key's css-map overlay over CDP (#3976) --- scripts/classmap-cdp-verify.mjs | 33 +++++++++++++---- scripts/classmap-cdp-verify.test.mjs | 26 ++++++++++++++ scripts/classmap-e2e.sh | 53 +++++++++++++++------------- 3 files changed, 81 insertions(+), 31 deletions(-) diff --git a/scripts/classmap-cdp-verify.mjs b/scripts/classmap-cdp-verify.mjs index f7069cef89..ecd8046b98 100644 --- a/scripts/classmap-cdp-verify.mjs +++ b/scripts/classmap-cdp-verify.mjs @@ -26,6 +26,9 @@ * --classmap/--out may be given explicitly, or derived from --out-dir / * CLASSMAP_OUT_DIR. --report is optional; when omitted, every classmap leaf * is probed directly. + * --overlay applies a key's css-map.json on top of --css-map, as apply does, + * so semantic probes use the names the published key gives its classes. + * The report records the digest of both maps. * * Exit codes * ---------- @@ -53,6 +56,7 @@ function parseArgs(argv) { report: null, classmap: null, cssMap: path.join(CLI_ROOT, "css-map.json"), + overlay: null, out: null, navigate: false, deep: false, @@ -86,6 +90,9 @@ function parseArgs(argv) { case "--css-map": args.cssMap = path.resolve(take()); break; + case "--overlay": + args.overlay = path.resolve(take()); + break; case "--out": args.out = path.resolve(take()); break; @@ -201,8 +208,15 @@ function pickXpuiTarget(targets) { return pages.find((t) => (t.url || "").includes("xpui.app.spotify.com")) || pages.find((t) => (t.url || "").includes("index.html")) || pages[0]; } -function loadChecks({ reportPath, classmapPath, cssMapPath }) { - const cssMap = fs.existsSync(cssMapPath) ? JSON.parse(fs.readFileSync(cssMapPath, "utf8")) : {}; +/** The CLI css-map with a key's overlay applied on top, as `apply` merges them. */ +export function effectiveCssMap(cssMapPath, overlayPath) { + const base = fs.existsSync(cssMapPath) ? JSON.parse(fs.readFileSync(cssMapPath, "utf8")) : {}; + const overlay = overlayPath ? JSON.parse(fs.readFileSync(overlayPath, "utf8")) : {}; + return { ...base, ...overlay }; +} + +export function loadChecks({ reportPath, classmapPath, cssMapPath, overlayPath = null }) { + const cssMap = effectiveCssMap(cssMapPath, overlayPath); /** @type {{path:string, hash:string, semantic:string|null, confidence:string}[]} */ let checks = []; @@ -634,6 +648,7 @@ async function main() { reportPath: args.report, classmapPath: args.classmap, cssMapPath: args.cssMap, + overlayPath: args.overlay, }); console.log(`Loaded ${checks.length} classmap paths`); @@ -690,6 +705,10 @@ async function main() { classmap: { sha256: crypto.createHash("sha256").update(fs.readFileSync(args.classmap)).digest("hex"), }, + cssMap: { + sha256: fs.existsSync(args.cssMap) ? crypto.createHash("sha256").update(fs.readFileSync(args.cssMap)).digest("hex") : null, + overlaySha256: args.overlay ? crypto.createHash("sha256").update(fs.readFileSync(args.overlay)).digest("hex") : null, + }, mode: args.mode, minHitRate: args.minHitRate, navigate: args.navigate, @@ -730,7 +749,9 @@ async function main() { process.exit(0); } -main().catch((err) => { - console.error(err.message || err); - process.exit(1); -}); +if (import.meta.main) { + main().catch((err) => { + console.error(err.message || err); + process.exit(1); + }); +} diff --git a/scripts/classmap-cdp-verify.test.mjs b/scripts/classmap-cdp-verify.test.mjs index 8d2b4611f0..c133104832 100644 --- a/scripts/classmap-cdp-verify.test.mjs +++ b/scripts/classmap-cdp-verify.test.mjs @@ -6,6 +6,8 @@ import { join } from "node:path"; import { test } from "node:test"; import { fileURLToPath } from "node:url"; +import { loadChecks } from "./classmap-cdp-verify.mjs"; + const source = readFileSync(new URL("./classmap-cdp-verify.mjs", import.meta.url), "utf8"); const verifier = fileURLToPath(new URL("./classmap-cdp-verify.mjs", import.meta.url)); @@ -68,3 +70,27 @@ for (const args of [ assert.match(result.stderr, /invalid/i); }); } + +test("an overlay renames classes on top of the CLI css-map", () => { + const dir = mkdtempSync(join(tmpdir(), "classmap-cdp-test-")); + try { + writeFileSync(join(dir, "classmap.json"), '{"topbar":"hashTopAA","nav":"hashNavBB"}\n'); + writeFileSync(join(dir, "css-map.json"), '{"hashTopAA":"main-topBar-old","hashNavBB":"main-navBar-navBar"}\n'); + writeFileSync(join(dir, "overlay.json"), '{"hashTopAA":"Root__globalNav"}\n'); + const checks = loadChecks({ + reportPath: null, + classmapPath: join(dir, "classmap.json"), + cssMapPath: join(dir, "css-map.json"), + overlayPath: join(dir, "overlay.json"), + }); + assert.deepEqual( + checks.map(({ path, semantic }) => ({ path, semantic })), + [ + { path: "topbar", semantic: "Root__globalNav" }, + { path: "nav", semantic: "main-navBar-navBar" }, + ], + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/scripts/classmap-e2e.sh b/scripts/classmap-e2e.sh index 126bef5290..84475b905d 100755 --- a/scripts/classmap-e2e.sh +++ b/scripts/classmap-e2e.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# Full classmap pipeline: migrate → static verify → CDP e2e DOM verify. +# Full classmap pipeline: migrate → static verify → overlay → CDP e2e DOM verify. # # Usage: # ./scripts/classmap-e2e.sh @@ -282,8 +282,30 @@ else log "2/4 static verify skipped" fi +if [[ "$SKIP_FLATTEN" -eq 0 ]]; then + log "3/4 flatten css-map overlay" + for f in "$CLASSMAP_OUT" "$REPORT_OUT"; do + if [[ ! -f "$f" ]]; then + echo "Missing $f (run migrate first, or point OUT_DIR at existing artifacts)" >&2 + exit 1 + fi + done + META_ARGS=() + [[ -f "$OUT_DIR/META.json" ]] && META_ARGS=(--meta "$OUT_DIR/META.json") + "$NODE" scripts/classmap-capture.ts flatten \ + --classmap "$CLASSMAP_OUT" \ + --base-classmap "$BASE_CLASSMAP" \ + --css-map "$CSS_MAP" \ + --report "$REPORT_OUT" \ + "${META_ARGS[@]+${META_ARGS[@]}}" \ + --out "$OVERLAY_OUT" \ + --allow-partial +else + log "3/4 flatten skipped" +fi + if [[ "$SKIP_CDP" -eq 0 ]]; then - log "3/4 CDP e2e verify" + log "4/4 CDP e2e verify" if [[ "$ENSURE_CDP" -eq 1 ]]; then ensure_cdp fi @@ -295,39 +317,20 @@ if [[ "$SKIP_CDP" -eq 0 ]]; then if [[ "$RESTART" -eq 1 ]]; then RESTART_FLAG=(--restart) fi + OVERLAY_ARGS=() + [[ -f "$OVERLAY_OUT" ]] && OVERLAY_ARGS=(--overlay "$OVERLAY_OUT") "$NODE" scripts/classmap-cdp-verify.mjs \ --port "$CDP_PORT" \ --mode both \ --classmap "$CLASSMAP_OUT" \ --css-map "$CSS_MAP" \ + "${OVERLAY_ARGS[@]+${OVERLAY_ARGS[@]}}" \ --out "$CDP_OUT" \ --min-hit-rate "$MIN_HIT_RATE" \ "${NAV_FLAGS[@]}" \ "${RESTART_FLAG[@]}" else - log "3/4 CDP e2e skipped" -fi - -if [[ "$SKIP_FLATTEN" -eq 0 ]]; then - log "4/4 flatten css-map overlay" - for f in "$CLASSMAP_OUT" "$REPORT_OUT"; do - if [[ ! -f "$f" ]]; then - echo "Missing $f (run migrate first, or point OUT_DIR at existing artifacts)" >&2 - exit 1 - fi - done - META_ARGS=() - [[ -f "$OUT_DIR/META.json" ]] && META_ARGS=(--meta "$OUT_DIR/META.json") - "$NODE" scripts/classmap-capture.ts flatten \ - --classmap "$CLASSMAP_OUT" \ - --base-classmap "$BASE_CLASSMAP" \ - --css-map "$CSS_MAP" \ - --report "$REPORT_OUT" \ - "${META_ARGS[@]+${META_ARGS[@]}}" \ - --out "$OVERLAY_OUT" \ - --allow-partial -else - log "4/4 flatten skipped" + log "4/4 CDP e2e skipped" fi log "Done" From cae28f60fae44e5441a2dcc01676e810a71252b0 Mon Sep 17 00:00:00 2001 From: Afonso Jorge Ramos Date: Wed, 30 Sep 2026 00:34:13 +0100 Subject: [PATCH 10/19] chore(release): 3.0.0-beta.20 (#3975) --- rust/Cargo.lock | 8 ++++---- rust/Cargo.toml | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/rust/Cargo.lock b/rust/Cargo.lock index cafecf9e4a..66e5a48b10 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -433,7 +433,7 @@ checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" [[package]] name = "cli" -version = "3.0.0-beta.19" +version = "3.0.0-beta.20" dependencies = [ "anyhow", "clap", @@ -662,7 +662,7 @@ dependencies = [ [[package]] name = "daemon" -version = "3.0.0-beta.19" +version = "3.0.0-beta.20" dependencies = [ "anyhow", "axum", @@ -3117,7 +3117,7 @@ dependencies = [ [[package]] name = "spicetify" -version = "3.0.0-beta.19" +version = "3.0.0-beta.20" dependencies = [ "aho-corasick", "anyhow", @@ -3717,7 +3717,7 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "tui" -version = "3.0.0-beta.19" +version = "3.0.0-beta.20" dependencies = [ "anyhow", "crossterm", diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 12a2992bd9..f6811bb4f7 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.19" +version = "3.0.0-beta.20" edition = "2024" rust-version = "1.95" repository = "https://github.com/veryboringhwl/app" From 1f9be4a72630a93979bf752a186318568a9c6812 Mon Sep 17 00:00:00 2001 From: Afonso Jorge Ramos Date: Wed, 30 Sep 2026 12:54:26 +0100 Subject: [PATCH 11/19] fix(scripts): reach every classmap surface in the deep CDP run (#3977) --- scripts/classmap-capture.ts | 4 +- scripts/classmap-cdp-verify.mjs | 147 ++++++++++++++++++--------- scripts/classmap-cdp-verify.test.mjs | 11 +- scripts/classmap-e2e.sh | 25 +++-- 4 files changed, 128 insertions(+), 59 deletions(-) diff --git a/scripts/classmap-capture.ts b/scripts/classmap-capture.ts index 65a9adc116..fb420491b4 100644 --- a/scripts/classmap-capture.ts +++ b/scripts/classmap-capture.ts @@ -24,8 +24,8 @@ * Examples * -------- * node scripts/classmap-capture.ts migrate \ - * --base-classmap ../classmaps/1020040/classmap.json \ - * --base-css-dir ../xpui-archive/1.2.40.599 \ + * --base-classmap ../classmaps/1030001/classmap.json \ + * --base-spa /path/to/1.3.1/xpui.spa \ * --target-spa "/Applications/Spotify.app/Contents/Resources/Apps/xpui.spa" \ * --css-map css-map.json \ * --out classmaps/1020092/classmap.json \ diff --git a/scripts/classmap-cdp-verify.mjs b/scripts/classmap-cdp-verify.mjs index ecd8046b98..8b168138c4 100644 --- a/scripts/classmap-cdp-verify.mjs +++ b/scripts/classmap-cdp-verify.mjs @@ -368,6 +368,42 @@ const NAV_HELPERS = ` return "nav-failed:" + pathname; } }; + const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + const waitFor = async (probe, ms = 3000) => { + const deadline = Date.now() + ms; + while (Date.now() < deadline) { + const value = probe(); + if (value) return value; + await wait(100); + } + return null; + }; + const openMenu = () => $$('[role="menu"]').find(visible); + const menuItem = (pattern) => + $$('[role="menu"] [role="menuitem"], [role="menu"] button, [role="menu"] a').find((el) => visible(el) && pattern.test((el.textContent || "").trim())); + const openDialogs = () => $$('dialog[open], [role="dialog"]').filter(visible); + // Closes open dialogs and menus so the next step starts from the page itself. + const closeOverlays = async () => { + for (const dialog of openDialogs()) { + const close = [...dialog.querySelectorAll("button[aria-label]")].find((b) => /^close$/i.test(b.getAttribute("aria-label"))); + if (close) click(close); + else dialog.close?.(); + } + document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", code: "Escape", bubbles: true })); + await waitFor(() => !openDialogs().length && !openMenu(), 1500); + }; + // Opens the context menu of the first visible track row. + const openTrackMenu = async () => { + // Track rows first: the Your Library sidebar also uses role="row". + const row = ['main [data-testid="tracklist-row"]', '[data-testid="tracklist-row"]', 'main [data-testid="internal-tracklist-row"]'] + .map((selector) => $$(selector).find(visible)) + .find(Boolean); + if (!row) return null; + const rect = row.getBoundingClientRect(); + const opts = { bubbles: true, cancelable: true, view: window, clientX: rect.left + Math.min(40, rect.width / 2), clientY: rect.top + rect.height / 2, button: 2, buttons: 2 }; + row.dispatchEvent(new MouseEvent("contextmenu", opts)); + return waitFor(openMenu, 2000); + }; `; const NAV_STEPS = [ @@ -441,36 +477,47 @@ const NAV_STEPS = [ }, { name: "context_menu", - waitMs: 900, - expr: `(() => { + waitMs: 300, + expr: `(async () => { ${NAV_HELPERS} - // Right-click a track row / more button to open context menu - const row = - $$('[data-testid="tracklist-row"], [data-testid="internal-tracklist-row"], [role="row"]').find(visible) || - $$('div[aria-selected], [data-testid*="track"]').find(visible); - if (row) { - const rect = row.getBoundingClientRect(); - const x = rect.left + Math.min(40, rect.width / 2); - const y = rect.top + rect.height / 2; - const opts = { bubbles: true, cancelable: true, view: window, clientX: x, clientY: y, button: 2, buttons: 2 }; - row.dispatchEvent(new MouseEvent("contextmenu", opts)); - // also try the "more" button in the row - const more = row.querySelector('button[aria-label*="More" i], button[data-testid="more-button"]'); - if (more) click(more); - const menu = $$('[data-testid="context-menu"], [role="menu"], #context-menu, .main-contextMenu-menu').find(visible); - return menu ? "context-menu-open" : "context-menu-dispatched"; - } - // Fallback: more button anywhere in main view - const more = findClickable([(e) => /more options|more/i.test(textOf(e)) && e.tagName === "BUTTON"]); - if (click(more)) return "clicked-more"; - return "no-track-row"; + if (await openTrackMenu()) return "context-menu-open"; + return $$('[data-testid="tracklist-row"], [role="row"]').find(visible) ? "context-menu-dispatched" : "no-track-row"; + })()`, + }, + { + // Menu labels are matched in English, the language the verifier expects the client to use. + name: "track_credits", + waitMs: 300, + expr: `(async () => { + ${NAV_HELPERS} + if (!openMenu() && !(await openTrackMenu())) return "no-track-row"; + const item = menuItem(/^View credits$/); + if (!click(item)) return "credits-not-found"; + return (await waitFor(() => openDialogs().length, 3000)) ? "credits-open" : "credits-not-found"; + })()`, + }, + { + name: "embed", + waitMs: 300, + expr: `(async () => { + ${NAV_HELPERS} + await closeOverlays(); + if (!(await openTrackMenu())) return "no-track-row"; + const share = menuItem(/^Share$/); + if (!share) return "embed-not-found"; + share.dispatchEvent(new MouseEvent("mouseover", { bubbles: true })); + click(share); + const embed = await waitFor(() => menuItem(/^Embed (track|song|episode)$/), 2000); + if (!click(embed)) return "embed-not-found"; + return (await waitFor(() => openDialogs().length, 3000)) ? "embed-open" : "embed-not-found"; })()`, }, { name: "sort_or_filter", waitMs: 800, - expr: `(() => { + expr: `(async () => { ${NAV_HELPERS} + await closeOverlays(); const el = findClickable([ (e) => /sort|filter|custom order|recently|title|artist/i.test(textOf(e)), (e) => (e.getAttribute("data-testid") || "").includes("sort"), @@ -481,43 +528,49 @@ const NAV_STEPS = [ }, { name: "settings", - waitMs: 1500, - expr: `(() => { + waitMs: 300, + expr: `(async () => { ${NAV_HELPERS} - // Profile chip → Settings - const profile = findClickable([ - (e) => (e.getAttribute("data-testid") || "") === "user-widget-link", - (e) => /profile|account menu/i.test(textOf(e)), - ]); - if (profile) click(profile); - const pref = findClickable([ - (e) => (e.getAttribute("href") || "").includes("/preferences"), - (e) => /settings|preferences/i.test(textOf(e)), - ]); - if (click(pref)) return "clicked-settings"; + await closeOverlays(); + // Settings opens from the account menu; a pushed history entry doesn't render it. + const profile = findClickable([(e) => (e.getAttribute("data-testid") || "") === "user-widget-link"]); + if (click(profile)) { + const item = await waitFor(() => menuItem(/^Settings$/), 2000); + if (click(item)) { + const rendered = await waitFor(() => $$("main input, main select").length > 5, 5000); + return rendered ? "settings-open" : "settings-not-rendered"; + } + } return navigateSpa("/preferences"); })()`, }, { name: "settings_scroll", - waitMs: 600, - expr: `(() => { + waitMs: 300, + expr: `(async () => { ${NAV_HELPERS} - // Scroll settings main to mount more sections - const main = document.querySelector('main, [data-testid="preferences-page"], [class*="settings"]') || document.scrollingElement; - if (main) { - main.scrollTop = main.scrollHeight / 2; - window.scrollTo(0, document.body.scrollHeight / 2); - return "scrolled-settings"; + // Scroll the whole settings page so lazily mounted sections render. + const inputs = $$("main input, main select"); + let scroller = inputs[0]; + while (scroller && !(scroller.scrollHeight > scroller.clientHeight + 50 && /auto|scroll/.test(getComputedStyle(scroller).overflowY))) { + scroller = scroller.parentElement; + } + if (!scroller) return "no-settings-scroll-target"; + for (let y = 0; y <= scroller.scrollHeight; y += 500) { + scroller.scrollTop = y; + await wait(120); } - return "no-settings-scroll-target"; + return "scrolled-settings"; })()`, }, ]; -function navigationSucceeded(result) { +export function navigationSucceeded(result) { return ( - typeof result === "string" && !/^(?:nav-failed|context-menu-dispatched|no-track-row|sort-not-found|no-settings-scroll-target)(?::|$)/.test(result) + typeof result === "string" && + !/^(?:nav-failed|context-menu-dispatched|no-track-row|sort-not-found|no-settings-scroll-target|credits-not-found|embed-not-found|settings-not-rendered)(?::|$)/.test( + result, + ) ); } diff --git a/scripts/classmap-cdp-verify.test.mjs b/scripts/classmap-cdp-verify.test.mjs index c133104832..11fd44d5cc 100644 --- a/scripts/classmap-cdp-verify.test.mjs +++ b/scripts/classmap-cdp-verify.test.mjs @@ -6,7 +6,7 @@ import { join } from "node:path"; import { test } from "node:test"; import { fileURLToPath } from "node:url"; -import { loadChecks } from "./classmap-cdp-verify.mjs"; +import { loadChecks, navigationSucceeded } from "./classmap-cdp-verify.mjs"; const source = readFileSync(new URL("./classmap-cdp-verify.mjs", import.meta.url), "utf8"); const verifier = fileURLToPath(new URL("./classmap-cdp-verify.mjs", import.meta.url)); @@ -94,3 +94,12 @@ test("an overlay renames classes on top of the CLI css-map", () => { rmSync(dir, { recursive: true, force: true }); } }); + +test("a step that could not open its surface does not count as navigation", () => { + for (const result of ["context-menu-open", "credits-open", "embed-open", "settings-open", "scrolled-settings", "clicked-home"]) { + assert.equal(navigationSucceeded(result), true, result); + } + for (const result of ["context-menu-dispatched", "credits-not-found", "embed-not-found", "settings-not-rendered", "no-track-row", "nav-failed:/"]) { + assert.equal(navigationSucceeded(result), false, result); + } +}); diff --git a/scripts/classmap-e2e.sh b/scripts/classmap-e2e.sh index 84475b905d..1a4e0e6617 100755 --- a/scripts/classmap-e2e.sh +++ b/scripts/classmap-e2e.sh @@ -9,9 +9,10 @@ # Env: # SPOTIFY_SPA Path to xpui.spa (default: macOS Spotify.app) # SPOTIFY_VERSION Exact Spotify version recorded in the static report -# BASE_CLASSMAP Base classmap JSON (default: ../classmaps/1020040/...) -# BASE_CSS_DIR Base CSS dir from xpui-archive -# OUT_DIR Output dir (default: classmaps/1020092) +# BASE_CLASSMAP Base classmap JSON (default: the newest key in ../classmaps) +# BASE_SPA Stock xpui.spa of the base build (or BASE_CSS_DIR); required to migrate +# BASE_CSS_DIR Directory of the base build's stock CSS +# OUT_DIR Output dir (default: $TMPDIR/spicetify-classmap-e2e) # CDP_PORT Remote debugging port (default: 9222) # MIN_HIT_RATE CDP pass threshold (default: 0.25) # SPICETIFY Spicetify binary (default: ~/.spicetify/spicetify) @@ -75,9 +76,11 @@ STOCK_SPA_CANDIDATES=( "/Applications/Spotify.app/Contents/Resources/Apps/xpui.spa" "/Applications/Spotify.app/Contents/Resources/Apps/xpui.spa.bak" ) -BASE_CLASSMAP="${BASE_CLASSMAP:-$ROOT/../classmaps/1020040/classmap.json}" -BASE_CSS_DIR="${BASE_CSS_DIR:-$ROOT/../xpui-archive/1.2.40.599}" -OUT_DIR="${OUT_DIR:-$ROOT/classmaps/1020092}" +NEWEST_KEY="$(ls -d "$ROOT"/../classmaps/[0-9][0-9][0-9][0-9][0-9][0-9][0-9] 2>/dev/null | sort | tail -1)" +BASE_CLASSMAP="${BASE_CLASSMAP:-$NEWEST_KEY/classmap.json}" +BASE_SPA="${BASE_SPA:-}" +BASE_CSS_DIR="${BASE_CSS_DIR:-}" +OUT_DIR="${OUT_DIR:-${TMPDIR:-/tmp}/spicetify-classmap-e2e}" CSS_MAP="${CSS_MAP:-$ROOT/css-map.json}" CDP_PORT="${CDP_PORT:-9222}" SPICETIFY="${SPICETIFY:-$HOME/.spicetify/spicetify}" @@ -246,14 +249,18 @@ if [[ "$SKIP_MIGRATE" -eq 0 ]]; then echo "Missing base classmap: $BASE_CLASSMAP" >&2 exit 1 fi - if [[ ! -d "$BASE_CSS_DIR" ]]; then - echo "Missing base CSS dir: $BASE_CSS_DIR" >&2 + if [[ -n "$BASE_SPA" && -f "$BASE_SPA" ]]; then + BASE_ARGS=(--base-spa "$BASE_SPA") + elif [[ -n "$BASE_CSS_DIR" && -d "$BASE_CSS_DIR" ]]; then + BASE_ARGS=(--base-css-dir "$BASE_CSS_DIR") + else + echo "Set BASE_SPA to the stock xpui.spa of the base build, or BASE_CSS_DIR to its CSS" >&2 exit 1 fi resolve_target_css_args 1 "$NODE" scripts/classmap-capture.ts migrate \ --base-classmap "$BASE_CLASSMAP" \ - --base-css-dir "$BASE_CSS_DIR" \ + "${BASE_ARGS[@]}" \ "${TARGET_ARGS[@]}" \ --css-map "$CSS_MAP" \ --out "$CLASSMAP_OUT" \ From 70a1b2d6f7b8f7f469c65ad0e9e11378e0e40cc4 Mon Sep 17 00:00:00 2001 From: Afonso Jorge Ramos Date: Wed, 30 Sep 2026 14:46:06 +0100 Subject: [PATCH 12/19] feat(scripts): match CDP menu labels in any language and finish on slow clients (#3978) --- scripts/classmap-cdp-verify.mjs | 89 +++++++++++++++++++--------- scripts/classmap-cdp-verify.test.mjs | 51 ++++++++++------ 2 files changed, 94 insertions(+), 46 deletions(-) diff --git a/scripts/classmap-cdp-verify.mjs b/scripts/classmap-cdp-verify.mjs index 8b168138c4..427d5c830c 100644 --- a/scripts/classmap-cdp-verify.mjs +++ b/scripts/classmap-cdp-verify.mjs @@ -322,7 +322,31 @@ class CdpSession { } /** Shared helpers injected into page for navigation recipes. */ +/** + * The menu labels the navigation steps click, in the client's language. + * `translations` is the client's own i18n/.json; missing keys fall back + * to English. + */ +export function menuLabels(translations = {}) { + const t = (key, english) => (typeof translations[key] === "string" && translations[key]) || english; + return { + credits: t("contextmenu.show-credits", "View credits"), + share: t("contextmenu.share", "Share"), + embed: [t("ewg.title.track", "Embed track"), t("ewg.title.episode", "Embed episode")], + settings: t("user.settings", "Settings"), + }; +} + const NAV_HELPERS = ` + const menuLabels = ${menuLabels.toString()}; + const labels = async () => { + const lang = document.documentElement.lang || "en"; + try { + const response = await fetch("/i18n/" + lang + ".json"); + if (response.ok) return menuLabels(await response.json()); + } catch {} + return menuLabels(); + }; const $$ = (sel, root = document) => [...root.querySelectorAll(sel)]; const visible = (el) => { if (!el) return false; @@ -335,7 +359,8 @@ const NAV_HELPERS = ` if (!el) return false; el.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true, cancelable: true, view: window })); el.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true, view: window })); - el.click(); + if (typeof el.click === "function") el.click(); + else el.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, view: window })); el.dispatchEvent(new MouseEvent("mouseup", { bubbles: true, cancelable: true, view: window })); return true; }; @@ -379,8 +404,8 @@ const NAV_HELPERS = ` return null; }; const openMenu = () => $$('[role="menu"]').find(visible); - const menuItem = (pattern) => - $$('[role="menu"] [role="menuitem"], [role="menu"] button, [role="menu"] a').find((el) => visible(el) && pattern.test((el.textContent || "").trim())); + const menuItem = (...texts) => + $$('[role="menu"] [role="menuitem"], [role="menu"] button, [role="menu"] a').find((el) => visible(el) && texts.includes((el.textContent || "").trim())); const openDialogs = () => $$('dialog[open], [role="dialog"]').filter(visible); // Closes open dialogs and menus so the next step starts from the page itself. const closeOverlays = async () => { @@ -390,7 +415,7 @@ const NAV_HELPERS = ` else dialog.close?.(); } document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", code: "Escape", bubbles: true })); - await waitFor(() => !openDialogs().length && !openMenu(), 1500); + await waitFor(() => !openDialogs().length && !openMenu(), 5000); }; // Opens the context menu of the first visible track row. const openTrackMenu = async () => { @@ -402,7 +427,7 @@ const NAV_HELPERS = ` const rect = row.getBoundingClientRect(); const opts = { bubbles: true, cancelable: true, view: window, clientX: rect.left + Math.min(40, rect.width / 2), clientY: rect.top + rect.height / 2, button: 2, buttons: 2 }; row.dispatchEvent(new MouseEvent("contextmenu", opts)); - return waitFor(openMenu, 2000); + return waitFor(openMenu, 8000); }; `; @@ -458,21 +483,22 @@ const NAV_STEPS = [ }, { name: "playlist", - waitMs: 1800, - expr: `(() => { + waitMs: 300, + expr: `(async () => { ${NAV_HELPERS} - // Prefer an in-library playlist / liked songs / card link - const el = findClickable([ - (e) => (e.getAttribute("href") || "").includes("/playlist/"), - (e) => (e.getAttribute("href") || "").includes("/collection/tracks"), - (e) => /liked songs|playlist/i.test(textOf(e)) && (e.getAttribute("href") || "").startsWith("/"), - (e) => (e.getAttribute("data-testid") || "").includes("playlist") || (e.getAttribute("data-testid") || "") === "internal-tracklist-row", - ]); - if (click(el)) return "clicked-playlist-or-liked:" + (el.getAttribute("href") || el.getAttribute("data-testid") || el.tagName); - // Fallback: open a well-known public playlist route (Today's Top Hits-ish may 404; use search results cards) - const card = $$('[data-testid="card-click-handler"], [data-testid="top-result-card"], a[href*="playlist"], a[href*="album"]').find(visible); - if (click(card)) return "clicked-card:" + (card.getAttribute("href") || card.getAttribute("data-testid")); - return navigateSpa("/collection/tracks"); + // Only real links: a filter chip such as "Playlists" matches a looser test and navigates nowhere. + const links = () => $$("a[href]").filter((a) => visible(a) && /^\\/(playlist|album)\\/|^\\/collection\\/tracks/.test(a.getAttribute("href"))); + if (!links().length) { + click(findClickable([(e) => e.getAttribute("data-testid") === "home-button"])); + await waitFor(() => links().length, 15000); + } + const tracksRendered = () => waitFor(() => $$('main [data-testid="tracklist-row"]').some(visible), 45000); + for (const link of links().slice(0, 2)) { + const target = link.getAttribute("href"); + click(link); + if (await tracksRendered()) return "clicked-playlist-or-liked:" + target; + } + return links().length ? "tracks-not-rendered" : "no-playlist-link"; })()`, }, { @@ -485,15 +511,14 @@ const NAV_STEPS = [ })()`, }, { - // Menu labels are matched in English, the language the verifier expects the client to use. name: "track_credits", waitMs: 300, expr: `(async () => { ${NAV_HELPERS} if (!openMenu() && !(await openTrackMenu())) return "no-track-row"; - const item = menuItem(/^View credits$/); + const item = menuItem((await labels()).credits); if (!click(item)) return "credits-not-found"; - return (await waitFor(() => openDialogs().length, 3000)) ? "credits-open" : "credits-not-found"; + return (await waitFor(() => openDialogs().length, 10000)) ? "credits-open" : "credits-not-found"; })()`, }, { @@ -503,13 +528,14 @@ const NAV_STEPS = [ ${NAV_HELPERS} await closeOverlays(); if (!(await openTrackMenu())) return "no-track-row"; - const share = menuItem(/^Share$/); + const text = await labels(); + const share = menuItem(text.share); if (!share) return "embed-not-found"; share.dispatchEvent(new MouseEvent("mouseover", { bubbles: true })); click(share); - const embed = await waitFor(() => menuItem(/^Embed (track|song|episode)$/), 2000); + const embed = await waitFor(() => menuItem(...text.embed), 8000); if (!click(embed)) return "embed-not-found"; - return (await waitFor(() => openDialogs().length, 3000)) ? "embed-open" : "embed-not-found"; + return (await waitFor(() => openDialogs().length, 10000)) ? "embed-open" : "embed-not-found"; })()`, }, { @@ -535,9 +561,10 @@ const NAV_STEPS = [ // Settings opens from the account menu; a pushed history entry doesn't render it. const profile = findClickable([(e) => (e.getAttribute("data-testid") || "") === "user-widget-link"]); if (click(profile)) { - const item = await waitFor(() => menuItem(/^Settings$/), 2000); + const settingsLabel = (await labels()).settings; + const item = await waitFor(() => menuItem(settingsLabel), 8000); if (click(item)) { - const rendered = await waitFor(() => $$("main input, main select").length > 5, 5000); + const rendered = await waitFor(() => $$("main input, main select").length > 5, 20000); return rendered ? "settings-open" : "settings-not-rendered"; } } @@ -568,7 +595,7 @@ const NAV_STEPS = [ export function navigationSucceeded(result) { return ( typeof result === "string" && - !/^(?:nav-failed|context-menu-dispatched|no-track-row|sort-not-found|no-settings-scroll-target|credits-not-found|embed-not-found|settings-not-rendered)(?::|$)/.test( + !/^(?:nav-failed|context-menu-dispatched|no-track-row|sort-not-found|no-settings-scroll-target|credits-not-found|embed-not-found|settings-not-rendered|tracks-not-rendered|no-playlist-link)(?::|$)/.test( result, ) ); @@ -723,6 +750,12 @@ async function main() { return result; }; + // A slow client (an emulated VM) can take a minute to render its shell after the page loads. + const ready = await session.evaluate(`(async () => { + ${NAV_HELPERS} + return Boolean(await waitFor(() => $$("button").length > 50 && document.querySelector('[data-testid="home-button"], [data-testid="global-nav-bar"], nav'), ${args.timeoutMs})); + })()`); + if (!ready) console.warn(` app shell not rendered within ${args.timeoutMs}ms; probing anyway`); await runProbe("initial"); if (args.navigate) { diff --git a/scripts/classmap-cdp-verify.test.mjs b/scripts/classmap-cdp-verify.test.mjs index 11fd44d5cc..8cac87c4af 100644 --- a/scripts/classmap-cdp-verify.test.mjs +++ b/scripts/classmap-cdp-verify.test.mjs @@ -1,21 +1,15 @@ import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { test } from "node:test"; import { fileURLToPath } from "node:url"; -import { loadChecks, navigationSucceeded } from "./classmap-cdp-verify.mjs"; +import { loadChecks, menuLabels, navigationSucceeded } from "./classmap-cdp-verify.mjs"; -const source = readFileSync(new URL("./classmap-cdp-verify.mjs", import.meta.url), "utf8"); const verifier = fileURLToPath(new URL("./classmap-cdp-verify.mjs", import.meta.url)); -test("CDP reports bind deep mode and the exact classmap digest", () => { - assert.match(source, /deep:\s*args\.deep/); - assert.match(source, /classmap:\s*\{\s*sha256:/); -}); - test("help works without report paths", () => { const result = spawnSync(process.execPath, [verifier, "--help"], { encoding: "utf8", @@ -44,15 +38,16 @@ test("out-dir does not silently consume a stale migrate report", () => { } }); -test("semantic class conjunction is mapped token by token", () => { - assert.match(source, /semanticClassName\(hash, cssMap\)/); - assert.match(source, /split\(\/\\s\+\/\).*cssMap\[token\] \|\| token/s); -}); - -test("navigation success excludes explicit failure sentinels", () => { - assert.match(source, /function navigationSucceeded\(result\)/); - assert.match(source, /nav-failed\|context-menu-dispatched\|no-track-row\|sort-not-found\|no-settings-scroll-target/); - assert.match(source, /if \(!navigationSucceeded\(navResult\)\)/); +test("a multi-class leaf is mapped to semantic names token by token", () => { + const dir = mkdtempSync(join(tmpdir(), "classmap-cdp-test-")); + try { + writeFileSync(join(dir, "classmap.json"), '{"button":"hashBtnAA e-10860-legacy-button"}\n'); + writeFileSync(join(dir, "css-map.json"), '{"hashBtnAA":"main-topBar-button"}\n'); + const [check] = loadChecks({ reportPath: null, classmapPath: join(dir, "classmap.json"), cssMapPath: join(dir, "css-map.json") }); + assert.equal(check.semantic, "main-topBar-button e-10860-legacy-button"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } }); for (const args of [ @@ -99,7 +94,27 @@ test("a step that could not open its surface does not count as navigation", () = for (const result of ["context-menu-open", "credits-open", "embed-open", "settings-open", "scrolled-settings", "clicked-home"]) { assert.equal(navigationSucceeded(result), true, result); } - for (const result of ["context-menu-dispatched", "credits-not-found", "embed-not-found", "settings-not-rendered", "no-track-row", "nav-failed:/"]) { + for (const result of [ + "tracks-not-rendered", + "no-playlist-link", + "context-menu-dispatched", + "credits-not-found", + "embed-not-found", + "settings-not-rendered", + "no-track-row", + "nav-failed:/", + ]) { assert.equal(navigationSucceeded(result), false, result); } }); + +test("menu labels come from the client's translations, with English for missing keys", () => { + // Strings from the stock Spotify 1.3.1.234 i18n/es.json. + const es = { "contextmenu.show-credits": "Ver créditos", "contextmenu.share": "Compartir", "ewg.title.track": "Insertar canción" }; + assert.deepEqual(menuLabels(es), { + credits: "Ver créditos", + share: "Compartir", + embed: ["Insertar canción", "Embed episode"], + settings: "Settings", + }); +}); From 38c2f5ba8e12e89964eaed2b12d9e10a8de9710c Mon Sep 17 00:00:00 2001 From: Afonso Jorge Ramos Date: Wed, 30 Sep 2026 16:05:37 +0100 Subject: [PATCH 13/19] fix(rust): keep Spotify running when Apply lacks write access (#3971) --- install.sh | 7 +- rust/crates/spicetify/src/commands/apply.rs | 112 +++++++++++++++++--- 2 files changed, 102 insertions(+), 17 deletions(-) diff --git a/install.sh b/install.sh index 1977903f1d..8ede01662e 100755 --- a/install.sh +++ b/install.sh @@ -261,17 +261,20 @@ log "Run 'spicetify --help' to get started" if [ "$channel" = "v3" ]; then log "This is a v3 preview. Modules are managed in-app through the Module Store, so there is no Marketplace step." # Apply now as a convenience. This patches Spotify and restarts it, and it - # seeds the store so it is waiting in the sidebar. A soft step on purpose: + # seeds the store so it is waiting in the top bar. A soft step on purpose: # Spotify may not be installed or logged in yet, so a failure here leaves # the CLI installed and tells the user to apply once that is sorted. Not # 'init', which is a destructive reset (it deletes installed modules) and # apply does not need it. log "Patching Spotify (this restarts it)..." if "$exe" apply; then - log "Done. Open Spotify and click Module Store in the sidebar." + log "Done. Return to Spotify and click Module Store in the top bar." else log "Install finished, but 'spicetify apply' did not complete. Fix the reported cause, then run: spicetify apply" log "If it cannot find Spotify, 'spicetify config' shows the paths it resolved." + if [ "$target" = "linux-x86_64" ]; then + log "For a system-owned Spotify installation, run 'spicetify spotify install' as your normal user. It installs and patches a user-owned copy when a verified classmap is available." + fi fi exit 0 fi diff --git a/rust/crates/spicetify/src/commands/apply.rs b/rust/crates/spicetify/src/commands/apply.rs index 9a5531b85c..b98904cc1a 100644 --- a/rust/crates/spicetify/src/commands/apply.rs +++ b/rust/crates/spicetify/src/commands/apply.rs @@ -103,6 +103,10 @@ fn run_inner(ctx: &AppContext, mode: ApplyMode, activate: bool) -> Result<()> { refresh_classmap(ctx, &version.to_string(), no_cache)?; } + // Creating an existing Apps directory does not prove it is writable. + // Prepare the actual staging directory before stopping a working client. + let tmp = prepare_staging_dir(&dest_apps)?; + if activate { crate::lifecycle::stop(ctx)?; } @@ -113,26 +117,11 @@ fn run_inner(ctx: &AppContext, mode: ApplyMode, activate: bool) -> Result<()> { .map_err(fs_err("restoring xpui.spa from the backup at", &spa))?; } - // Names the path on failure: a bare "Permission denied" here means the - // resolved Spotify directory is not writable (often a system-wide install, - // or a bundle detection that missed the real location), and the errno - // alone does not say which directory to look at. - std::fs::create_dir_all(&dest_apps).map_err(|e| { - anyhow::anyhow!( - "cannot prepare {}: {e}. If Spotify is installed elsewhere, set spotify_data_dir in config.toml", - dest_apps.display() - ) - })?; tracing::info!( "{}", fl!("extracting-spa", src = spa.to_string_lossy(), dest = dest_xpui.to_string_lossy()) ); - let tmp = dest_apps.join("xpui.tmp"); - if tmp.exists() { - cleanup_tmp(&tmp); - } - if let Err(e) = extract_into(&spa, &tmp) { cleanup_tmp(&tmp); return Err(e); @@ -334,6 +323,33 @@ fn cleanup_tmp(tmp: &Path) { } } +fn prepare_staging_dir(apps: &Path) -> Result { + let tmp = apps.join("xpui.tmp"); + let prepare = || -> std::io::Result<()> { + std::fs::create_dir_all(apps)?; + if let Err(error) = std::fs::remove_dir_all(&tmp) + && error.kind() != std::io::ErrorKind::NotFound + { + return Err(error); + } + std::fs::create_dir(&tmp) + }; + prepare().map_err(|error| { + let guidance = "If Spotify is installed elsewhere, set spotify_data_dir in config.toml."; + #[cfg(target_os = "linux")] + let guidance = if matches!( + error.kind(), + std::io::ErrorKind::PermissionDenied | std::io::ErrorKind::ReadOnlyFilesystem + ) { + "Run 'spicetify spotify install' as your normal user to install and patch a user-owned Spotify copy. This requires a verified classmap for that Spotify version. Do not run Spicetify with sudo." + } else { + guidance + }; + anyhow::anyhow!("cannot prepare {}: {error}. {guidance}", tmp.display()) + })?; + Ok(tmp) +} + fn extract_into(spa: &Path, dest: &Path) -> Result<()> { if !spa.exists() { return Err(anyhow::anyhow!(fl!("xpui-not-found", path = spa.to_string_lossy()))); @@ -699,6 +715,72 @@ fn patch_index_html( mod tests { use super::*; + #[cfg(target_os = "linux")] + #[test] + fn unwritable_installation_does_not_stop_spotify_or_change_its_archive() { + use std::io::Write; + use std::os::unix::fs::PermissionsExt; + + let root = std::env::temp_dir() + .join(format!("spicetify-apply-permissions-{}", std::process::id())); + let apps = root.join("Spotify/Apps"); + std::fs::create_dir_all(&apps).expect("temporary Spotify installation"); + let spa = apps.join("xpui.spa"); + let mut archive = zip::ZipWriter::new(std::fs::File::create(&spa).expect("stock archive")); + archive + .start_file("index.html", zip::write::SimpleFileOptions::default()) + .expect("stock index entry"); + archive.write_all(b"").expect("stock index contents"); + drop(archive.finish().expect("finish stock archive")); + let original = std::fs::read(&spa).expect("original archive bytes"); + let executable = root.join(format!("spa{}", std::process::id())); + let _ = std::fs::copy("/bin/sleep", &executable).expect("isolated client executable"); + let mut client = std::process::Command::new(&executable) + .arg("60") + .spawn() + .expect("start simulated client"); + let ctx = AppContext { + config_file: root.join("config/config.toml"), + config_root: root.join("config"), + mirror: false, + daemon: false, + spotify_data_dir: root.join("Spotify"), + spotify_exec: executable, + offline_bnk_dir: root.clone(), + block_spotify_updates: None, + }; + // Wait until process-name lookup can see the simulated client. + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + while !crate::lifecycle::is_running(&ctx) && std::time::Instant::now() < deadline { + std::thread::sleep(std::time::Duration::from_millis(10)); + } + let client_visible = crate::lifecycle::is_running(&ctx); + std::fs::set_permissions(&apps, std::fs::Permissions::from_mode(0o555)) + .expect("protect Spotify Apps"); + + let result = if client_visible { + run_inner(&ctx, ApplyMode::Cli { no_cache: false }, true) + } else { + Err(anyhow::anyhow!("simulated client did not become visible")) + }; + let client_status = client.try_wait().expect("client process state"); + let archive_after = std::fs::read(&spa).expect("archive after failed Apply"); + let backup_exists = apps.join("xpui.spa.backup").exists(); + std::fs::set_permissions(&apps, std::fs::Permissions::from_mode(0o755)) + .expect("restore fixture permissions"); + let _ = client.kill(); + let _ = client.wait().expect("reap simulated client"); + std::fs::remove_dir_all(root).expect("remove permission fixture"); + + assert!(client_visible, "process lookup must observe the simulated client before Apply"); + assert!(client_status.is_none(), "client exited: {client_status:?}; Apply: {result:?}"); + assert_eq!(archive_after, original); + assert!(!backup_exists); + let error = result.expect_err("read-only Apps must refuse Apply").to_string(); + assert!(error.contains("xpui.tmp"), "error must name the unwritable path: {error}"); + assert!(error.contains("spicetify spotify install"), "missing recovery: {error}"); + } + const STOCK: &str = r#"Spotify
"#; const STOCK_DIRECT: &str = r#"Spotify
"#; From 3b5855057a18c48e9d1fc58b56bd568c724c99e1 Mon Sep 17 00:00:00 2001 From: Afonso Jorge Ramos Date: Wed, 30 Sep 2026 16:05:43 +0100 Subject: [PATCH 14/19] fix(rust): restore Store and Settings on classmap fallback (#3968) --- rust/crates/spicetify/src/module/cssmap.rs | 57 ++++++++++++++++++++-- rust/crates/spicetify/src/module/stage.rs | 2 +- 2 files changed, 53 insertions(+), 6 deletions(-) diff --git a/rust/crates/spicetify/src/module/cssmap.rs b/rust/crates/spicetify/src/module/cssmap.rs index 0d0cbc15bf..a1c949c432 100644 --- a/rust/crates/spicetify/src/module/cssmap.rs +++ b/rust/crates/spicetify/src/module/cssmap.rs @@ -50,15 +50,14 @@ impl CssMap { let mut map: BTreeMap = serde_json::from_str(&raw).ok()?; tracing::info!("using css map {source} ({} entries)", map.len()); - if let Some(overlay) = find_overlay(config_root, classmap_key) + let overlay_key = super::stage::resolve_classmap_key(config_root, classmap_key) + .map_or_else(|| classmap_key.to_owned(), |(key, _)| key); + if let Some(overlay) = find_overlay(config_root, &overlay_key) .and_then(|p| std::fs::read_to_string(p).ok()) .and_then(|raw| serde_json::from_str::>(&raw).ok()) && !overlay.is_empty() { - tracing::info!( - "applied css-map overlay for {classmap_key} ({} entries)", - overlay.len() - ); + tracing::info!("applied css-map overlay for {overlay_key} ({} entries)", overlay.len()); map.extend(overlay); } @@ -265,6 +264,54 @@ mod tests { assert_eq!(m.apply_css(src), src); } + #[test] + fn fallback_classmap_keeps_its_css_overlay_for_client_and_modules() { + let root = std::env::temp_dir().join(format!( + "spicetify-fallback-css-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock") + .as_nanos() + )); + let fallback = root.join("classmaps/1030000"); + std::fs::create_dir_all(&fallback).expect("fallback directory"); + std::fs::write(root.join("css-map.json"), "{}").expect("base css map"); + std::fs::write(fallback.join("classmap.json"), "{}").expect("classmap"); + std::fs::write( + fallback.join("css-map.json"), + r#"{"nqqkID2Vj36sqqr4GPmw":"main-globalNav-historyButtonsWrapper","fmmygDM45MTS7I35OyLh":"main-contextMenu-menuItemButton"}"#, + ) + .expect("fallback overlay"); + + let map = CssMap::load(&root, "1030001").expect("css map"); + let client = root.join("xpui"); + let modules = client.join("modules/manager"); + std::fs::create_dir_all(&modules).expect("staged modules"); + std::fs::write( + client.join("xpui-modules.js"), + r#"const classes={nav:"nqqkID2Vj36sqqr4GPmw",menu:"fmmygDM45MTS7I35OyLh"};"#, + ) + .expect("client bundle"); + std::fs::write( + client.join("xpui-snapshot.css"), + ".nqqkID2Vj36sqqr4GPmw{display:flex}.fmmygDM45MTS7I35OyLh:hover{color:red}", + ) + .expect("client css"); + std::fs::write(modules.join("index.js"), r#"const cls="fmmygDM45MTS7I35OyLh";"#) + .expect("staged module"); + + let touched = apply_to_tree(&map, &client).expect("rewrite tree"); + let css = std::fs::read_to_string(client.join("xpui-snapshot.css")).expect("read css"); + let module = std::fs::read_to_string(modules.join("index.js")).expect("read module"); + std::fs::remove_dir_all(root).expect("cleanup"); + + assert_eq!(touched, 3, "a fallback must rewrite the client JS, CSS, and staged modules"); + assert!(css.contains(".main-globalNav-historyButtonsWrapper{display:flex}")); + assert!(css.contains(".main-contextMenu-menuItemButton:hover")); + assert!(module.contains("main-contextMenu-menuItemButton")); + } + #[test] fn does_not_search_for_a_legacy_map_beside_the_executable() { let config_root = PathBuf::from("configured-css-map-root"); diff --git a/rust/crates/spicetify/src/module/stage.rs b/rust/crates/spicetify/src/module/stage.rs index a5082afca6..7b1e846cc5 100644 --- a/rust/crates/spicetify/src/module/stage.rs +++ b/rust/crates/spicetify/src/module/stage.rs @@ -140,7 +140,7 @@ pub(crate) fn classmap_search_dirs(config_root: &Path) -> Vec { /// crossing a minor is not assumed to be safe. /// /// Returns the resolved key and whether it is a fallback. -fn resolve_classmap_key(config_root: &Path, key: &str) -> Option<(String, bool)> { +pub(crate) fn resolve_classmap_key(config_root: &Path, key: &str) -> Option<(String, bool)> { if find_classmap_file(config_root, key).is_some() { return Some((key.to_string(), false)); } From 65f68ca733755e110eef5b890ef310d5866898fc Mon Sep 17 00:00:00 2001 From: Afonso Jorge Ramos Date: Wed, 30 Sep 2026 17:20:04 +0200 Subject: [PATCH 15/19] fix(pkg): update only store-managed modules the user left enabled pkg update now shares the planner the system-module refresh uses: a module disabled or hand-pinned in the local vault is left alone, a developer's own directory or linked build is never replaced, and any module that could not be updated makes the command fail. The superseded store copy is still collected. --- rust/crates/spicetify/src/commands/pkg.rs | 322 ++++++++++++++-------- 1 file changed, 200 insertions(+), 122 deletions(-) diff --git a/rust/crates/spicetify/src/commands/pkg.rs b/rust/crates/spicetify/src/commands/pkg.rs index d685399de7..839db5cb7e 100644 --- a/rust/crates/spicetify/src/commands/pkg.rs +++ b/rust/crates/spicetify/src/commands/pkg.rs @@ -193,49 +193,87 @@ pub(crate) fn ensure_system_modules(ctx: &AppContext) { }; let intent = crate::module::vault::load(&paths.vault_path).unwrap_or_default(); for id in SYSTEM_MODULES { - if intent.modules.get(*id).is_some_and(local_intent_blocks) { - continue; - } - let present = paths.modules_root.join(id).exists(); - let installed = store_managed_version(&paths.modules_root, &paths.store_root, id); - let Some(module) = vault.modules.get(*id) else { - tracing::warn!("system module {id} is not in the registry"); - continue; - }; - let target = match resolve_version(module) { - Ok(version) => version, - Err(e) => { - tracing::warn!("cannot resolve system module {id}: {e}"); - continue; + match plan_refresh(&paths, &intent, &vault, id, true) { + Ok(Refresh::Stage { target, superseded }) => { + if let Err(e) = + stage_refresh(ctx, &paths, id, &target, superseded.as_deref(), &vault) + { + tracing::warn!("could not stage system module {id}@{target}: {e}"); + } } - }; - if !should_stage(present, installed.as_deref(), &target, !module.enabled.is_empty()) { - continue; - } - let Some(entry) = module.v.get(&target) else { - tracing::warn!("system module {id}@{target} is not in the registry"); - continue; - }; - if let Err(e) = seed_system_module(ctx, id, &target, entry) { - tracing::warn!("could not stage system module {id}@{target}: {e}"); - continue; + Ok(Refresh::Skip(_)) => {} + Err(e) => tracing::warn!("cannot refresh system module {id}: {e}"), } - // The version this refresh just superseded would otherwise sit in - // the store tree forever, a megabyte per release. - if let Some(old) = installed.filter(|old| old != &target) { - let superseded = - match crate::module::vault::StoreIdentifier::parse(&format!("{id}@{old}")) { - Ok(id) => id, - Err(e) => { - tracing::warn!("cannot collect invalid superseded package {id}@{old}: {e}"); - continue; - } - }; - if let Err(e) = crate::module::delete(&paths, &superseded) { - tracing::warn!("could not collect superseded {superseded}: {e}"); + } +} + +/// What a registry refresh does with one module. +#[derive(Debug, PartialEq, Eq)] +enum Refresh { + /// Install `target`, then collect the store copy of `superseded`. + Stage { target: String, superseded: Option }, + /// Leave the module as it is, for the reason given. + Skip(&'static str), +} + +/// Decides one module's refresh against the registry. Deliberate user intent +/// recorded in the local vault wins over freshness, and only store-managed +/// installs are ever replaced: a developer's real directory or a link into +/// their own build output is never judged against the registry. An absent +/// module is staged only when `seed_absent` asks for it. +fn plan_refresh( + paths: &crate::module::ModulePaths, + intent: &crate::module::vault::Vault, + vault: &Vault, + id: &str, + seed_absent: bool, +) -> Result { + if intent.modules.get(id).is_some_and(local_intent_blocks) { + return Ok(Refresh::Skip("disabled or pinned to an older version")); + } + let present = paths.modules_root.join(id).exists(); + if !present && !seed_absent { + anyhow::bail!("{id} is not installed"); + } + let installed = store_managed_version(&paths.modules_root, &paths.store_root, id); + if present && installed.is_none() { + return Ok(Refresh::Skip("not managed by the store")); + } + let Some(module) = vault.modules.get(id) else { + anyhow::bail!("{id} is not in the registry"); + }; + let target = resolve_version(module)?; + if !should_stage(present, installed.as_deref(), &target, !module.enabled.is_empty()) { + return Ok(Refresh::Skip("already current")); + } + Ok(Refresh::Stage { superseded: installed.filter(|old| old != &target), target }) +} + +/// Installs and enables `id@target`, then collects the superseded store copy, +/// which would otherwise sit in the store tree forever, a megabyte per release. +fn stage_refresh( + ctx: &AppContext, + paths: &crate::module::ModulePaths, + id: &str, + target: &str, + superseded: Option<&str>, + vault: &Vault, +) -> Result<()> { + let Some(entry) = vault.modules.get(id).and_then(|module| module.v.get(target)) else { + anyhow::bail!("{id}@{target} is not in the registry"); + }; + stage_module_version(ctx, id, target, entry)?; + if let Some(old) = superseded { + match crate::module::vault::StoreIdentifier::parse(&format!("{id}@{old}")) { + Ok(old) => { + if let Err(e) = crate::module::delete(paths, &old) { + tracing::warn!("could not collect superseded {old}: {e}"); + } } + Err(e) => tracing::warn!("cannot collect invalid superseded package {id}@{old}: {e}"), } } + Ok(()) } /// Whether the local vault records intent this refresh must not override: @@ -289,7 +327,7 @@ fn should_stage(present: bool, installed: Option<&str>, target: &str, target_pin } } -fn seed_system_module( +fn stage_module_version( ctx: &AppContext, id: &str, version: &str, @@ -306,7 +344,7 @@ fn seed_system_module( entry.checksum.clone(), )?; crate::module::enable_module(&ctx.config_root, &tag)?; - tracing::info!("staged system module {tag}"); + tracing::info!("staged {tag}"); Ok(()) } @@ -356,98 +394,56 @@ pub(crate) fn install(ctx: &AppContext, identifier: &str) -> Result<()> { ) } +/// `pkg update [id]`: refreshes one installed module, or every store-managed +/// one, to the registry's version, with the same rules the system-module +/// refresh follows. Fails when any module could not be updated. pub(crate) fn update(ctx: &AppContext, target_id: Option<&str>) -> Result<()> { let vault = cached_vault(&ctx.config_root)?; let paths = crate::module::ModulePaths::from_config_root(&ctx.config_root); - let installed_mods = installed(&ctx.config_root); - - if installed_mods.is_empty() { - tracing::info!("no modules installed"); + let intent = crate::module::vault::load(&paths.vault_path).unwrap_or_default(); + let ids: Vec = match target_id { + Some(id) => vec![id.to_string()], + None => installed(&ctx.config_root).into_iter().map(|(id, _)| id).collect(), + }; + let failures = update_modules(&paths, &intent, &vault, &ids, |id, target, superseded| { + stage_refresh(ctx, &paths, id, target, superseded, &vault) + }); + if failures.is_empty() { return Ok(()); } + anyhow::bail!("could not update {}", failures.join("; ")) +} - if let Some(target) = target_id { - let Some((_, current_version)) = installed_mods.iter().find(|(id, _)| id == target) else { - anyhow::bail!("module '{target}' is not installed"); - }; - - let Some(module) = vault.modules.get(target) else { - anyhow::bail!("module '{target}' is not in the vault"); - }; - - let target_version = resolve_version(module)?; - if !should_stage(true, Some(current_version), &target_version, !module.enabled.is_empty()) { - tracing::info!("{target} is already up to date ({current_version})"); - return Ok(()); - } - - let Some(entry) = module.v.get(&target_version) else { - anyhow::bail!("{target}@{target_version} is not in the vault"); - }; - - update_single_module(ctx, &paths, target, current_version, &target_version, entry)?; - } else { - let mut updated_count = 0; - for (id, current_version) in &installed_mods { - let Some(module) = vault.modules.get(id) else { - continue; - }; - - let Ok(target_version) = resolve_version(module) else { - continue; - }; - - if should_stage(true, Some(current_version), &target_version, !module.enabled.is_empty()) - && let Some(entry) = module.v.get(&target_version) - { - tracing::info!("updating {id}: {current_version} -> {target_version}"); - if let Err(e) = update_single_module(ctx, &paths, id, current_version, &target_version, entry) { - tracing::warn!("failed to update {id}: {e}"); - } else { - updated_count += 1; +/// Plans and stages each module, logging what happened, and returns one +/// message per module that failed. +fn update_modules( + paths: &crate::module::ModulePaths, + intent: &crate::module::vault::Vault, + vault: &Vault, + ids: &[String], + mut stage: impl FnMut(&str, &str, Option<&str>) -> Result<()>, +) -> Vec { + let mut failures = Vec::new(); + let mut updated = 0usize; + for id in ids { + match plan_refresh(paths, intent, vault, id, false) { + Ok(Refresh::Stage { target, superseded }) => { + match stage(id, &target, superseded.as_deref()) { + Ok(()) => { + updated += 1; + tracing::info!("updated {id} to {target}"); + } + Err(e) => failures.push(format!("{id}@{target}: {e}")), } } - } - - if updated_count == 0 { - tracing::info!("all modules are up to date"); - } else { - tracing::info!("successfully updated {updated_count} module(s)"); + Ok(Refresh::Skip(reason)) => tracing::info!("{id}: {reason}"), + Err(e) => failures.push(format!("{id}: {e}")), } } - - Ok(()) -} - -fn update_single_module( - ctx: &AppContext, - paths: &crate::module::ModulePaths, - id: &str, - old_version: &str, - new_version: &str, - entry: &VaultVersion, -) -> Result<()> { - if entry.artifacts.is_empty() { - anyhow::bail!("{id}@{new_version} has no artifacts"); + if failures.is_empty() { + tracing::info!("updated {updated} module(s)"); } - - let tag = format!("{id}@{new_version}"); - crate::module::install_from_vault( - &ctx.config_root, - &tag, - entry.artifacts.clone(), - entry.checksum.clone(), - )?; - crate::module::enable_module(&ctx.config_root, &tag)?; - tracing::info!("updated {id} to {new_version}"); - - if old_version != new_version - && let Ok(superseded) = crate::module::vault::StoreIdentifier::parse(&format!("{id}@{old_version}")) - { - let _ = crate::module::delete(paths, &superseded); - } - - Ok(()) + failures } #[cfg(test)] @@ -578,6 +574,88 @@ mod tests { } } + /// A config root with a store-managed `lyrics@1.0.0`, a disabled + /// store-managed `off@1.0.0` and a developer's real `dev` directory. + fn update_fixture( + name: &str, + ) -> (std::path::PathBuf, crate::module::ModulePaths, crate::module::vault::Vault, Vault) { + let root = + std::env::temp_dir().join(format!("spicetify-update-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + let paths = crate::module::ModulePaths::from_config_root(&root); + for id in ["lyrics", "off"] { + std::fs::create_dir_all(paths.store_root.join(id).join("1.0.0")).expect("store copy"); + std::fs::create_dir_all(&paths.modules_root).expect("modules root"); + crate::util::link::create_dir_link( + &paths.store_root.join(id).join("1.0.0"), + &paths.modules_root.join(id), + ) + .expect("store link"); + } + std::fs::create_dir_all(paths.modules_root.join("dev")).expect("developer build"); + let mut local = crate::module::vault::Vault::default(); + assert!( + local.modules.insert("off".to_string(), intent(None, &[("1.0.0", true)])).is_none() + ); + let entry = || VaultVersion { + artifacts: vec!["https://example.invalid/m.zip".to_string()], + checksum: String::new(), + }; + let module = |versions: &[&str]| VaultModule { + enabled: String::new(), + v: versions.iter().map(|v| ((*v).to_string(), entry())).collect(), + }; + let vault = Vault { + modules: BTreeMap::from([ + ("lyrics".to_string(), module(&["1.0.0", "1.1.0"])), + ("off".to_string(), module(&["1.1.0"])), + ("dev".to_string(), module(&["2.0.0"])), + ]), + }; + (root, paths, local, vault) + } + + #[test] + fn update_plans_only_store_managed_modules_the_user_left_alone() { + let (root, paths, intent, vault) = update_fixture("plan"); + assert_eq!( + plan_refresh(&paths, &intent, &vault, "lyrics", false).expect("plan"), + Refresh::Stage { target: "1.1.0".to_string(), superseded: Some("1.0.0".to_string()) } + ); + assert_eq!( + plan_refresh(&paths, &intent, &vault, "dev", false).expect("plan"), + Refresh::Skip("not managed by the store"), + "a developer's build is never replaced" + ); + assert_eq!( + plan_refresh(&paths, &intent, &vault, "off", false).expect("plan"), + Refresh::Skip("disabled or pinned to an older version"), + "a disabled module stays off" + ); + let error = + plan_refresh(&paths, &intent, &vault, "ghost", false).expect_err("not installed"); + assert!(error.to_string().contains("ghost is not installed"), "{error}"); + std::fs::remove_dir_all(&root).expect("cleanup"); + } + + #[test] + fn update_stages_what_it_plans_and_reports_every_failure() { + let (root, paths, intent, vault) = update_fixture("report"); + let ids: Vec = + ["dev", "ghost", "lyrics", "off"].iter().map(|id| (*id).to_string()).collect(); + let mut staged = Vec::new(); + let failures = update_modules(&paths, &intent, &vault, &ids, |id, target, superseded| { + staged.push(format!("{id}@{target} replacing {superseded:?}")); + anyhow::bail!("download failed") + }); + assert_eq!(staged, ["lyrics@1.1.0 replacing Some(\"1.0.0\")"]); + assert_eq!(failures, ["ghost: ghost is not installed", "lyrics@1.1.0: download failed"]); + + let failures = update_modules(&paths, &intent, &vault, &ids[2..3], |_, _, _| Ok(())); + assert!(failures.is_empty(), "{failures:?}"); + std::fs::remove_dir_all(&root).expect("cleanup"); + } + #[test] fn honours_disable_and_hand_pins_but_not_deletion() { assert!( @@ -626,7 +704,7 @@ mod tests { let module = vault.modules.get(*id).expect("registry carries every system module"); let version = resolve_version(module).expect("resolvable"); let entry = module.v.get(&version).expect("resolved version has an entry"); - let result = seed_system_module(&ctx, id, &version, entry); + let result = stage_module_version(&ctx, id, &version, entry); assert!(result.is_ok(), "seed {id}: {:?}", result.err()); } From 5772bee38e6f86fb2bf8e5518da6ba7a3b12c452 Mon Sep 17 00:00:00 2001 From: Afonso Jorge Ramos Date: Wed, 30 Sep 2026 17:28:33 +0200 Subject: [PATCH 16/19] fix(pkg): give apply and pkg update their own refresh policies A refresh now plans under Recover, UpdateAll or UpdateNamed and returns a typed skip reason. A bulk update skips modules missing from the registry and versions installed from an explicit artifact instead of failing; naming a module overrides an inferred pin and fails with a hint when it is disabled or not store-managed. Versions a refresh already collected no longer count as a hand pin, so a registry rollback can't leave a module stuck. The update fetches the registry fresh, reads the local vault strictly, logs each change and tells the user to run apply, and pkg update is documented. --- docs/v3-modules.md | 17 + rust/crates/cli/src/main.rs | 2 +- rust/crates/spicetify/src/commands/pkg.rs | 358 +++++++++++++++++----- 3 files changed, 300 insertions(+), 77 deletions(-) diff --git a/docs/v3-modules.md b/docs/v3-modules.md index be01d0f7f9..713c51aa54 100644 --- a/docs/v3-modules.md +++ b/docs/v3-modules.md @@ -147,6 +147,23 @@ deliberate act rather than a source the CLI consults on its own: spicetify pkg install my-module https://example.com/my-module@1.0.0.zip ``` +### Updating installed modules + +`spicetify pkg update` moves every installed module to the version the +registry currently serves, and `spicetify pkg update ` does the same for +one module. It fetches the registry fresh and changes only modules the store +manages. Run `spicetify apply` afterwards; Spotify loads the new versions +then. A bulk update leaves these modules as they are and says why: + +- A module you disabled, or pinned by enabling an older version while a newer + one is installed. Naming the module overrides a pin, but not a disable. +- A developer's own directory or linked build. +- A module the registry doesn't carry, or a version installed from an + explicit artifact. + +The command fails when any module could not be updated, or when you name a +module it has to leave alone, so a script notices. + `localStorage["spicetify:defaultVaultUrl"]` repoints the store at another vault. That is a development lever for previewing a catalog before submitting it, not a distribution channel: it replaces the registry rather than adding diff --git a/rust/crates/cli/src/main.rs b/rust/crates/cli/src/main.rs index 93b38769a6..671c89fd4a 100644 --- a/rust/crates/cli/src/main.rs +++ b/rust/crates/cli/src/main.rs @@ -170,7 +170,7 @@ enum CliPkgAction { Delete { id: String }, #[command(about = "Enable a package")] Enable { id: String }, - #[command(about = "Update installed modules")] + #[command(about = "Update installed modules to the registry's versions (run apply afterwards)")] Update { #[arg(help = "Module identifier to update (updates all if omitted)")] id: Option, diff --git a/rust/crates/spicetify/src/commands/pkg.rs b/rust/crates/spicetify/src/commands/pkg.rs index 839db5cb7e..983ec4926d 100644 --- a/rust/crates/spicetify/src/commands/pkg.rs +++ b/rust/crates/spicetify/src/commands/pkg.rs @@ -191,9 +191,15 @@ pub(crate) fn ensure_system_modules(ctx: &AppContext) { return; } }; - let intent = crate::module::vault::load(&paths.vault_path).unwrap_or_default(); + let intent = crate::module::vault::load(&paths.vault_path).unwrap_or_else(|e| { + tracing::warn!( + "cannot read {}: {e}; refreshing without local intent", + paths.vault_path.display() + ); + crate::module::vault::Vault::default() + }); for id in SYSTEM_MODULES { - match plan_refresh(&paths, &intent, &vault, id, true) { + match plan_refresh(&paths, &intent, &vault, id, RefreshPolicy::Recover) { Ok(Refresh::Stage { target, superseded }) => { if let Err(e) = stage_refresh(ctx, &paths, id, &target, superseded.as_deref(), &vault) @@ -207,48 +213,113 @@ pub(crate) fn ensure_system_modules(ctx: &AppContext) { } } +/// Which caller a refresh serves; each treats missing modules, local intent and +/// registry gaps its own way. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum RefreshPolicy { + /// `apply`: seed absent system modules and never override local intent. + Recover, + /// `pkg update`: every installed module, skipping anything it can't judge. + UpdateAll, + /// `pkg update `: the user named this module, so an inferred pin gives way. + UpdateNamed, +} + +/// Why a refresh leaves a module as it is. +#[derive(Debug, PartialEq, Eq)] +enum Skip { + /// Installed but not enabled. + Disabled, + /// Enabled at an older version while a newer one is installed. + HandPinned { enabled: String }, + /// A developer's real directory or a link outside the store tree. + NotStoreManaged, + /// The registry doesn't carry this module. + NotInRegistry, + /// The installed version came from an explicit artifact, not the registry. + ExplicitArtifact, + /// Already at the registry's version. + Current, +} + +impl std::fmt::Display for Skip { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Disabled => f.write_str("disabled"), + Self::HandPinned { enabled } => write!(f, "pinned to {enabled}"), + Self::NotStoreManaged => f.write_str("not managed by the store"), + Self::NotInRegistry => f.write_str("not in the registry"), + Self::ExplicitArtifact => f.write_str("installed from an explicit artifact"), + Self::Current => f.write_str("already up to date"), + } + } +} + /// What a registry refresh does with one module. #[derive(Debug, PartialEq, Eq)] enum Refresh { /// Install `target`, then collect the store copy of `superseded`. Stage { target: String, superseded: Option }, - /// Leave the module as it is, for the reason given. - Skip(&'static str), + /// Leave the module as it is. + Skip(Skip), } -/// Decides one module's refresh against the registry. Deliberate user intent -/// recorded in the local vault wins over freshness, and only store-managed +/// Decides one module's refresh against the registry. Only store-managed /// installs are ever replaced: a developer's real directory or a link into -/// their own build output is never judged against the registry. An absent -/// module is staged only when `seed_absent` asks for it. +/// their own build output is never judged against the registry. fn plan_refresh( paths: &crate::module::ModulePaths, intent: &crate::module::vault::Vault, vault: &Vault, id: &str, - seed_absent: bool, + policy: RefreshPolicy, ) -> Result { - if intent.modules.get(id).is_some_and(local_intent_blocks) { - return Ok(Refresh::Skip("disabled or pinned to an older version")); - } + let local = intent.modules.get(id); let present = paths.modules_root.join(id).exists(); - if !present && !seed_absent { + match local.and_then(local_intent) { + Some(Skip::Disabled) => return Ok(Refresh::Skip(Skip::Disabled)), + Some(pinned @ Skip::HandPinned { .. }) if policy != RefreshPolicy::UpdateNamed => { + return Ok(Refresh::Skip(pinned)); + } + _ => {} + } + if !present && policy != RefreshPolicy::Recover { anyhow::bail!("{id} is not installed"); } let installed = store_managed_version(&paths.modules_root, &paths.store_root, id); if present && installed.is_none() { - return Ok(Refresh::Skip("not managed by the store")); + return Ok(Refresh::Skip(Skip::NotStoreManaged)); } let Some(module) = vault.modules.get(id) else { + if policy == RefreshPolicy::UpdateAll { + return Ok(Refresh::Skip(Skip::NotInRegistry)); + } anyhow::bail!("{id} is not in the registry"); }; + if policy == RefreshPolicy::UpdateAll + && let Some(version) = installed.as_deref() + && from_explicit_artifact(local, module, version) + { + return Ok(Refresh::Skip(Skip::ExplicitArtifact)); + } let target = resolve_version(module)?; if !should_stage(present, installed.as_deref(), &target, !module.enabled.is_empty()) { - return Ok(Refresh::Skip("already current")); + return Ok(Refresh::Skip(Skip::Current)); } Ok(Refresh::Stage { superseded: installed.filter(|old| old != &target), target }) } +/// Whether the installed `version` was installed from artifacts other than the +/// registry's, which a bulk update must not overwrite. +fn from_explicit_artifact( + local: Option<&crate::module::vault::Module>, + module: &VaultModule, + version: &str, +) -> bool { + let Some(store) = local.and_then(|local| local.versions.get(version)) else { return false }; + module.v.get(version).is_none_or(|entry| entry.artifacts != store.artifacts) +} + /// Installs and enables `id@target`, then collects the superseded store copy, /// which would otherwise sit in the store tree forever, a megabyte per release. fn stage_refresh( @@ -276,17 +347,25 @@ fn stage_refresh( Ok(()) } -/// Whether the local vault records intent this refresh must not override: -/// disabled while still installed, or pinned to an enabled version below one -/// that is still installed (the only way a downgrade comes about by hand). -/// A deleted module has no installed versions left and is fair game. -fn local_intent_blocks(module: &crate::module::vault::Module) -> bool { +/// The intent the local vault records for a module, if any refresh must honour +/// it: disabled while still installed, or enabled at a version below one that +/// is still installed (the only way a downgrade comes about by hand). Versions +/// a refresh already collected are no longer installed and don't count, so a +/// registry rollback doesn't leave a module looking pinned. A deleted module +/// has no installed versions left and is fair game. +fn local_intent(module: &crate::module::vault::Module) -> Option { let has_installed = module.versions.values().any(|store| store.installed); match &module.enabled { - None => has_installed, + None => has_installed.then_some(Skip::Disabled), Some(pin) => { - let Ok(pin) = semver::Version::parse(pin) else { return false }; - module.versions.keys().filter_map(|v| semver::Version::parse(v).ok()).any(|v| v > pin) + let parsed = semver::Version::parse(pin).ok()?; + module + .versions + .iter() + .filter(|(_, store)| store.installed) + .filter_map(|(v, _)| semver::Version::parse(v).ok()) + .any(|v| v > parsed) + .then(|| Skip::HandPinned { enabled: pin.clone() }) } } } @@ -395,55 +474,105 @@ pub(crate) fn install(ctx: &AppContext, identifier: &str) -> Result<()> { } /// `pkg update [id]`: refreshes one installed module, or every store-managed -/// one, to the registry's version, with the same rules the system-module -/// refresh follows. Fails when any module could not be updated. +/// one, to the registry's current version. The registry is fetched fresh, +/// falling back to the cache only when offline. Fails when any module could +/// not be updated, or when a named module was left as it is for a reason the +/// user has to act on. pub(crate) fn update(ctx: &AppContext, target_id: Option<&str>) -> Result<()> { - let vault = cached_vault(&ctx.config_root)?; + if let Some(id) = target_id + && let Some((name, _)) = id.split_once('@') + { + anyhow::bail!( + "pkg update takes a module id; to switch {name} to a specific version, run `spicetify pkg enable {id}`" + ); + } let paths = crate::module::ModulePaths::from_config_root(&ctx.config_root); - let intent = crate::module::vault::load(&paths.vault_path).unwrap_or_default(); - let ids: Vec = match target_id { - Some(id) => vec![id.to_string()], - None => installed(&ctx.config_root).into_iter().map(|(id, _)| id).collect(), + let intent = crate::module::vault::load(&paths.vault_path) + .map_err(|e| anyhow::anyhow!("cannot read {}: {e}", paths.vault_path.display()))?; + let (ids, policy) = match target_id { + Some(id) => (vec![id.to_string()], RefreshPolicy::UpdateNamed), + None => ( + installed(&ctx.config_root).into_iter().map(|(id, _)| id).collect(), + RefreshPolicy::UpdateAll, + ), }; - let failures = update_modules(&paths, &intent, &vault, &ids, |id, target, superseded| { + if ids.is_empty() { + tracing::info!("no modules installed"); + return Ok(()); + } + let vault = vault_via_cache( + &vault_cache_path(&ctx.config_root), + std::time::Duration::ZERO, + std::time::SystemTime::now(), + || fetch_vault_body(DEFAULT_VAULT), + )?; + let report = update_modules(&paths, &intent, &vault, &ids, policy, |id, target, superseded| { stage_refresh(ctx, &paths, id, target, superseded, &vault) }); - if failures.is_empty() { + if report.updated > 0 { + tracing::info!( + "updated {} module(s); run `spicetify apply` to load them in Spotify", + report.updated + ); + } else if report.failures.is_empty() { + tracing::info!("all modules are up to date"); + } + if report.failures.is_empty() { return Ok(()); } - anyhow::bail!("could not update {}", failures.join("; ")) + anyhow::bail!("could not update {}", report.failures.join("; ")) +} + +/// The outcome of an update run. +#[derive(Debug, Default, PartialEq, Eq)] +struct UpdateReport { + updated: usize, + failures: Vec, } -/// Plans and stages each module, logging what happened, and returns one -/// message per module that failed. +/// Plans and stages each module, logging what happened. A named module left +/// as it is for a reason the user has to act on counts as a failure. fn update_modules( paths: &crate::module::ModulePaths, intent: &crate::module::vault::Vault, vault: &Vault, ids: &[String], + policy: RefreshPolicy, mut stage: impl FnMut(&str, &str, Option<&str>) -> Result<()>, -) -> Vec { - let mut failures = Vec::new(); - let mut updated = 0usize; +) -> UpdateReport { + let mut report = UpdateReport::default(); for id in ids { - match plan_refresh(paths, intent, vault, id, false) { + match plan_refresh(paths, intent, vault, id, policy) { Ok(Refresh::Stage { target, superseded }) => { match stage(id, &target, superseded.as_deref()) { Ok(()) => { - updated += 1; - tracing::info!("updated {id} to {target}"); + report.updated += 1; + if let Some(old) = &superseded { + tracing::info!("updated {id}: {old} -> {target}"); + } else { + tracing::info!("updated {id} to {target}"); + } } - Err(e) => failures.push(format!("{id}@{target}: {e}")), + Err(e) => report.failures.push(format!("{id}@{target}: {e}")), } } - Ok(Refresh::Skip(reason)) => tracing::info!("{id}: {reason}"), - Err(e) => failures.push(format!("{id}: {e}")), + Ok(Refresh::Skip(Skip::Current)) => tracing::info!("{id}: {}", Skip::Current), + Ok(Refresh::Skip(Skip::Disabled)) if policy == RefreshPolicy::UpdateNamed => { + report.failures.push(format!( + "{id}: disabled; run `spicetify pkg enable {id}@` to turn it back on" + )); + } + Ok(Refresh::Skip(Skip::NotStoreManaged)) if policy == RefreshPolicy::UpdateNamed => { + report.failures.push(format!( + "{id}: {}, so it is never replaced from the registry", + Skip::NotStoreManaged + )); + } + Ok(Refresh::Skip(reason)) => tracing::info!("{id}: {reason}, left as is"), + Err(e) => report.failures.push(format!("{id}: {e}")), } } - if failures.is_empty() { - tracing::info!("updated {updated} module(s)"); - } - failures + report } #[cfg(test)] @@ -583,7 +712,7 @@ mod tests { std::env::temp_dir().join(format!("spicetify-update-{name}-{}", std::process::id())); let _ = std::fs::remove_dir_all(&root); let paths = crate::module::ModulePaths::from_config_root(&root); - for id in ["lyrics", "off"] { + for id in ["lyrics", "off", "orphan", "url", "pinned"] { std::fs::create_dir_all(paths.store_root.join(id).join("1.0.0")).expect("store copy"); std::fs::create_dir_all(&paths.modules_root).expect("modules root"); crate::util::link::create_dir_link( @@ -597,6 +726,12 @@ mod tests { assert!( local.modules.insert("off".to_string(), intent(None, &[("1.0.0", true)])).is_none() ); + let mut url = intent(Some("1.0.0"), &[("1.0.0", true)]); + url.versions.get_mut("1.0.0").expect("version").artifacts = + vec!["https://example.invalid/fork.zip".to_string()]; + assert!(local.modules.insert("url".to_string(), url).is_none()); + let pinned = intent(Some("1.0.0"), &[("1.0.0", true), ("1.1.0", true)]); + assert!(local.modules.insert("pinned".to_string(), pinned).is_none()); let entry = || VaultVersion { artifacts: vec!["https://example.invalid/m.zip".to_string()], checksum: String::new(), @@ -610,70 +745,141 @@ mod tests { ("lyrics".to_string(), module(&["1.0.0", "1.1.0"])), ("off".to_string(), module(&["1.1.0"])), ("dev".to_string(), module(&["2.0.0"])), + ("url".to_string(), module(&["1.0.0", "1.1.0"])), + ("absent".to_string(), module(&["1.0.0"])), + ("pinned".to_string(), module(&["1.0.0", "1.1.0", "1.2.0"])), ]), }; (root, paths, local, vault) } #[test] - fn update_plans_only_store_managed_modules_the_user_left_alone() { + fn each_refresh_policy_judges_modules_its_own_way() { let (root, paths, intent, vault) = update_fixture("plan"); + let plan = |id: &str, policy| plan_refresh(&paths, &intent, &vault, id, policy); + let stage = |target: &str, superseded: Option<&str>| Refresh::Stage { + target: target.to_string(), + superseded: superseded.map(str::to_string), + }; + use RefreshPolicy::{Recover, UpdateAll, UpdateNamed}; + + assert_eq!(plan("lyrics", UpdateAll).expect("plan"), stage("1.1.0", Some("1.0.0"))); + assert_eq!(plan("dev", UpdateAll).expect("plan"), Refresh::Skip(Skip::NotStoreManaged)); + assert_eq!(plan("off", UpdateNamed).expect("plan"), Refresh::Skip(Skip::Disabled)); + + assert_eq!(plan("orphan", UpdateAll).expect("plan"), Refresh::Skip(Skip::NotInRegistry)); + let error = + plan("orphan", UpdateNamed).expect_err("named module missing from the registry"); + assert!(error.to_string().contains("orphan is not in the registry"), "{error}"); + + assert_eq!(plan("url", UpdateAll).expect("plan"), Refresh::Skip(Skip::ExplicitArtifact)); + assert_eq!(plan("url", UpdateNamed).expect("plan"), stage("1.1.0", Some("1.0.0"))); + assert_eq!( - plan_refresh(&paths, &intent, &vault, "lyrics", false).expect("plan"), - Refresh::Stage { target: "1.1.0".to_string(), superseded: Some("1.0.0".to_string()) } + plan("pinned", UpdateAll).expect("plan"), + Refresh::Skip(Skip::HandPinned { enabled: "1.0.0".to_string() }) ); assert_eq!( - plan_refresh(&paths, &intent, &vault, "dev", false).expect("plan"), - Refresh::Skip("not managed by the store"), - "a developer's build is never replaced" + plan("pinned", UpdateNamed).expect("plan"), + stage("1.2.0", Some("1.0.0")), + "naming the module overrides an inferred pin" ); assert_eq!( - plan_refresh(&paths, &intent, &vault, "off", false).expect("plan"), - Refresh::Skip("disabled or pinned to an older version"), - "a disabled module stays off" + plan("pinned", Recover).expect("plan"), + Refresh::Skip(Skip::HandPinned { enabled: "1.0.0".to_string() }) ); - let error = - plan_refresh(&paths, &intent, &vault, "ghost", false).expect_err("not installed"); - assert!(error.to_string().contains("ghost is not installed"), "{error}"); + + assert_eq!( + plan("absent", Recover).expect("plan"), + stage("1.0.0", None), + "apply seeds absent modules" + ); + let error = plan("absent", UpdateNamed).expect_err("not installed"); + assert!(error.to_string().contains("absent is not installed"), "{error}"); std::fs::remove_dir_all(&root).expect("cleanup"); } #[test] - fn update_stages_what_it_plans_and_reports_every_failure() { - let (root, paths, intent, vault) = update_fixture("report"); - let ids: Vec = - ["dev", "ghost", "lyrics", "off"].iter().map(|id| (*id).to_string()).collect(); + fn a_bulk_update_skips_what_it_cannot_judge_and_reports_real_failures() { + let (root, paths, intent, vault) = update_fixture("bulk"); + let ids: Vec = ["dev", "lyrics", "orphan", "pinned", "url"] + .iter() + .map(|id| (*id).to_string()) + .collect(); let mut staged = Vec::new(); - let failures = update_modules(&paths, &intent, &vault, &ids, |id, target, superseded| { - staged.push(format!("{id}@{target} replacing {superseded:?}")); - anyhow::bail!("download failed") - }); + let report = update_modules( + &paths, + &intent, + &vault, + &ids, + RefreshPolicy::UpdateAll, + |id, target, superseded| { + staged.push(format!("{id}@{target} replacing {superseded:?}")); + anyhow::bail!("download failed") + }, + ); assert_eq!(staged, ["lyrics@1.1.0 replacing Some(\"1.0.0\")"]); - assert_eq!(failures, ["ghost: ghost is not installed", "lyrics@1.1.0: download failed"]); + assert_eq!( + report, + UpdateReport { + updated: 0, + failures: vec!["lyrics@1.1.0: download failed".to_string()] + } + ); + + let report = + update_modules(&paths, &intent, &vault, &ids, RefreshPolicy::UpdateAll, |_, _, _| { + Ok(()) + }); + assert_eq!(report, UpdateReport { updated: 1, failures: Vec::new() }); + std::fs::remove_dir_all(&root).expect("cleanup"); + } - let failures = update_modules(&paths, &intent, &vault, &ids[2..3], |_, _, _| Ok(())); - assert!(failures.is_empty(), "{failures:?}"); + #[test] + fn a_named_update_fails_when_the_user_has_to_act() { + let (root, paths, intent, vault) = update_fixture("named"); + let named = |id: &str| { + update_modules( + &paths, + &intent, + &vault, + &[id.to_string()], + RefreshPolicy::UpdateNamed, + |_, _, _| Ok(()), + ) + }; + assert!(named("off").failures[0].contains("run `spicetify pkg enable off@`")); + assert!(named("dev").failures[0].contains("never replaced from the registry")); + assert!(named("ghost").failures[0].contains("ghost is not installed")); + assert_eq!(named("pinned"), UpdateReport { updated: 1, failures: Vec::new() }); std::fs::remove_dir_all(&root).expect("cleanup"); } #[test] fn honours_disable_and_hand_pins_but_not_deletion() { assert!( - local_intent_blocks(&intent(None, &[("1.10.0", true)])), + local_intent(&intent(None, &[("1.10.0", true)])) == Some(Skip::Disabled), "disabled while installed stays off" ); assert!( - !local_intent_blocks(&intent(None, &[("1.10.0", false)])), + local_intent(&intent(None, &[("1.10.0", false)])).is_none(), "a deleted module is recovery, not intent" ); assert!( - local_intent_blocks(&intent(Some("1.7.0"), &[("1.7.0", true), ("1.10.0", true)])), + matches!( + local_intent(&intent(Some("1.7.0"), &[("1.7.0", true), ("1.10.0", true)])), + Some(Skip::HandPinned { .. }) + ), "an enabled version below an installed newer one is a hand pin" ); assert!( - !local_intent_blocks(&intent(Some("1.10.0"), &[("1.10.0", true)])), + local_intent(&intent(Some("1.10.0"), &[("1.10.0", true)])).is_none(), "enabled at the newest installed version is the normal state" ); + assert!( + local_intent(&intent(Some("1.0.0"), &[("1.0.0", true), ("1.1.0", false)])).is_none(), + "a version a registry rollback already collected is not a hand pin" + ); } #[test] From f989020e37e11e68dba4fbe04e26223eb4277e6e Mon Sep 17 00:00:00 2001 From: Afonso Jorge Ramos Date: Wed, 30 Sep 2026 17:33:51 +0200 Subject: [PATCH 17/19] fix(pkg): identify explicit installs by checksum --- rust/crates/spicetify/src/commands/pkg.rs | 56 +++++++++++++++++++++-- 1 file changed, 52 insertions(+), 4 deletions(-) diff --git a/rust/crates/spicetify/src/commands/pkg.rs b/rust/crates/spicetify/src/commands/pkg.rs index 983ec4926d..cb6b8e3528 100644 --- a/rust/crates/spicetify/src/commands/pkg.rs +++ b/rust/crates/spicetify/src/commands/pkg.rs @@ -309,15 +309,21 @@ fn plan_refresh( Ok(Refresh::Stage { superseded: installed.filter(|old| old != &target), target }) } -/// Whether the installed `version` was installed from artifacts other than the -/// registry's, which a bulk update must not overwrite. +/// Whether the installed `version` came from bytes other than the registry's, +/// which a bulk update must not overwrite. Registry and store installs record +/// the registry's checksum; an install from an explicit URL records none. A +/// version the registry no longer lists, or lists without a checksum, is not +/// evidence of either. fn from_explicit_artifact( local: Option<&crate::module::vault::Module>, module: &VaultModule, version: &str, ) -> bool { let Some(store) = local.and_then(|local| local.versions.get(version)) else { return false }; - module.v.get(version).is_none_or(|entry| entry.artifacts != store.artifacts) + module + .v + .get(version) + .is_some_and(|entry| !entry.checksum.is_empty() && entry.checksum != store.checksum) } /// Installs and enables `id@target`, then collects the superseded store copy, @@ -712,7 +718,7 @@ mod tests { std::env::temp_dir().join(format!("spicetify-update-{name}-{}", std::process::id())); let _ = std::fs::remove_dir_all(&root); let paths = crate::module::ModulePaths::from_config_root(&root); - for id in ["lyrics", "off", "orphan", "url", "pinned"] { + for id in ["lyrics", "off", "orphan", "url", "pinned", "yanked", "mirror"] { std::fs::create_dir_all(paths.store_root.join(id).join("1.0.0")).expect("store copy"); std::fs::create_dir_all(&paths.modules_root).expect("modules root"); crate::util::link::create_dir_link( @@ -727,9 +733,15 @@ mod tests { local.modules.insert("off".to_string(), intent(None, &[("1.0.0", true)])).is_none() ); let mut url = intent(Some("1.0.0"), &[("1.0.0", true)]); + url.versions.get_mut("1.0.0").expect("version").checksum = String::new(); url.versions.get_mut("1.0.0").expect("version").artifacts = vec!["https://example.invalid/fork.zip".to_string()]; assert!(local.modules.insert("url".to_string(), url).is_none()); + let mut mirror = intent(Some("1.0.0"), &[("1.0.0", true)]); + let store = mirror.versions.get_mut("1.0.0").expect("version"); + store.checksum = "sha256:mirror".to_string(); + store.artifacts = vec!["https://mirror.invalid/m.zip".to_string()]; + assert!(local.modules.insert("mirror".to_string(), mirror).is_none()); let pinned = intent(Some("1.0.0"), &[("1.0.0", true), ("1.1.0", true)]); assert!(local.modules.insert("pinned".to_string(), pinned).is_none()); let entry = || VaultVersion { @@ -750,6 +762,32 @@ mod tests { ("pinned".to_string(), module(&["1.0.0", "1.1.0", "1.2.0"])), ]), }; + let mut vault = vault; + let checksummed = |checksum: &str| VaultVersion { + artifacts: vec![ + "https://example.invalid/m.zip".to_string(), + "https://mirror.invalid/m.zip".to_string(), + ], + checksum: checksum.to_string(), + }; + assert!( + vault + .modules + .get_mut("url") + .expect("url") + .v + .insert("1.0.0".to_string(), checksummed("sha256:registry")) + .is_some() + ); + let mirror = VaultModule { + enabled: String::new(), + v: BTreeMap::from([ + ("1.0.0".to_string(), checksummed("sha256:mirror")), + ("1.1.0".to_string(), checksummed("sha256:next")), + ]), + }; + assert!(vault.modules.insert("mirror".to_string(), mirror).is_none()); + assert!(vault.modules.insert("yanked".to_string(), module(&["1.1.0"])).is_none()); (root, paths, local, vault) } @@ -774,6 +812,16 @@ mod tests { assert_eq!(plan("url", UpdateAll).expect("plan"), Refresh::Skip(Skip::ExplicitArtifact)); assert_eq!(plan("url", UpdateNamed).expect("plan"), stage("1.1.0", Some("1.0.0"))); + assert_eq!( + plan("mirror", UpdateAll).expect("plan"), + stage("1.1.0", Some("1.0.0")), + "a registry install whose recorded artifacts differ from the registry's list still updates" + ); + assert_eq!( + plan("yanked", UpdateAll).expect("plan"), + stage("1.1.0", Some("1.0.0")), + "a version the registry no longer lists is moved off, not mistaken for an explicit install" + ); assert_eq!( plan("pinned", UpdateAll).expect("plan"), From 5d6bc2d2f89e3dada804e8dd63a85b664503cf32 Mon Sep 17 00:00:00 2001 From: Afonso Jorge Ramos Date: Wed, 30 Sep 2026 17:38:00 +0200 Subject: [PATCH 18/19] refactor(module): extract checksum normalization --- rust/crates/spicetify/src/module/mod.rs | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/rust/crates/spicetify/src/module/mod.rs b/rust/crates/spicetify/src/module/mod.rs index e10f98c021..a22290f667 100644 --- a/rust/crates/spicetify/src/module/mod.rs +++ b/rust/crates/spicetify/src/module/mod.rs @@ -76,13 +76,18 @@ pub(crate) fn add_store(paths: &ModulePaths, id: &StoreIdentifier, store: Store) Ok(()) } +/// The bare lowercase hex of a `sha256:` prefixed or bare checksum, either case. +pub(crate) fn normalize_checksum(checksum: &str) -> String { + // Lowercase before stripping: an upper-case prefix is the same claim, + // and stripping first would leave it in the compared value. + let checksum = checksum.trim().to_ascii_lowercase(); + checksum.trim_start_matches("sha256:").to_string() +} + /// Compares a downloaded artifact against the checksum the vault recorded /// for it. `sha256:` prefixed or bare, either case. fn verify_checksum(expected: &str, bytes: &[u8]) -> Result<()> { - // Lowercase before stripping: an upper-case prefix is the same claim, - // and stripping first would leave it in the compared value. - let want = expected.trim().to_ascii_lowercase(); - let want = want.trim_start_matches("sha256:"); + let want = normalize_checksum(expected); let got = remote::digest(bytes); if want != got { anyhow::bail!( From 49928f0b5e82cb260198be310048ba0b3b67dce6 Mon Sep 17 00:00:00 2001 From: Afonso Jorge Ramos Date: Wed, 30 Sep 2026 17:38:00 +0200 Subject: [PATCH 19/19] fix(pkg): keep unlisted URL installs during a bulk update --- rust/crates/spicetify/src/commands/pkg.rs | 54 ++++++++++++++++++----- 1 file changed, 44 insertions(+), 10 deletions(-) diff --git a/rust/crates/spicetify/src/commands/pkg.rs b/rust/crates/spicetify/src/commands/pkg.rs index cb6b8e3528..da274907de 100644 --- a/rust/crates/spicetify/src/commands/pkg.rs +++ b/rust/crates/spicetify/src/commands/pkg.rs @@ -312,18 +312,22 @@ fn plan_refresh( /// Whether the installed `version` came from bytes other than the registry's, /// which a bulk update must not overwrite. Registry and store installs record /// the registry's checksum; an install from an explicit URL records none. A -/// version the registry no longer lists, or lists without a checksum, is not -/// evidence of either. +/// version the registry lists without a checksum is not evidence of either, +/// and one it doesn't list is judged by whether a checksum was recorded. fn from_explicit_artifact( local: Option<&crate::module::vault::Module>, module: &VaultModule, version: &str, ) -> bool { let Some(store) = local.and_then(|local| local.versions.get(version)) else { return false }; - module - .v - .get(version) - .is_some_and(|entry| !entry.checksum.is_empty() && entry.checksum != store.checksum) + match module.v.get(version) { + Some(entry) => { + !entry.checksum.is_empty() + && crate::module::normalize_checksum(&entry.checksum) + != crate::module::normalize_checksum(&store.checksum) + } + None => store.checksum.is_empty(), + } } /// Installs and enables `id@target`, then collects the superseded store copy, @@ -718,7 +722,17 @@ mod tests { std::env::temp_dir().join(format!("spicetify-update-{name}-{}", std::process::id())); let _ = std::fs::remove_dir_all(&root); let paths = crate::module::ModulePaths::from_config_root(&root); - for id in ["lyrics", "off", "orphan", "url", "pinned", "yanked", "mirror"] { + for id in [ + "lyrics", + "off", + "orphan", + "url", + "pinned", + "yanked", + "mirror", + "fork", + "unchecksummed", + ] { std::fs::create_dir_all(paths.store_root.join(id).join("1.0.0")).expect("store copy"); std::fs::create_dir_all(&paths.modules_root).expect("modules root"); crate::util::link::create_dir_link( @@ -739,9 +753,16 @@ mod tests { assert!(local.modules.insert("url".to_string(), url).is_none()); let mut mirror = intent(Some("1.0.0"), &[("1.0.0", true)]); let store = mirror.versions.get_mut("1.0.0").expect("version"); - store.checksum = "sha256:mirror".to_string(); + store.checksum = "MIRROR".to_string(); store.artifacts = vec!["https://mirror.invalid/m.zip".to_string()]; assert!(local.modules.insert("mirror".to_string(), mirror).is_none()); + for (id, checksum) in + [("yanked", "sha256:gone"), ("fork", ""), ("unchecksummed", "sha256:old")] + { + let mut module = intent(Some("1.0.0"), &[("1.0.0", true)]); + module.versions.get_mut("1.0.0").expect("version").checksum = checksum.to_string(); + assert!(local.modules.insert(id.to_string(), module).is_none()); + } let pinned = intent(Some("1.0.0"), &[("1.0.0", true), ("1.1.0", true)]); assert!(local.modules.insert("pinned".to_string(), pinned).is_none()); let entry = || VaultVersion { @@ -788,6 +809,9 @@ mod tests { }; assert!(vault.modules.insert("mirror".to_string(), mirror).is_none()); assert!(vault.modules.insert("yanked".to_string(), module(&["1.1.0"])).is_none()); + assert!(vault.modules.insert("fork".to_string(), module(&["1.1.0"])).is_none()); + let unchecksummed = module(&["1.0.0", "1.1.0"]); + assert!(vault.modules.insert("unchecksummed".to_string(), unchecksummed).is_none()); (root, paths, local, vault) } @@ -815,12 +839,22 @@ mod tests { assert_eq!( plan("mirror", UpdateAll).expect("plan"), stage("1.1.0", Some("1.0.0")), - "a registry install whose recorded artifacts differ from the registry's list still updates" + "a registry install with other artifacts and checksum spelling updates" ); assert_eq!( plan("yanked", UpdateAll).expect("plan"), stage("1.1.0", Some("1.0.0")), - "a version the registry no longer lists is moved off, not mistaken for an explicit install" + "a registry install the registry no longer lists updates" + ); + assert_eq!( + plan("fork", UpdateAll).expect("plan"), + Refresh::Skip(Skip::ExplicitArtifact), + "a URL install of a version the registry doesn't list stays" + ); + assert_eq!( + plan("unchecksummed", UpdateAll).expect("plan"), + stage("1.1.0", Some("1.0.0")), + "a registry entry without a checksum is no evidence of an explicit install" ); assert_eq!(