From 529e7c38ad018345eddd5a73074a284fd0b354ef Mon Sep 17 00:00:00 2001 From: Afonso Jorge Ramos Date: Wed, 16 Sep 2026 14:58:27 +0200 Subject: [PATCH 1/6] fix(daemon): keep service alive during in-client apply --- docs/daemon-apply-verification.md | 70 +++++++++++++++++++ docs/windows-update-verification.md | 5 ++ rust/crates/daemon/src/routes.rs | 58 ++++++++++++++- rust/crates/daemon/src/server.rs | 5 +- rust/crates/daemon/src/update_job.rs | 6 +- rust/crates/daemon/src/watcher.rs | 2 +- rust/crates/spicetify/src/commands/apply.rs | 23 ++++-- rust/crates/spicetify/src/commands/mod.rs | 10 ++- .../crates/spicetify/src/commands/protocol.rs | 25 ++++--- 9 files changed, 181 insertions(+), 23 deletions(-) create mode 100644 docs/daemon-apply-verification.md diff --git a/docs/daemon-apply-verification.md b/docs/daemon-apply-verification.md new file mode 100644 index 0000000000..77c950b385 --- /dev/null +++ b/docs/daemon-apply-verification.md @@ -0,0 +1,70 @@ +# Daemon-owned Apply verification, 2026-09-16 + +Applying from the client could stop the daemon permanently and disable its +autostart entry. Hide Window Controls then reported that the service was +unavailable. Restarting the daemon recovered that module without reopening +Spotify. + +The RPC handler ran synchronous commands on the daemon's single async thread. +Apply's request to its own health endpoint timed out, so Apply treated the +daemon's version as unknown, unregistered it, and killed its own process. +Daemon-owned Apply could also register `spicetify-daemon` as the URL handler, +although that executable does not implement CLI protocol commands. + +RPC commands now execute on blocking workers. Explicit Apply modes keep daemon +maintenance and URL registration in the foreground CLI. RPC Apply, watcher +repairs, and the update transaction preserve their owning daemon. Blocking +workers are no longer limited to one, so a watcher waiting for Spotify to exit +does not prevent RPC commands from reaching the operation guard. + +## Automated checks + +- A regression holds the Apply file lock while dispatching a real Apply RPC. + The old handler stalled the async runtime for five seconds and failed. The + fixed handler kept it responsive and passed in 0.06 seconds. The fixture + refuses a foreign apply before any real Spotify operation. +- `cargo +1.95.0 test --workspace --locked --features daemon/native-window-controls-tests`: + 142 passed, three existing real-bundle/platform tests ignored. +- `cargo +1.95.0 clippy --workspace --locked -- -D warnings`: passed. + A pre-existing TUI Backspace match required a behavior-preserving lint fix. +- An additional `--all-targets` Clippy scan found existing test-only warnings + outside this regression. That broader scan is not the repository CI command + and is not reported as passing. +- Matching release CLI/daemon binaries were built with the current payload. + +## Windows live run + +The patched pair was installed in the normal local installation directory, +with backups retained. Both still identify as 3.0.0-beta.17; this is a local +build, not a published release. The existing daemon was explicitly restarted +before testing because its version alone cannot distinguish local builds. + +An authenticated `spicetify:0:apply` request completed against Spotify desktop +1.3.0.277. This used the same RPC as the client but was sent by a diagnostic +script, **not clicked in the UI**. + +- All 109 concurrent health requests succeeded; maximum latency was 17 ms. +- Daemon PID 28012 survived the operation, with monotonically increasing uptime. +- Apply finished and automatically launched Spotify, which exposed a window + titled `Spotify Free`. +- Autostart stayed enabled and the URL handler still targeted `spicetify.exe`. +- No daemon restart or registration mutation appeared in the Apply log. +- Spotify updates remained blocked. + +Evidence is retained locally under the workspace's +`scratchpad/daemon-owned-apply/`. It is not a release fixture. + +## End-user coverage and remaining limits + +Before installing this fix, native Computer Use verified the profile-menu +settings route, Manager's six loaded modules, playback, elapsed-time rendering, +and recovery of hidden window controls after restarting the stopped daemon. + +The native bridge subsequently became unavailable in the current host session. +Both a fresh connection and a session reset returned `native pipe unavailable`. +Consequently the fixed build's automatic launch was verified as a process and +window, not visually inspected. The normal UI Apply action and the first +patched renderer after an actual Spotify version update still need a native +end-user pass. The earlier [Windows update report](windows-update-verification.md) +remains accurate; this run does not clear its first-boot limitation or enable +Windows Update & Apply in release builds. diff --git a/docs/windows-update-verification.md b/docs/windows-update-verification.md index 6e8e5b4387..4e317e060d 100644 --- a/docs/windows-update-verification.md +++ b/docs/windows-update-verification.md @@ -76,6 +76,11 @@ These files are local diagnostic artifacts, not release fixtures. ## Remaining verification +The later [daemon Apply verification](daemon-apply-verification.md) diagnoses +and fixes a separate self-shutdown path seen during an ordinary in-client +Apply. Its authenticated RPC run passed, but it does not replace the missing +first-boot visual check below. + Repeat an actual version update with reliable first-boot observation and without a diagnostic restart. Check loaded modules through the normal UI before calling the job's user outcome complete. Microsoft Store installations, Linux, diff --git a/rust/crates/daemon/src/routes.rs b/rust/crates/daemon/src/routes.rs index 0797a20d1a..7b92615fbd 100644 --- a/rust/crates/daemon/src/routes.rs +++ b/rust/crates/daemon/src/routes.rs @@ -203,8 +203,7 @@ async fn handle_ws(mut socket: WebSocket, state: Arc) { while let Some(Ok(msg)) = socket.next().await { if let Message::Text(text) = msg { tracing::info!("{}", spicetify::fl!("rpc-received", msg = text.as_str())); - let ctx = state.ctx.load(); - match protocol::handle(&ctx, &text) { + match dispatch_rpc(state.ctx.load_full(), text.to_string()).await { Ok(res) if !res.is_empty() => { if let Err(e) = socket.send(Message::Text(res.into())).await { tracing::warn!(error = %e, "failed to send ws message"); @@ -226,6 +225,16 @@ async fn handle_ws(mut socket: WebSocket, state: Arc) { } } +async fn dispatch_rpc( + ctx: Arc, + text: String, +) -> anyhow::Result { + tokio::task::spawn_blocking(move || { + protocol::handle(&ctx, &text, spicetify::commands::apply::ApplyMode::Daemon) + }) + .await? +} + // A cross-origin POST is sent even when the browser refuses to let the page // read the reply, so without a token any page the user visits could stop the // daemon and silently disable auto re-apply. @@ -246,6 +255,51 @@ async fn shutdown_handler( mod tests { use super::*; + #[tokio::test] + async fn apply_rpc_does_not_block_the_server_while_waiting_for_the_apply_lock() + -> anyhow::Result<()> { + use spicetify::context::{AppContext, Config}; + use std::fs::OpenOptions; + + let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH)?.as_nanos(); + let root = + std::env::temp_dir().join(format!("spicetify-rpc-{}-{nonce}", std::process::id())); + // Foreign apply artifacts make the command fail before it can touch Spotify. + std::fs::create_dir_all(root.join("Apps/xpui"))?; + let ctx = Arc::new(AppContext::from_config( + root.clone(), + &Config { + spotify_exec: Some(root.join("Spotify")), + spotify_data_dir: Some(root.clone()), + offline_bnk_dir: Some(root.clone()), + ..Config::default() + }, + )?); + let lock = OpenOptions::new() + .create(true) + .truncate(false) + .write(true) + .open(root.join("spicetify-apply.lock"))?; + lock.lock()?; + let (release, held) = std::sync::mpsc::channel(); + // A bounded external release keeps a regression from hanging the test runtime. + let holder = std::thread::spawn(move || { + let _ = held.recv_timeout(Duration::from_secs(5)); + drop(lock); + }); + let rpc = tokio::spawn(dispatch_rpc(ctx, "spicetify:0:apply".to_string())); + let started = std::time::Instant::now(); + tokio::time::sleep(Duration::from_millis(50)).await; + let responsive = started.elapsed() < Duration::from_secs(2) && !rpc.is_finished(); + let _ = release.send(()); + let result = rpc.await?; + holder.join().expect("lock holder exits"); + std::fs::remove_dir_all(root)?; + assert!(result.is_err(), "fixture must refuse a foreign apply"); + assert!(responsive, "the server must keep polling while Apply waits on disk"); + Ok(()) + } + fn headers(protocols: &str) -> HeaderMap { let mut h = HeaderMap::new(); let _ = diff --git a/rust/crates/daemon/src/server.rs b/rust/crates/daemon/src/server.rs index 8d008c76a2..e6bcfc3394 100644 --- a/rust/crates/daemon/src/server.rs +++ b/rust/crates/daemon/src/server.rs @@ -40,10 +40,7 @@ pub fn run() -> anyhow::Result<()> { fn start(ctx: AppContext) -> anyhow::Result<()> { let _lock = acquire_instance_lock(&ctx.config_root)?; - let runtime = tokio::runtime::Builder::new_current_thread() - .enable_all() - .max_blocking_threads(1) - .build()?; + let runtime = tokio::runtime::Builder::new_current_thread().enable_all().build()?; runtime.block_on(async move { let shared = Arc::new(SharedContext::new(ctx)); let shutdown = Arc::new(tokio::sync::Notify::new()); diff --git a/rust/crates/daemon/src/update_job.rs b/rust/crates/daemon/src/update_job.rs index 961cc81123..6778b1e1d0 100644 --- a/rust/crates/daemon/src/update_job.rs +++ b/rust/crates/daemon/src/update_job.rs @@ -637,7 +637,11 @@ impl Supervisor { ); return; }; - if let Err(e) = spicetify::commands::apply::run(ctx, guard, false) { + if let Err(e) = spicetify::commands::apply::run( + ctx, + guard, + spicetify::commands::apply::ApplyMode::Daemon, + ) { self.secure_failure( FailureCode::ApplyFailed, &format!("Spicetify apply failed after Spotify updated: {e}"), diff --git a/rust/crates/daemon/src/watcher.rs b/rust/crates/daemon/src/watcher.rs index 9bd953113d..850d8bdaf1 100644 --- a/rust/crates/daemon/src/watcher.rs +++ b/rust/crates/daemon/src/watcher.rs @@ -130,7 +130,7 @@ fn auto_apply(ctx: &AppContext, nth: u32) { tracing::info!("stock xpui.spa is no longer present; skipping auto-apply"); return; } - if let Err(e) = commands::apply::run(ctx, &guard, false) { + if let Err(e) = commands::apply::run(ctx, &guard, commands::apply::ApplyMode::Daemon) { tracing::warn!(error = %e, "auto-apply failed"); } } diff --git a/rust/crates/spicetify/src/commands/apply.rs b/rust/crates/spicetify/src/commands/apply.rs index e134d9d82d..e98ac490d5 100644 --- a/rust/crates/spicetify/src/commands/apply.rs +++ b/rust/crates/spicetify/src/commands/apply.rs @@ -9,6 +9,15 @@ use crate::{fl, util}; const APPLY_LOCK_FILE: &str = "spicetify-apply.lock"; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ApplyMode { + Cli { + no_cache: bool, + }, + /// Preserve the daemon executing this apply and the CLI's URL registration. + Daemon, +} + // The foreground CLI and daemon both enter this command and mutate the same // xpui.spa, backup and xpui.tmp paths. Keep one persistent lock file: deleting // it on drop could let a third process lock a new inode while a waiter still @@ -44,8 +53,9 @@ fn fs_err<'a>(doing: &'a str, path: &'a Path) -> impl FnOnce(std::io::Error) -> pub fn run( ctx: &AppContext, _operation_guard: &super::guard::DisruptiveOperationGuard, - no_cache: bool, + mode: ApplyMode, ) -> Result<()> { + let no_cache = matches!(mode, ApplyMode::Cli { no_cache: true }); let _apply_lock = acquire_apply_lock(&ctx.config_root)?; let dest_apps = ctx.dest_apps_path(); let spa = ctx.spotify_apps_path().join("xpui.spa"); @@ -177,11 +187,15 @@ pub fn run( // bundle, not that intermediate resource set. super::updates::finalize_app_signature(ctx)?; - ensure_daemon(ctx); + if matches!(mode, ApplyMode::Cli { .. }) { + ensure_daemon(ctx); + } crate::lifecycle::start(ctx)?; - crate::platform::register_url_scheme(); + if matches!(mode, ApplyMode::Cli { .. }) { + crate::platform::register_url_scheme(); + } tracing::info!("{}", fl!("applied-patches")); Ok(()) @@ -854,7 +868,8 @@ mod tests { let apply = std::thread::spawn(move || { let guard = super::super::guard::try_acquire(&ctx.config_root) .expect("synthetic apply owns the disruptive-operation guard"); - tx.send(run(&ctx, &guard, false)).expect("test receiver remains available"); + tx.send(run(&ctx, &guard, ApplyMode::Cli { no_cache: false })) + .expect("test receiver remains available"); }); assert!( rx.recv_timeout(std::time::Duration::from_millis(100)).is_err(), diff --git a/rust/crates/spicetify/src/commands/mod.rs b/rust/crates/spicetify/src/commands/mod.rs index 1a699e2c65..2ea11d93cf 100644 --- a/rust/crates/spicetify/src/commands/mod.rs +++ b/rust/crates/spicetify/src/commands/mod.rs @@ -65,7 +65,7 @@ pub fn dispatch(cmd: &Command, ctx: &AppContext) -> Result<()> { match cmd { Command::Apply { no_cache } => { let guard = guard::try_acquire(&ctx.config_root)?; - apply::run(ctx, &guard, *no_cache) + apply::run(ctx, &guard, apply::ApplyMode::Cli { no_cache: *no_cache }) } Command::Config(action) => match action { ConfigAction::Show => config::run(ctx), @@ -162,8 +162,12 @@ mod tests { "fast-delete", "fast-remove", ] { - let error = protocol::handle(&ctx, &format!("spicetify:0:{action}?id=module%401")) - .expect_err("competing protocol mutation"); + let error = protocol::handle( + &ctx, + &format!("spicetify:0:{action}?id=module%401"), + apply::ApplyMode::Daemon, + ) + .expect_err("competing protocol mutation"); assert!(error.to_string().contains("already in progress"), "{error}"); } let error = diff --git a/rust/crates/spicetify/src/commands/protocol.rs b/rust/crates/spicetify/src/commands/protocol.rs index 78f4e8299f..24cbfd6af2 100644 --- a/rust/crates/spicetify/src/commands/protocol.rs +++ b/rust/crates/spicetify/src/commands/protocol.rs @@ -2,13 +2,14 @@ use std::borrow::Cow; use url::Url; +use super::apply::ApplyMode; use crate::context::AppContext; use crate::error::Result; use crate::fl; use crate::module::{self, ModulePaths, Store}; pub(crate) fn run(ctx: &AppContext, uri: &str) -> Result<()> { - let response = handle(ctx, uri)?; + let response = handle(ctx, uri, ApplyMode::Cli { no_cache: false })?; if !response.is_empty() { let outbound = format!("spotify:app:rpc:{response}"); launch_uri(&outbound)?; @@ -16,7 +17,7 @@ pub(crate) fn run(ctx: &AppContext, uri: &str) -> Result<()> { Ok(()) } -pub fn handle(ctx: &AppContext, uri: &str) -> Result { +pub fn handle(ctx: &AppContext, uri: &str, apply_mode: ApplyMode) -> Result { let u = Url::parse(uri).map_err(|_| anyhow::anyhow!(fl!("proxy-invalid-url")))?; if u.scheme() != "spicetify" { return Err(anyhow::anyhow!(fl!("unsupported-scheme"))); @@ -30,7 +31,7 @@ pub fn handle(ctx: &AppContext, uri: &str) -> Result { let prefix = format!("spicetify:{module_id}:"); let action = ProtocolAction::parse(action) .ok_or_else(|| anyhow::anyhow!(fl!("protocol-error", err = "unknown action")))?; - perform(ctx, action, &u)?; + perform(ctx, action, &u, apply_mode)?; if module_id == "0" { return Ok(String::new()); @@ -76,7 +77,12 @@ impl ProtocolAction { } } -fn perform(ctx: &AppContext, action: ProtocolAction, uri: &Url) -> Result<()> { +fn perform( + ctx: &AppContext, + action: ProtocolAction, + uri: &Url, + apply_mode: ApplyMode, +) -> Result<()> { let _guard = match action { ProtocolAction::Apply | ProtocolAction::BlockUpdates | ProtocolAction::UnblockUpdates => { None @@ -176,7 +182,7 @@ fn perform(ctx: &AppContext, action: ProtocolAction, uri: &Url) -> Result<()> { // fire-and-forget rather than waiting on a response. ProtocolAction::Apply => { let guard = super::guard::try_acquire(&ctx.config_root)?; - super::apply::run(ctx, &guard, false) + super::apply::run(ctx, &guard, apply_mode) } ProtocolAction::BlockUpdates => { let _guard = super::guard::try_acquire(&ctx.config_root)?; @@ -284,7 +290,8 @@ mod tests { for raw in ["../victim@1", "/victim@1", "module@..", "../victim@"] { let mut uri = Url::parse(&format!("spicetify:0:{action}"))?; let _ = uri.query_pairs_mut().append_pair("id", raw); - let error = handle(&ctx, uri.as_str()).expect_err("unsafe IDs must be refused"); + let error = handle(&ctx, uri.as_str(), ApplyMode::Daemon) + .expect_err("unsafe IDs must be refused"); assert!(error.to_string().contains("invalid store id"), "{action} {raw}: {error}"); assert!(!root.join("modules").exists(), "validation must precede vault mutation"); } @@ -311,7 +318,8 @@ mod tests { let version = if action == "enable" { "" } else { "1" }; let mut uri = Url::parse(&format!("spicetify:0:{action}"))?; let _ = uri.query_pairs_mut().append_pair("id", &format!("{module}@{version}")); - let error = handle(&ctx, uri.as_str()).expect_err("the Store must be protected"); + let error = handle(&ctx, uri.as_str(), ApplyMode::Daemon) + .expect_err("the Store must be protected"); assert!(error.to_string().contains("cannot be uninstalled"), "{error}"); } } @@ -319,7 +327,8 @@ mod tests { for action in ["add", "fast-install", "fast-enable"] { let mut uri = Url::parse(&format!("spicetify:0:{action}"))?; let _ = uri.query_pairs_mut().append_pair("id", &format!("{module}@1")); - let error = handle(&ctx, uri.as_str()).expect_err("unverified system install"); + let error = handle(&ctx, uri.as_str(), ApplyMode::Daemon) + .expect_err("unverified system install"); assert!(error.to_string().contains("registry-verified"), "{error}"); } } From 5545b06c9c2b390650b0612a4d8c2af7e3bcdac7 Mon Sep 17 00:00:00 2001 From: Afonso Jorge Ramos Date: Wed, 16 Sep 2026 14:58:39 +0200 Subject: [PATCH 2/6] fix(tui): satisfy backspace match lint --- rust/crates/tui/src/app/input.rs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/rust/crates/tui/src/app/input.rs b/rust/crates/tui/src/app/input.rs index eda760db30..4d0b65a1ff 100644 --- a/rust/crates/tui/src/app/input.rs +++ b/rust/crates/tui/src/app/input.rs @@ -167,10 +167,8 @@ impl TuiApp { self.input = None; self.run_command(cmd, &label); } - KeyCode::Backspace => { - if input.buffer.pop().is_none() { - tracing::debug!("backspace pressed with empty input buffer"); - } + KeyCode::Backspace if input.buffer.pop().is_none() => { + tracing::debug!("backspace pressed with empty input buffer"); } KeyCode::Char(c) if key.modifiers.is_empty() || key.modifiers == KeyModifiers::SHIFT => From 2840e53c8c654af13439279dc3c7ad03c7af186c Mon Sep 17 00:00:00 2001 From: Afonso Jorge Ramos Date: Wed, 16 Sep 2026 15:01:17 +0200 Subject: [PATCH 3/6] docs: clarify ignored integration test requirements --- docs/daemon-apply-verification.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/daemon-apply-verification.md b/docs/daemon-apply-verification.md index 77c950b385..0f297a6fd9 100644 --- a/docs/daemon-apply-verification.md +++ b/docs/daemon-apply-verification.md @@ -24,7 +24,7 @@ does not prevent RPC commands from reaching the operation guard. fixed handler kept it responsive and passed in 0.06 seconds. The fixture refuses a foreign apply before any real Spotify operation. - `cargo +1.95.0 test --workspace --locked --features daemon/native-window-controls-tests`: - 142 passed, three existing real-bundle/platform tests ignored. + 142 passed, three existing tests requiring real bundles or registry downloads ignored. - `cargo +1.95.0 clippy --workspace --locked -- -D warnings`: passed. A pre-existing TUI Backspace match required a behavior-preserving lint fix. - An additional `--all-targets` Clippy scan found existing test-only warnings From 060776c133a51845f34fe08e878fc84499897034 Mon Sep 17 00:00:00 2001 From: Afonso Jorge Ramos Date: Wed, 16 Sep 2026 15:25:51 +0200 Subject: [PATCH 4/6] fix(windows): stop recurring console flashes after apply --- docs/daemon-apply-verification.md | 29 ++++- rust/crates/daemon/src/watcher.rs | 104 +++++++++++++----- .../spicetify/src/hooks/version_detect.rs | 3 +- rust/crates/spicetify/src/platform/windows.rs | 4 +- rust/crates/spicetify/src/process.rs | 32 +++++- 5 files changed, 136 insertions(+), 36 deletions(-) diff --git a/docs/daemon-apply-verification.md b/docs/daemon-apply-verification.md index 0f297a6fd9..8258f0788e 100644 --- a/docs/daemon-apply-verification.md +++ b/docs/daemon-apply-verification.md @@ -17,6 +17,13 @@ repairs, and the update transaction preserve their owning daemon. Blocking workers are no longer limited to one, so a watcher waiting for Spotify to exit does not prevent RPC commands from reaching the operation guard. +The watcher could also keep waiting after another Apply had consumed the stock +archive. On Windows, each process check launched `tasklist.exe` with a visible +console. Process sampling confirmed daemon-owned `tasklist.exe` starts about +2.2 seconds apart, each followed by an `OpenConsole.exe` start. The watcher now +stops waiting when repair is no longer pending. Windows process and PowerShell +helpers use `CREATE_NO_WINDOW` so legitimate background checks stay hidden too. + ## Automated checks - A regression holds the Apply file lock while dispatching a real Apply RPC. @@ -24,7 +31,11 @@ does not prevent RPC commands from reaching the operation guard. fixed handler kept it responsive and passed in 0.06 seconds. The fixture refuses a foreign apply before any real Spotify operation. - `cargo +1.95.0 test --workspace --locked --features daemon/native-window-controls-tests`: - 142 passed, three existing tests requiring real bundles or registry downloads ignored. + 144 passed, three existing tests requiring real bundles or registry downloads ignored. +- A watcher regression consumes the archive while Spotify remains running and + verifies that polling stops and the operation lock remains available. +- A Windows child-process test verifies that a background PowerShell helper has + no console while preserving its output and nonzero exit status. - `cargo +1.95.0 clippy --workspace --locked -- -D warnings`: passed. A pre-existing TUI Backspace match required a behavior-preserving lint fix. - An additional `--all-targets` Clippy scan found existing test-only warnings @@ -54,6 +65,22 @@ script, **not clicked in the UI**. Evidence is retained locally under the workspace's `scratchpad/daemon-owned-apply/`. It is not a release fixture. +### Follow-up after the console fix + +The matching CLI and daemon were rebuilt and installed, then the same diagnostic +RPC Apply was repeated. All 84 health requests succeeded with a maximum latency +of 28 ms. Daemon PID 28800 survived; autostart, CLI URL registration, and update +protection remained intact. + +A 45-second process sample spanning Apply and the period after it recorded the +expected daemon-owned helpers during Apply and no new `OpenConsole.exe` process. +There were no recurring `tasklist.exe` starts after completion. This run's +watcher saw the already-applied client and skipped repair; the cancellation of +an existing wait is covered by the regression test, not this live timing. +The latest RPC result, Apply log, and helper-process sample are retained in the +same local evidence directory. This is process-level verification, not a native +visual pass. + ## End-user coverage and remaining limits Before installing this fix, native Computer Use verified the profile-menu diff --git a/rust/crates/daemon/src/watcher.rs b/rust/crates/daemon/src/watcher.rs index 850d8bdaf1..f0bde81378 100644 --- a/rust/crates/daemon/src/watcher.rs +++ b/rust/crates/daemon/src/watcher.rs @@ -117,10 +117,17 @@ fn auto_apply(ctx: &AppContext, nth: u32) { nth, "auto-apply triggered by a Spotify update; waiting for pending package operations" ); - let guard = match wait_for_idle_guard(&ctx.config_root, CLIENT_EXIT_CEILING, || { - spicetify::lifecycle::is_running(ctx) - }) { - Ok(guard) => guard, + let guard = match wait_for_idle_guard( + &ctx.config_root, + CLIENT_EXIT_CEILING, + || ctx.spotify_apps_path().join("xpui.spa").is_file(), + || spicetify::lifecycle::is_running(ctx), + ) { + Ok(Some(guard)) => guard, + Ok(None) => { + tracing::info!("stock xpui.spa is no longer present; cancelling pending auto-apply"); + return; + } Err(e) => { tracing::warn!(error = %e, "auto-apply could not acquire an idle client; run `spicetify apply` when convenient"); return; @@ -138,11 +145,15 @@ fn auto_apply(ctx: &AppContext, nth: u32) { fn wait_for_idle_guard( config_root: &std::path::Path, timeout: Duration, + mut repair_pending: impl FnMut() -> bool, mut is_running: impl FnMut() -> bool, -) -> anyhow::Result { +) -> anyhow::Result> { let deadline = std::time::Instant::now() + timeout; let mut waited = false; loop { + if !repair_pending() { + return Ok(None); + } let remaining = deadline.saturating_duration_since(std::time::Instant::now()); if remaining.is_zero() { anyhow::bail!( @@ -164,7 +175,7 @@ fn wait_for_idle_guard( config_root, remaining.min(Duration::from_secs(2)), ) { - Ok(guard) if !is_running() => return Ok(guard), + Ok(guard) if !is_running() => return Ok(Some(guard)), // Release the guard and resume the polite wait if Spotify relaunched. Ok(_) => waited = true, Err(error) if commands::guard::is_contention(&error) => {} @@ -312,15 +323,20 @@ mod tests { let root = scratch("contention")?; let mut competing = Some(commands::guard::try_acquire(&root)?); let mut checks = 0; - let result = wait_for_idle_guard(&root, Duration::from_secs(10), || { - checks += 1; - if checks == 2 { - drop(competing.take()); - } - false - }); + let result = wait_for_idle_guard( + &root, + Duration::from_secs(10), + || true, + || { + checks += 1; + if checks == 2 { + drop(competing.take()); + } + false + }, + ); drop(competing); - drop(result?); + drop(result?.expect("repair remains pending")); assert!(checks >= 3, "retry must recheck the client before and after acquiring"); std::fs::remove_dir_all(root)?; Ok(()) @@ -330,20 +346,25 @@ mod tests { fn auto_apply_resumes_waiting_when_spotify_relaunches() -> anyhow::Result<()> { let root = scratch("relaunch")?; let mut checks = 0; - let result = wait_for_idle_guard(&root, Duration::from_secs(10), || { - checks += 1; - match checks { - // Idle before locking, but restarted by the post-lock check. - 2 => true, - 3 => { - // The polite wait must not keep package operations locked. - drop(commands::guard::try_acquire(&root).expect("guard released")); - false + let result = wait_for_idle_guard( + &root, + Duration::from_secs(10), + || true, + || { + checks += 1; + match checks { + // Idle before locking, but restarted by the post-lock check. + 2 => true, + 3 => { + // The polite wait must not keep package operations locked. + drop(commands::guard::try_acquire(&root).expect("guard released")); + false + } + _ => false, } - _ => false, - } - }); - drop(result?); + }, + ); + drop(result?.expect("repair remains pending")); assert!(checks >= 5, "a restart must resume waiting, not abandon the repair"); std::fs::remove_dir_all(root)?; Ok(()) @@ -353,17 +374,42 @@ 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), || false).unwrap_err(); + let error = wait_for_idle_guard(&root, Duration::from_millis(20), || true, || false) + .expect_err("operation remains locked"); 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_secs(60), || false).unwrap_err(); + let error = wait_for_idle_guard(&file, Duration::from_mins(1), || true, || false) + .expect_err("lock path is not a directory"); assert!(error.downcast_ref::().is_some()); std::fs::remove_dir_all(root)?; Ok(()) } + #[test] + fn auto_apply_stops_polling_when_another_apply_consumes_the_archive() -> anyhow::Result<()> { + let root = scratch("completed-elsewhere")?; + let archive = root.join("xpui.spa"); + std::fs::write(&archive, "stock")?; + let mut process_checks = 0; + let result = wait_for_idle_guard( + &root, + Duration::from_secs(10), + || archive.is_file(), + || { + process_checks += 1; + std::fs::remove_file(&archive).expect("another apply consumes the archive"); + true + }, + )?; + 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"); + drop(commands::guard::try_acquire(&root)?); + std::fs::remove_dir_all(root)?; + Ok(()) + } + #[expect(clippy::unnecessary_wraps, reason = "matches the channel's item type")] fn event() -> notify::Result { Ok(Event { kind: EventKind::Modify(notify::event::ModifyKind::Any), ..Event::default() }) diff --git a/rust/crates/spicetify/src/hooks/version_detect.rs b/rust/crates/spicetify/src/hooks/version_detect.rs index 5b01fd8e36..403d1bfbe1 100644 --- a/rust/crates/spicetify/src/hooks/version_detect.rs +++ b/rust/crates/spicetify/src/hooks/version_detect.rs @@ -1,4 +1,5 @@ use std::path::Path; +#[cfg(not(windows))] use std::process::Command; use std::sync::LazyLock; @@ -122,7 +123,7 @@ fn detect_version(exec_path: &Path) -> Result { let ps_script = format!("(Get-Item -LiteralPath '{}').VersionInfo.ProductVersion", exec_path.display()); - let output = Command::new("powershell.exe") + let output = crate::process::background_command("powershell.exe") .args(["-NoProfile", "-NonInteractive", "-Command", &ps_script]) .output() .map_err(|e| anyhow::anyhow!("failed to run powershell: {e}"))?; diff --git a/rust/crates/spicetify/src/platform/windows.rs b/rust/crates/spicetify/src/platform/windows.rs index 295a6100c8..255bae1b49 100644 --- a/rust/crates/spicetify/src/platform/windows.rs +++ b/rust/crates/spicetify/src/platform/windows.rs @@ -1,5 +1,4 @@ use std::path::{Path, PathBuf}; -use std::process::Command; use std::sync::LazyLock; use tracing; @@ -14,9 +13,10 @@ struct SpotifyPackage { } static SPOTIFY_PACKAGE: LazyLock> = LazyLock::new(|| { - let output = match Command::new("powershell") + let output = match crate::process::background_command("powershell") .args([ "-NoProfile", + "-NonInteractive", "-Command", "$p=Get-AppxPackage -Name 'SpotifyAB.SpotifyMusic'; if($p){$p.InstallLocation; \ $p.PackageFamilyName}", diff --git a/rust/crates/spicetify/src/process.rs b/rust/crates/spicetify/src/process.rs index 5e0fe1c1d6..990d7f447c 100644 --- a/rust/crates/spicetify/src/process.rs +++ b/rust/crates/spicetify/src/process.rs @@ -3,6 +3,16 @@ use std::process::{Command, Stdio}; use crate::context::AppContext; use crate::error::Result; +#[cfg(windows)] +pub(crate) fn background_command(program: impl AsRef) -> Command { + use std::os::windows::process::CommandExt; + use windows::Win32::System::Threading::CREATE_NO_WINDOW; + + let mut command = Command::new(program); + let _ = command.creation_flags(CREATE_NO_WINDOW.0); + command +} + pub(crate) fn process_running(name: &str) -> bool { #[cfg(any(target_os = "linux", target_os = "macos"))] { @@ -15,7 +25,7 @@ pub(crate) fn process_running(name: &str) -> bool { } #[cfg(windows)] { - Command::new("tasklist") + background_command("tasklist") .args(["/FI", &format!("ImageName eq {name}"), "/NH"]) .stdout(Stdio::piped()) .stderr(Stdio::null()) @@ -49,7 +59,7 @@ pub(crate) fn kill_image(name: &str) { } #[cfg(windows)] { - match Command::new("taskkill") + match background_command("taskkill") .args(["/F", "/IM", name]) .stdout(Stdio::null()) .stderr(Stdio::null()) @@ -125,7 +135,7 @@ fn spawn_windows(ctx: &AppContext) -> Result<()> { let ps_cmd = format!("& \"{}\" --app-directory=\"{}\"", appx_exe.display(), dest_apps.display()); - let child = Command::new("powershell.exe") + let child = background_command("powershell.exe") .args(["-NoProfile", "-NonInteractive", "-Command", &ps_cmd]) .stdin(Stdio::null()) .stdout(Stdio::null()) @@ -207,6 +217,22 @@ pub fn force_kill_spotify(ctx: &AppContext) { kill_image(image); } +#[cfg(all(test, windows))] +mod windows_tests { + #[test] + fn background_helpers_have_no_console_and_preserve_output_and_status() { + let output = super::background_command("powershell.exe") + .args([ + "-NoProfile", "-NonInteractive", "-Command", + r#"Add-Type 'using System; using System.Runtime.InteropServices; public class ConsoleProbe { [DllImport("kernel32.dll")] public static extern IntPtr GetConsoleWindow(); }'; [ConsoleProbe]::GetConsoleWindow().ToInt64(); exit 7"#, + ]) + .output() + .expect("run background console probe"); + assert_eq!(output.status.code(), Some(7), "child exit status is preserved: {output:?}"); + assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), "0", "no console is allocated"); + } +} + #[cfg(all(test, target_os = "macos"))] mod tests { use super::macos_bundle; From e2b1a64629c37a3508d50d301f0b17470caa4adf Mon Sep 17 00:00:00 2001 From: Afonso Jorge Ramos Date: Thu, 17 Sep 2026 01:19:13 +0200 Subject: [PATCH 5/6] refactor(apply): separate client activation from preparation --- rust/crates/spicetify/src/commands/apply.rs | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/rust/crates/spicetify/src/commands/apply.rs b/rust/crates/spicetify/src/commands/apply.rs index 4fca73d395..59c83bf9c2 100644 --- a/rust/crates/spicetify/src/commands/apply.rs +++ b/rust/crates/spicetify/src/commands/apply.rs @@ -205,19 +205,24 @@ fn run_inner(ctx: &AppContext, mode: ApplyMode, activate: bool) -> Result<()> { super::updates::finalize_app_signature(ctx)?; if activate { - if matches!(mode, ApplyMode::Cli { .. }) { - ensure_daemon(ctx); - } - crate::lifecycle::start(ctx)?; - if matches!(mode, ApplyMode::Cli { .. }) { - crate::platform::register_url_scheme(); - } + activate_client(ctx, mode)?; } tracing::info!("{}", fl!("applied-patches")); Ok(()) } +fn activate_client(ctx: &AppContext, mode: ApplyMode) -> Result<()> { + if matches!(mode, ApplyMode::Cli { .. }) { + ensure_daemon(ctx); + } + crate::lifecycle::start(ctx)?; + if matches!(mode, ApplyMode::Cli { .. }) { + crate::platform::register_url_scheme(); + } + Ok(()) +} + fn detect_supported_spotify_version(ctx: &AppContext) -> Result> { match crate::hooks::version_detect::detect_spotify_version(ctx) { Ok(version) if !crate::hooks::version_detect::spotify_supported(&version) => { From ed03250f8a9cd5eb0a0a95c1b64e959183ecc3ed Mon Sep 17 00:00:00 2001 From: Afonso Jorge Ramos Date: Thu, 17 Sep 2026 01:37:05 +0200 Subject: [PATCH 6/6] docs: record visible macOS Store Apply verification --- docs/daemon-apply-verification.md | 33 ++++++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/docs/daemon-apply-verification.md b/docs/daemon-apply-verification.md index 8258f0788e..a1b1a7401d 100644 --- a/docs/daemon-apply-verification.md +++ b/docs/daemon-apply-verification.md @@ -90,8 +90,39 @@ and recovery of hidden window controls after restarting the stopped daemon. The native bridge subsequently became unavailable in the current host session. Both a fresh connection and a session reset returned `native pipe unavailable`. Consequently the fixed build's automatic launch was verified as a process and -window, not visually inspected. The normal UI Apply action and the first +window, not visually inspected. The Windows UI Apply action and the first patched renderer after an actual Spotify version update still need a native end-user pass. The earlier [Windows update report](windows-update-verification.md) remains accurate; this run does not clear its first-boot limitation or enable Windows Update & Apply in release builds. + + +## macOS visible Store Apply, 2026-09-17 + +A combined local build at `115d010` included this fix, GraphQL discovery and +protocol signing (#3951), and launchd reconciliation (#3952). Matching CLI and +daemon binaries were installed in the normal installation directory after +backing up both binaries and Spotify. This remains a local build reporting +3.0.0-beta.17, not a published release. + +The fixture used the published, checksum-verified stdlib 1.11.3 artifact, +installed and applied through the CLI. From the visible Module Store, **Update +all** staged stdlib 1.12.0. **Apply stdlib update** opened the restart warning. +**Cancel** preserved the staged update. Reopening the confirmation and clicking +**Apply and restart** restarted Spotify 1.3.0.277. The Home view and Module Store +rendered with the existing theme, the Apply banner cleared, and the manifest +and installed module link both returned to stdlib 1.12.0. + +Daemon PID 3060 survived the operation with increasing uptime and an unchanged +launch-agent plist. The concurrent health sample recorded no failures or uptime +resets. Both watchers remained active. The watcher observed the temporary stock +archive during Apply and cancelled its pending repair once that archive was +consumed. The normal macOS URL handoff, `open spicetify:0:apply`, subsequently +completed another Apply and restart with the same daemon PID. The registered +applet passed strict code-signature verification. + +Fixture preparation and the URL handoff used CLI commands; the Store update, +cancellation, confirmation, restart, and returned views were exercised through +the native UI. This run does not test a Spotify version upgrade, Windows native +UI behavior, or browser confirmation prompts for custom URL schemes. Evidence +is retained under `scratchpad/final-reconciliation/` in the local workspace.