diff --git a/desktop/src-tauri/src/main.rs b/desktop/src-tauri/src/main.rs index df2841fb..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 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 +3219,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) } } @@ -3343,6 +3377,27 @@ fn ffmpeg_dir_if_present(data_dir: &Path) -> 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). /// @@ -4376,6 +4431,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}" 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