Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
127 changes: 114 additions & 13 deletions desktop/src-tauri/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1414,6 +1414,9 @@ fn warmup_models(state: tauri::State<BackendState>) -> Result<ModelWarmupStatus,
.env("TORCH_HOME", data_dir.join("models").join("torch"))
.stdout(Stdio::piped())
.stderr(Stdio::piped());
// The vocal-split model's downloader probes for `ffmpeg` on PATH before it
// will load anything (#505).
apply_ffmpeg_path(&mut command, &data_dir)?;

let child = command
.spawn()
Expand Down Expand Up @@ -1566,13 +1569,7 @@ fn start_backend(
.stdout(stdout)
.stderr(stderr);

if let Some(ffmpeg_dir) = ffmpeg_dir_if_present(&data_dir) {
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);
}
apply_ffmpeg_path(&mut cmd, &data_dir)?;

#[cfg(windows)]
{
Expand Down Expand Up @@ -3172,6 +3169,47 @@ fn sha256_file(path: &Path) -> Result<String, String> {
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<R: Read>(
mut archive: Archive<R>,
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()))?;
Expand All @@ -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)
}
}

Expand Down Expand Up @@ -3343,6 +3377,27 @@ fn ffmpeg_dir_if_present(data_dir: &Path) -> Option<PathBuf> {
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).
///
Expand Down Expand Up @@ -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
Expand Down
46 changes: 46 additions & 0 deletions scripts/macos/make-runtime-pack.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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}"
Expand Down
2 changes: 1 addition & 1 deletion templates/stemdeck.xml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<?xml version="1.0"?>
<Container version="2">
<Name>StemDeck</Name>
<Repository>ghcr.io/stemdeckapp/stemdeck:0.15.2</Repository>
<Repository>ghcr.io/stemdeckapp/stemdeck:0.16.0</Repository>
<Registry>https://github.com/stemdeckapp/stemdeck/pkgs/container/stemdeck</Registry>
<Network>bridge</Network>
<Shell>sh</Shell>
Expand Down
Loading