From 4ccb83649700ba71c7438d132fc3c0f6f0c0c183 Mon Sep 17 00:00:00 2001 From: Thales Pereira <31625914+thcp@users.noreply.github.com> Date: Sun, 30 Aug 2026 22:45:07 +0100 Subject: [PATCH 1/3] fix(macos): stop shipping AppleDouble files that break song sections The macOS runtime pack copies Python with `ditto`, which preserves extended attributes on every file, and then tarred the staging tree without disabling copyfile. macOS tar serialized those xattrs as AppleDouble "._name" members, and the Rust tar crate that unpacks the pack on the user's machine has no AppleDouble support, so it wrote 30,239 of them out as literal files. One lands in matplotlib's style directory. matplotlib globs "*.mplstyle" at import time, matches "._seaborn-v0_8-bright.mplstyle", and dies on byte 45 of the AppleDouble header: UnicodeDecodeError: 'utf-8' codec can't decode byte 0xa3 in position 45 That kills `import matplotlib.pyplot`, and with it allin1_infer and every importer of it. Automatic song sections were broken outright on macOS, not merely un-prewarmed: section_worker.py takes the same import path warmup does. Linux and Docker are unaffected. Fixed on both ends. The pack script strips xattrs and sidecars from the staging tree and sets COPYFILE_DISABLE, and extract_tar_archive skips "._" entries so packs already published are handled too. The pack script also verifies the finished archive, because whether macOS tar emits AppleDouble members at all varies by OS version, so none of the guards can be trusted blind. That check cannot use `tar -tf`: macOS tar folds "._name" members back into their sibling's metadata while listing exactly as it does while creating, and reports a clean archive either way. It streams members through Python's tarfile instead, which has no AppleDouble handling. Refs #505 --- desktop/src-tauri/src/main.rs | 95 ++++++++++++++++++++++++++++-- scripts/macos/make-runtime-pack.sh | 46 +++++++++++++++ 2 files changed, 135 insertions(+), 6 deletions(-) diff --git a/desktop/src-tauri/src/main.rs b/desktop/src-tauri/src/main.rs index df2841fb..fbf70ee1 100644 --- a/desktop/src-tauri/src/main.rs +++ b/desktop/src-tauri/src/main.rs @@ -3172,6 +3172,47 @@ fn sha256_file(path: &Path) -> Result { Ok(format!("{:x}", hasher.finalize())) } +/// True for AppleDouble sidecars -- the `._name` files macOS `tar` emits to +/// carry a file's extended attributes (#505). +/// +/// The runtime pack is built on macOS, where `ditto` preserves xattrs and `tar` +/// may encode them as these sidecar members. This crate has no AppleDouble +/// support, so unpacking them writes 30k binary stubs into `site-packages` -- +/// and `matplotlib`'s `*.mplstyle` glob then matches `._seaborn-v0_8-bright +/// .mplstyle` and dies decoding its header, which takes down `matplotlib +/// .pyplot`, `allin1_infer`, and automatic song sections with it. +/// +/// The pack script strips them at build time and fails if any survive, so this +/// exists for the packs already published without that guard. +fn is_apple_double(path: &Path) -> bool { + path.file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.starts_with("._")) +} + +fn unpack_without_apple_double( + mut archive: Archive, + destination: &Path, +) -> Result<(), String> { + let entries = archive + .entries() + .map_err(|e| format!("failed to read runtime pack: {e}"))?; + for entry in entries { + let mut entry = entry.map_err(|e| format!("failed to read runtime pack: {e}"))?; + let path = entry + .path() + .map_err(|e| format!("failed to read runtime pack: {e}"))? + .into_owned(); + if is_apple_double(&path) { + continue; + } + entry + .unpack_in(destination) + .map_err(|e| format!("failed to extract runtime pack: {e}"))?; + } + Ok(()) +} + fn extract_tar_archive(archive: &Path, destination: &Path) -> Result<(), String> { let file = fs::File::open(archive) .map_err(|e| format!("failed to open archive {}: {e}", archive.display()))?; @@ -3181,14 +3222,10 @@ fn extract_tar_archive(archive: &Path, destination: &Path) -> Result<(), String> if is_zst { let decoder = zstd::Decoder::new(file).map_err(|e| format!("failed to init zstd decoder: {e}"))?; - Archive::new(decoder) - .unpack(destination) - .map_err(|e| format!("failed to extract runtime pack: {e}")) + unpack_without_apple_double(Archive::new(decoder), destination) } else { let decoder = GzDecoder::new(file); - Archive::new(decoder) - .unpack(destination) - .map_err(|e| format!("failed to extract runtime pack: {e}")) + unpack_without_apple_double(Archive::new(decoder), destination) } } @@ -4376,6 +4413,52 @@ mod tests { tempfile::tempdir().expect("failed to create temp dir") } + #[test] + fn extract_tar_archive_drops_apple_double_sidecars() { + // A macOS-built runtime pack can carry `._name` AppleDouble members + // alongside the real files. Unpacked verbatim, the one beside + // matplotlib's stylelib matches its `*.mplstyle` glob and kills every + // import of matplotlib.pyplot -- and with it automatic song sections + // (#505). + let source = make_tmp(); + let stylelib = source.path().join("runtime/stylelib"); + fs::create_dir_all(&stylelib).unwrap(); + fs::write( + stylelib.join("seaborn-v0_8-bright.mplstyle"), + b"axes.grid: True", + ) + .unwrap(); + fs::write( + stylelib.join("._seaborn-v0_8-bright.mplstyle"), + b"\x00\x05\x16\x07\xa3binary AppleDouble header", + ) + .unwrap(); + + // Must be .tar.zst: that is the shape the macOS runtime pack ships in, + // and the only one extract_tar_archive routes away from gzip. + let archive_dir = make_tmp(); + let archive = archive_dir.path().join("runtime.tar.zst"); + let encoder = zstd::Encoder::new(fs::File::create(&archive).unwrap(), 0).unwrap(); + let mut builder = tar::Builder::new(encoder); + builder + .append_dir_all("runtime", source.path().join("runtime")) + .unwrap(); + builder.into_inner().unwrap().finish().unwrap(); + + let destination = make_tmp(); + super::extract_tar_archive(&archive, destination.path()).unwrap(); + + let unpacked = destination.path().join("runtime/stylelib"); + assert!( + unpacked.join("seaborn-v0_8-bright.mplstyle").is_file(), + "real files must still be extracted" + ); + assert!( + !unpacked.join("._seaborn-v0_8-bright.mplstyle").exists(), + "AppleDouble sidecar must not be written to disk" + ); + } + #[test] fn legacy_migration_preserves_user_settings_when_data_dir_already_exists() { // setup() creates the destination before ensure_workspace() invokes diff --git a/scripts/macos/make-runtime-pack.sh b/scripts/macos/make-runtime-pack.sh index 797c6ebb..16c05674 100755 --- a/scripts/macos/make-runtime-pack.sh +++ b/scripts/macos/make-runtime-pack.sh @@ -214,6 +214,20 @@ echo "==> Stripping Python caches" find "$PYTHON_DIR" -type d -name "__pycache__" -prune -exec rm -rf {} + 2>/dev/null || true find "$PYTHON_DIR" -type f \( -name "*.pyc" -o -name "*.pyo" \) -delete +# The ditto above preserves extended attributes on every copied file (#505). +# macOS tar then serializes those xattrs as AppleDouble "._name" members, and +# the Rust tar crate that unpacks this archive on the user's machine knows +# nothing about AppleDouble, so it writes them out as literal files. One of +# them lands in matplotlib's style directory, where the "*.mplstyle" glob picks +# it up and chokes on the binary header -- taking down every import of +# matplotlib.pyplot, and with it allin1_infer and automatic song sections. +# Strip the xattrs, delete any sidecars already on disk, and tell tar not to +# regenerate them. +echo "==> Stripping extended attributes and AppleDouble sidecars" +xattr -cr "$STAGING" 2>/dev/null || true +find "$STAGING" -name "._*" -delete + +export COPYFILE_DISABLE=1 ARCHIVE_NAME="StemDeck-runtime-macOS-${ARCH}.tar.zst" ARCHIVE_PATH="${BUILD_DIR}/${ARCHIVE_NAME}" if command -v zstd >/dev/null 2>&1; then @@ -224,6 +238,38 @@ else tar -czf "$ARCHIVE_PATH" -C "$STAGING" runtime fi +# The three guards above are all environment-dependent -- whether macOS tar +# emits AppleDouble members at all varies by OS version -- so verify the actual +# archive rather than trusting them. A pack that ships even one sidecar is a +# broken pack. +# +# `tar -tf` cannot do this audit: macOS tar folds "._name" members back into +# their sibling's metadata while listing, exactly as it does while creating, so +# it reports a clean archive whether or not one is clean. Stream the members +# through Python's tarfile instead, which has no AppleDouble handling at all. +echo "==> Verifying archive carries no AppleDouble entries" +if [[ "$ARCHIVE_PATH" == *.zst ]]; then + DECOMPRESS=(zstd -dc "$ARCHIVE_PATH") +else + DECOMPRESS=(gzip -dc "$ARCHIVE_PATH") +fi +APPLE_DOUBLE="$("${DECOMPRESS[@]}" | "$PYTHON_BIN" -c ' +import posixpath, sys, tarfile + +found = [ + member.name + for member in tarfile.open(fileobj=sys.stdin.buffer, mode="r|") + if posixpath.basename(member.name).startswith("._") +] +print("\n".join(found[:5])) +print(f"({len(found)} total)" if found else "", end="") +')" +if [[ -n "$APPLE_DOUBLE" ]]; then + echo "ERROR: archive contains AppleDouble ._ entries (see #505)" >&2 + echo "$APPLE_DOUBLE" >&2 + exit 1 +fi + SIZE="$(stat -f%z "$ARCHIVE_PATH")" SHA256="$(shasum -a 256 "$ARCHIVE_PATH" | awk '{print $1}')" RUNTIME_URL="${RELEASE_BASE_URL}/${ARCHIVE_NAME}" From 3b0ae9ef9e84206af0f90c74b8ea3f37cc5439bc Mon Sep 17 00:00:00 2001 From: Thales Pereira <31625914+thcp@users.noreply.github.com> Date: Sun, 30 Aug 2026 22:45:28 +0100 Subject: [PATCH 2/3] fix(macos): give model warmup the bundled FFmpeg on PATH `warmup_models` spawned its Python child without the PATH block `start_backend` has, so the bundled FFmpeg in the data directory was invisible to it. A Finder-launched .app inherits a bare /usr/bin:/bin:/usr/sbin:/sbin, and audio_separator probes for `ffmpeg` before it will load anything, so the karaoke vocal-split model failed every setup run with: WARMUP_FAILED vocal_split [Errno 2] No such file or directory: 'ffmpeg' Smaller blast radius than the sections failure: the real backend does get the right PATH, so the on-demand vocal split still worked, it just paid the model download mid-job instead of during setup. The two spawn sites had drifted because each carried its own copy of the PATH logic, so extract it into apply_ffmpeg_path and call it from both. Refs #505 --- desktop/src-tauri/src/main.rs | 32 +++++++++++++++++++++++++------- 1 file changed, 25 insertions(+), 7 deletions(-) diff --git a/desktop/src-tauri/src/main.rs b/desktop/src-tauri/src/main.rs index fbf70ee1..40145f5e 100644 --- a/desktop/src-tauri/src/main.rs +++ b/desktop/src-tauri/src/main.rs @@ -1414,6 +1414,9 @@ fn warmup_models(state: tauri::State) -> Result Option { path.parent().map(Path::to_path_buf) } +/// Prepend the bundled FFmpeg directory to `cmd`'s PATH. +/// +/// Every child process that may shell out to `ffmpeg`/`ffprobe` needs this, not +/// just the backend: a Finder-launched `.app` inherits a bare +/// `/usr/bin:/bin:/usr/sbin:/sbin`, so an FFmpeg we downloaded into the data +/// directory is invisible to anything we spawn unless we put it there +/// ourselves. Warmup grew its own command without this and silently lost the +/// karaoke vocal-split model to `FileNotFoundError` (#505), so it lives in one +/// place now. +fn apply_ffmpeg_path(cmd: &mut Command, data_dir: &Path) -> Result<(), String> { + let Some(ffmpeg_dir) = ffmpeg_dir_if_present(data_dir) else { + return Ok(()); + }; + let existing = env::var_os("PATH").unwrap_or_default(); + let mut paths = vec![ffmpeg_dir]; + paths.extend(env::split_paths(&existing)); + let joined = env::join_paths(paths).map_err(|e| e.to_string())?; + cmd.env("PATH", joined); + Ok(()) +} + /// Claim `host:port` without serving on it, and report the port that was /// actually granted (`port` of 0 asks the OS to choose). /// From e75ba52bd2b5de772406eee154d9fe12c1cb2698 Mon Sep 17 00:00:00 2001 From: Thales Pereira <31625914+thcp@users.noreply.github.com> Date: Sun, 30 Aug 2026 22:58:47 +0100 Subject: [PATCH 3/3] chore(unraid): pin the template at 0.16.0 The template tracks the latest published pre-release, which is now 0.16.0. Unraid users pull exactly this tag, so leaving it at 0.15.2 means their installs silently lag behind. Only the tag changes; the Docker image itself is unaffected by the macOS fixes in this branch. --- templates/stemdeck.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/stemdeck.xml b/templates/stemdeck.xml index 7399e517..3a8bd99c 100644 --- a/templates/stemdeck.xml +++ b/templates/stemdeck.xml @@ -1,7 +1,7 @@ StemDeck - ghcr.io/stemdeckapp/stemdeck:0.15.2 + ghcr.io/stemdeckapp/stemdeck:0.16.0 https://github.com/stemdeckapp/stemdeck/pkgs/container/stemdeck bridge sh