Release pipeline: signed macOS/Windows, Linux packages, FreeBSD, Web; app icon

- .github/workflows/release.yml: every push to `release` builds a draft
  GitHub Release: macOS universal DMG (Developer ID + notarization),
  Windows x64/x86/arm64 MSI + zips (Azure Trusted Signing), Linux
  x86_64/aarch64 AppImage/deb/rpm/tar.gz + Flatpak manifest, FreeBSD,
  Web/WASM zip; SHA256SUMS; secrets only via the release environment
- packaging/ for every platform; .scraft + audio file associations
- cargo xtask version [set X.Y.Z] and xtask ico
- apps/soundcraft-web (trunk, WebGPU/WebGL2, WebAudio)
- placeholder app icon (original nightingale), window icon, Windows resources
- ROADMAP.md

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Brandon ThomasandClaude Opus 5.5 committed 2026-10-05 23:17:59 -04:00
1 parent a28e15210c
commit 04d222069d
53 files changed
+2567 -5

No files matched your search

+50
View File
@@ -0,0 +1,50 @@
# Builds and tests the workspace on FreeBSD, inside a FreeBSD VM on a Linux runner
# (GitHub has no FreeBSD runners). GPU tests skip themselves when the VM has no adapter, and the
# player falls back to its silent clock when the VM has no sound device.
name: FreeBSD
on:
push:
branches: [main]
pull_request:
paths:
- Cargo.toml
- Cargo.lock
- "**/Cargo.toml"
- "crates/playback/**"
- .github/workflows/freebsd.yml
workflow_dispatch:
concurrency:
group: freebsd-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
freebsd:
name: FreeBSD 14 (build + test)
runs-on: ubuntu-latest
timeout-minutes: 300
steps:
- uses: actions/checkout@v4
- name: Build and test in a FreeBSD VM
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1
with:
release: "14.3"
usesh: true
cpu: 4
mem: 12288
# Nothing to bring back; copying the target dir out of the VM fails on large builds.
copyback: false
prepare: |
pkg install -y git curl pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2 alsa-lib
curl -fsSL https://sh.rustup.rs | sh -s -- -y --profile minimal
run: |
. "$HOME/.cargo/env"
# The checkout is synced in under another owner; let tests that call git read it.
git config --global --add safe.directory "*"
rustc --version
CARGO_PROFILE_DEV_DEBUG=0 cargo build --workspace --all-targets --locked
CARGO_PROFILE_DEV_DEBUG=0 cargo test --workspace --locked
+57
View File
@@ -0,0 +1,57 @@
# Quick static checks for the release pipeline (seconds, no Rust build): workflow syntax,
# packaging shell/PowerShell scripts, and the Linux desktop/AppStream/Flatpak metadata.
name: Packaging lint
on:
push:
branches: [main, release]
paths: [".github/workflows/**", "packaging/**", "assets/app-icon/**"]
pull_request:
paths: [".github/workflows/**", "packaging/**", "assets/app-icon/**"]
workflow_dispatch:
permissions:
contents: read
concurrency:
group: packaging-lint-${{ github.ref }}
cancel-in-progress: true
jobs:
lint:
runs-on: ubuntu-latest
env:
ACTIONLINT_VERSION: 1.7.12
steps:
- uses: actions/checkout@v4
- name: Install tools
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends shellcheck desktop-file-utils appstream libxml2-utils python3-yaml
curl -fsSL "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" \
| tar -xz -C "$RUNNER_TEMP" actionlint
- name: actionlint
run: |
"$RUNNER_TEMP/actionlint" -color
- name: shellcheck
run: shellcheck packaging/*.sh packaging/*/*.sh
- name: PowerShell
shell: pwsh
run: |
foreach ($f in Get-ChildItem packaging/windows/*.ps1) {
$errors = $null
[System.Management.Automation.Language.Parser]::ParseFile($f.FullName, [ref]$null, [ref]$errors) | Out-Null
if ($errors) { $errors | ForEach-Object { Write-Output "::error file=$($f.Name)::$_" }; exit 1 }
Write-Output "ok $($f.Name)"
}
- name: XML, desktop entry, AppStream, Flatpak manifest
run: |
xmllint --noout packaging/windows/soundcraft.wxs packaging/macos/*.plist* packaging/linux/*.xml*
# Windows Installer needs Icon ids ending in .ico or .exe, or shortcuts show a blank icon.
if grep -oE '<Icon Id="[^"]*"' packaging/windows/soundcraft.wxs | grep -vE '\.(ico|exe)"$'; then
echo "::error::Icon Id must end in .ico or .exe"; exit 1
fi
desktop-file-validate packaging/linux/ai.storyteller.soundcraft.desktop
sed -e 's/@VERSION@/0.0.0/' -e 's/@DATE@/2026-01-01/' packaging/linux/ai.storyteller.soundcraft.metainfo.xml.in >"$RUNNER_TEMP/ai.storyteller.soundcraft.metainfo.xml"
appstreamcli validate --no-net --explain "$RUNNER_TEMP/ai.storyteller.soundcraft.metainfo.xml"
python3 -c "import yaml; m = yaml.safe_load(open('packaging/linux/flatpak/ai.storyteller.soundcraft.yml')); assert m['id'] == 'ai.storyteller.soundcraft' and m['command'] == 'soundcraft'"
+333
View File
@@ -0,0 +1,333 @@
# Release pipeline: every push to `release` builds signed installers for every platform and
# creates or updates the draft GitHub Release "SoundCraft v<version>". See docs/releasing.md.
#
# All jobs run in the `release` environment (restricted to the `release` branch), which holds
# the signing secrets. Every secret is optional: missing ones produce unsigned artifacts and a
# warning, never a failed build.
name: Release
on:
push:
branches: [release]
workflow_dispatch:
inputs:
version:
description: "Version override for a test run, e.g. 0.2.0-rc.1 (empty: Cargo.toml)"
type: string
default: ""
permissions:
contents: read
concurrency:
group: release-${{ github.ref }}
# Never cancel a run mid-notarization or mid-upload; a newer push waits its turn.
cancel-in-progress: false
env:
CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: 0
RUST_BACKTRACE: 1
SOUNDCRAFT_BUILD_SHA: ${{ github.sha }}
INPUT_VERSION: ${{ inputs.version }}
jobs:
version:
name: version
runs-on: ubuntu-latest
environment: release
outputs:
version: ${{ steps.v.outputs.version }}
date: ${{ steps.v.outputs.date }}
prerelease: ${{ steps.v.outputs.prerelease }}
steps:
- uses: actions/checkout@v4
- id: v
name: Resolve version
run: |
. packaging/env.sh
v="${INPUT_VERSION#v}"
v="${v:-$VERSION}"
if ! printf '%s' "$v" | grep -Eq '^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$'; then
echo "::error::'$v' is not a version like 1.2.3 or 1.2.3-rc.1"
exit 1
fi
case "$v" in *-*) pre=true ;; *) pre=false ;; esac
{
echo "version=$v"
echo "date=$(date -u +%Y-%m-%d)"
echo "prerelease=$pre"
} >>"$GITHUB_OUTPUT"
echo "### SoundCraft v$v (\`${GITHUB_SHA::9}\`)" >>"$GITHUB_STEP_SUMMARY"
macos:
name: macOS universal
needs: version
runs-on: macos-15
environment: release
env:
SOUNDCRAFT_VERSION: ${{ needs.version.outputs.version }}
SOUNDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
targets: aarch64-apple-darwin, x86_64-apple-darwin
- uses: Swatinem/rust-cache@v2
with:
key: release-macos-universal
- name: Apply version override
if: inputs.version != ''
run: cargo xtask version set "$SOUNDCRAFT_VERSION"
- name: Import signing certificate
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
run: packaging/macos/import-cert.sh
- name: Build, sign, notarize
env:
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: packaging/macos/package.sh --arch universal
- uses: actions/upload-artifact@v4
with:
name: release-macos
path: dist/release/*
if-no-files-found: error
- name: Remove temporary keychain
if: always()
run: |
if [ -n "${MACOS_KEYCHAIN:-}" ]; then security delete-keychain "$MACOS_KEYCHAIN" || true; fi
windows:
name: Windows ${{ matrix.arch }}
needs: version
runs-on: windows-latest
environment: release
strategy:
fail-fast: false
matrix:
include:
- { arch: x64, target: x86_64-pc-windows-msvc }
- { arch: x86, target: i686-pc-windows-msvc }
# Windows on ARM: cross-compiled on the x64 runner, so signing and WiX run as above.
# .github/workflows/windows-arm64.yml installs and runs it on ARM64 hardware.
- { arch: arm64, target: aarch64-pc-windows-msvc }
env:
SOUNDCRAFT_VERSION: ${{ needs.version.outputs.version }}
SOUNDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- uses: Swatinem/rust-cache@v2
with:
key: release-windows-${{ matrix.arch }}
- name: Apply version override
if: inputs.version != ''
shell: bash
run: cargo xtask version set "$SOUNDCRAFT_VERSION"
- name: Install WiX
shell: pwsh
run: dotnet tool install --global wix --version 5.0.2
- name: Build, sign, package
shell: pwsh
env:
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}
run: ./packaging/windows/package.ps1 -Arch ${{ matrix.arch }}
- uses: actions/upload-artifact@v4
with:
name: release-windows-${{ matrix.arch }}
path: dist/release/*
if-no-files-found: error
linux:
name: Linux ${{ matrix.arch }}
needs: version
# The oldest GitHub-hosted image, so the binaries only need glibc >= 2.35 (Ubuntu 22.04+,
# Debian 12+, Fedora 36+, RHEL 10). See docs/releasing.md.
runs-on: ${{ matrix.runner }}
environment: release
strategy:
fail-fast: false
matrix:
include:
- { arch: x86_64, runner: ubuntu-22.04, deb: amd64 }
- { arch: aarch64, runner: ubuntu-22.04-arm, deb: arm64 }
env:
SOUNDCRAFT_VERSION: ${{ needs.version.outputs.version }}
SOUNDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
NFPM_VERSION: 2.47.0
steps:
- uses: actions/checkout@v4
- name: Install build and packaging tools
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev libgl1-mesa-dev \
libasound2-dev pkg-config \
desktop-file-utils appstream python3-yaml
curl -fsSL -o "$RUNNER_TEMP/nfpm.deb" \
"https://github.com/goreleaser/nfpm/releases/download/v${NFPM_VERSION}/nfpm_${NFPM_VERSION}_${{ matrix.deb }}.deb"
sudo dpkg -i "$RUNNER_TEMP/nfpm.deb"
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
with:
key: release-linux-${{ matrix.arch }}
- name: Apply version override
if: inputs.version != ''
run: cargo xtask version set "$SOUNDCRAFT_VERSION"
- name: Build and package (AppImage, deb, rpm, tar.gz)
run: packaging/linux/package.sh
- name: Check the packages
run: |
dpkg-deb --info dist/release/*.deb
dpkg-deb --contents dist/release/*.deb | grep -E 'usr/bin/soundcraft$|metainfo|applications'
ldd target/release/soundcraft
# cpal's ALSA backend: the one audio library linked at build time (see nfpm.yaml).
ldd target/release/soundcraft | grep -q libasound
python3 -c "import yaml; m = yaml.safe_load(open('packaging/linux/flatpak/ai.storyteller.soundcraft.yml')); assert m['id'] == 'ai.storyteller.soundcraft'"
for appimage in dist/release/*.AppImage; do
chmod +x "$appimage"
APPIMAGE_EXTRACT_AND_RUN=1 "$appimage" --version
done
- uses: actions/upload-artifact@v4
with:
name: release-linux-${{ matrix.arch }}
path: dist/release/*
if-no-files-found: error
freebsd:
name: FreeBSD x86_64
needs: version
# GitHub has no FreeBSD runners: build in a FreeBSD 14.3 VM (same image as freebsd.yml).
runs-on: ubuntu-latest
timeout-minutes: 150
environment: release
env:
SOUNDCRAFT_VERSION: ${{ needs.version.outputs.version }}
SOUNDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
if: inputs.version != ''
- name: Apply version override
if: inputs.version != ''
run: cargo xtask version set "$SOUNDCRAFT_VERSION"
- name: Build and package in a FreeBSD VM
# v1.5.9, pinned by commit.
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc
with:
release: "14.3"
usesh: true
mem: 12288
# Passed through by name; never templated into the script.
envs: "SOUNDCRAFT_VERSION SOUNDCRAFT_BUILD_DATE SOUNDCRAFT_BUILD_SHA"
# alsa-lib: cpal's audio backend on FreeBSD (ALSA over OSS via alsa-plugins-oss at runtime).
prepare: |
pkg install -y bash curl git pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2 alsa-lib
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain stable
run: |
. "$HOME/.cargo/env"
git config --global --add safe.directory "$PWD"
# Build outside the checkout so only dist/ is copied back out of the VM.
export CARGO_TARGET_DIR=/var/tmp/soundcraft-target CARGO_BUILD_JOBS=4
bash packaging/freebsd/package.sh
- uses: actions/upload-artifact@v4
with:
name: release-freebsd
path: dist/release/*
if-no-files-found: error
web:
name: Web (wasm)
needs: version
runs-on: ubuntu-latest
environment: release
env:
SOUNDCRAFT_VERSION: ${{ needs.version.outputs.version }}
SOUNDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
TRUNK_VERSION: 0.21.14
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
targets: wasm32-unknown-unknown
- uses: Swatinem/rust-cache@v2
with:
key: release-web
- name: Install trunk
run: |
curl -fsSL "https://github.com/trunk-rs/trunk/releases/download/v${TRUNK_VERSION}/trunk-x86_64-unknown-linux-gnu.tar.gz" \
| tar -xz -C "$HOME/.cargo/bin" trunk
trunk --version
- name: Apply version override
if: inputs.version != ''
run: cargo xtask version set "$SOUNDCRAFT_VERSION"
- name: Build and zip
run: packaging/web/package.sh
- uses: actions/upload-artifact@v4
with:
name: release-web
path: dist/release/*
if-no-files-found: error
release:
name: Draft GitHub Release
needs: [version, macos, windows, linux, freebsd, web]
runs-on: ubuntu-latest
environment: release
permissions:
contents: write
env:
VERSION: ${{ needs.version.outputs.version }}
PRERELEASE: ${{ needs.version.outputs.prerelease }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
steps:
- uses: actions/download-artifact@v4
with:
pattern: release-*
path: artifacts
merge-multiple: true
- name: Checksums
working-directory: artifacts
run: |
sha256sum -- * | tee SHA256SUMS.txt
- name: Create or update the draft release
working-directory: artifacts
run: |
tag="v$VERSION"
title="SoundCraft v$VERSION"
pre=()
if [ "$PRERELEASE" = true ]; then pre=(--prerelease); fi
if draft="$(gh release view "$tag" --json isDraft --jq .isDraft 2>/dev/null)"; then
if [ "$draft" != "true" ]; then
echo "::error::Release $tag is already published. Bump the version (cargo xtask version set) before pushing to release."
exit 1
fi
echo "Updating draft $tag: replacing its assets"
gh release view "$tag" --json assets --jq '.assets[].name' | while read -r asset; do
gh release delete-asset "$tag" "$asset" --yes
done
gh release edit "$tag" --draft --title "$title" --target "$GITHUB_SHA" --prerelease="$PRERELEASE"
gh release upload "$tag" --clobber -- *
else
echo "Creating draft $tag"
gh release create "$tag" --draft --title "$title" --target "$GITHUB_SHA" --generate-notes "${pre[@]}" -- *
fi
{
echo "Draft release **$title** at \`${GITHUB_SHA::9}\`:"
echo
sed 's/^/ /' SHA256SUMS.txt
} >>"$GITHUB_STEP_SUMMARY"
+95
View File
@@ -0,0 +1,95 @@
# Windows on ARM: package the ARM64 release the way release.yml does (cross-compiled on an x64
# runner, unsigned here), then install that MSI on a Windows 11 ARM64 runner, check the installed
# binaries are ARM64, run the CLI natively, and uninstall. (A native ARM64 `cargo test` job is left
# out for now: PrintCraft saw an intermittent access violation in egui UI tests there.)
name: Windows ARM64
on:
push:
branches: [main]
pull_request:
paths:
- Cargo.toml
- Cargo.lock
- "**/Cargo.toml"
- "**/build.rs"
- .github/workflows/windows-arm64.yml
- packaging/windows/**
schedule:
- cron: "41 4 * * 1" # weekly, so drift in the runner images shows up
workflow_dispatch:
concurrency:
group: windows-arm64-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
env:
CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: 0
jobs:
package:
name: package (cross-compiled on x64)
runs-on: windows-latest
timeout-minutes: 90
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@89b12181fb390509a0842a86cc55eeb8eb928c1d # stable
with:
targets: aarch64-pc-windows-msvc
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2
with:
key: windows-arm64-package
- name: Install WiX
shell: pwsh
run: dotnet tool install --global wix --version 5.0.2
- name: Build and package (unsigned)
shell: pwsh
env:
DIST: ${{ github.workspace }}\dist\arm64
run: ./packaging/windows/package.ps1 -Arch arm64
- uses: actions/upload-artifact@v4
with:
name: soundcraft-windows-arm64
path: dist/arm64/*
if-no-files-found: error
retention-days: 7
install:
name: install and run on ARM64
needs: package
runs-on: windows-11-arm
timeout-minutes: 30
steps:
- uses: actions/download-artifact@v4
with:
name: soundcraft-windows-arm64
path: dist
- name: Install the MSI, check it, run it, uninstall it
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
$msi = (Get-ChildItem dist/*.msi | Select-Object -First 1).FullName
$p = Start-Process msiexec.exe -ArgumentList '/i', "`"$msi`"", '/qn', '/l*v', 'install.log' -Wait -PassThru
if ($p.ExitCode -ne 0) { Get-Content install.log -Tail 60; throw "msiexec /i exited $($p.ExitCode)" }
$dir = Join-Path $env:ProgramFiles 'SoundCraft'
foreach ($exe in 'soundcraft.exe', 'soundcraft-cli.exe') {
$path = Join-Path $dir $exe
if (-not (Test-Path $path)) { throw "$exe not installed in $dir" }
$bytes = [System.IO.File]::ReadAllBytes($path)
$machine = [BitConverter]::ToUInt16($bytes, [BitConverter]::ToInt32($bytes, 0x3C) + 4)
if ($machine -ne 0xAA64) { throw "$exe is for machine 0x$('{0:X}' -f $machine), not ARM64" }
Write-Output "ok $path (ARM64)"
}
# The installed CLI runs natively on ARM64.
$cli = Join-Path $dir 'soundcraft-cli.exe'
$out = & $cli --version 2>&1 | Out-String
if ($LASTEXITCODE -ne 0) { Write-Output $out; throw "soundcraft-cli --version exited $LASTEXITCODE" }
Write-Output "ok soundcraft-cli --version: $($out.Trim())"
$p = Start-Process msiexec.exe -ArgumentList '/x', "`"$msi`"", '/qn' -Wait -PassThru
if ($p.ExitCode -ne 0) { throw "msiexec /x exited $($p.ExitCode)" }
if (Test-Path (Join-Path $dir 'soundcraft.exe')) { throw 'uninstall left soundcraft.exe behind' }
Write-Output 'ok uninstalled'
+9 -2
View File
@@ -9,8 +9,9 @@ Assets must be original, public domain (CC0) or permissively licensed. See `AGEN
## Icons
All UI icons (tools, transport, track buttons) are drawn procedurally in
`crates/ui-egui/src/icons.rs` by the SoundCraft contributors (MIT OR Apache-2.0). No icon image
files are used.
`crates/ui-egui/src/icons.rs` by the SoundCraft contributors (MIT OR Apache-2.0). No UI icon image
files are used. The app icon (`assets/app-icon/`, a singing nightingale drawn as original SVG by
the SoundCraft contributors) is listed under Files below.
## Audio
@@ -36,3 +37,9 @@ and, at runtime, the operating system's own UI fonts when available (never redis
| `docs/brand/artcraft-mark.png` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms |
| `docs/brand/artcraft-mark-black.svg` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms |
| `docs/brand/artcraft-mark-black.png` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms |
| `assets/app-icon/icon.svg` | SoundCraft contributors | original (hand-placed SVG paths; placeholder app icon) | MIT OR Apache-2.0 |
| `assets/app-icon/soundcraft-1024.png` | SoundCraft contributors | original, rendered from `icon.svg` | MIT OR Apache-2.0 |
| `assets/app-icon/soundcraft-macos-512.png` | SoundCraft contributors | original, rendered from `icon.svg` | MIT OR Apache-2.0 |
| `assets/app-icon/soundcraft.icns` | SoundCraft contributors | original, rendered from `icon.svg` | MIT OR Apache-2.0 |
| `assets/app-icon/soundcraft.ico` | SoundCraft contributors | original, rendered from `icon.svg` | MIT OR Apache-2.0 |
| `assets/app-icon/hicolor/` | SoundCraft contributors | original, rendered from `icon.svg` (16–512 px PNGs and the scalable SVG) | MIT OR Apache-2.0 |
Generated
+20
View File
@@ -3357,6 +3357,7 @@ dependencies = [
"soundcraft-engine",
"soundcraft-playback",
"soundcraft-ui-egui",
"winresource",
]
[[package]]
@@ -3435,6 +3436,7 @@ name = "soundcraft-mix"
version = "0.1.0"
dependencies = [
"log",
"rayon",
"soundcraft-audio-io",
"soundcraft-dsp",
"soundcraft-midi",
@@ -3868,10 +3870,12 @@ version = "1.1.6+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "920602543f0911ab71da12c50d59701da54c196d1a2bf5cb4b75667f137a406a"
dependencies = [
"indexmap",
"serde_core",
"serde_spanned",
"toml_datetime",
"toml_parser",
"toml_writer",
"winnow",
]
@@ -3905,6 +3909,12 @@ dependencies = [
"winnow",
]
[[package]]
name = "toml_writer"
version = "1.1.2+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2"
[[package]]
name = "tracing"
version = "0.1.44"
@@ -4942,6 +4952,16 @@ dependencies = [
"memchr",
]
[[package]]
name = "winresource"
version = "0.1.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0986a8b1d586b7d3e4fe3d9ea39fb451ae22869dcea4aa109d287a374d866087"
dependencies = [
"toml",
"version_check",
]
[[package]]
name = "wit-bindgen"
version = "0.57.1"
+53
View File
@@ -0,0 +1,53 @@
# SoundCraft roadmap
SoundCraft is a clean-room, pure-Rust digital audio workstation that aims at full parity with
Avid Pro Tools, and then beyond it: faster, open, scriptable and agent-controllable. This file is
the honest status: what works today, what is missing, and how far we are.
## Where we are (2026-10-07)
| Measure | Value | How |
|---|---|---|
| Menu-catalog parity (engine + UI) | see [`docs/parity.md`](docs/parity.md) | `cargo xtask parity` compares the incumbent's 512 menu leaves (names observed black-box) with our command registry |
| Estimated overall feature parity | **~40 %** | judgement across the areas below, weighted by how much professional users rely on them |
| Estimated remaining effort | **~220–300 wall-clock hours** of Claude Opus 5.5 work (single agent; roughly 70–100 hours with 3–4 agents in parallel) | sum of the per-area estimates below |
### Area by area
| Area | Status | Parity | Remaining (h) |
|---|---|---:|---:|
| Session model, save/load, undo | Done: tracks, playlists, clips, fades, clip gain, automation, markers, groups, busses; JSON `.scraft` + Audio Files folder; COW undo with gesture coalescing | 80 % | 10 |
| Audio file I/O | WAV/BWF/RF64, AIFF/AIFC, FLAC (read/write); MP3, OGG, AAC, ALAC, CAF (read); peaks | 75 % | 10 |
| Editing | Slip/Shuffle/Spot/Grid, cut/copy/paste/clear/duplicate/repeat/shift, separate/heal/trim/consolidate/strip silence, fades & crossfades, nudge, playlists, grabber/trim/selector/smart/pencil/zoom/scrubber gestures, edit groups | 60 % | 35 |
| Mixer & routing | Faders, pan, mute/solo (SIP, implicit solo), sends pre/post, busses, aux inputs, master faders, VCAs, routing folders, 10 inserts, automation read | 60 % | 30 |
| Automation | Breakpoint lanes for volume/pan/mute/sends/plugin params; modes stored; write/thin/glide/convert commands | 45 % | 25 |
| Plugins (built-in) | 26 original processors + 2 instruments, AudioSuite offline processing | 45 % | 30 |
| Third-party plugins (CLAP/VST3/AU) | Not started | 0 % | 40 |
| MIDI | MIDI/instrument tracks, SMF import/export, MIDI editor (piano roll + velocity), event list, step input, quantize/transpose/velocity/duration ops | 40 % | 30 |
| Recording | Audio input capture, punch into selection; loop record, QuickPunch, input monitoring with latency compensation missing | 30 % | 20 |
| Elastic Audio / TCE / Beat Detective | Clip stretch ratio, offline time-stretch and pitch-shift, transient detection | 20 % | 25 |
| Video, surround panning, Atmos | Formats modelled; no video track, no surround panner | 5 % | 40 |
| UI fidelity | Edit + Mix windows, toolbar, rulers, track headers, menus for the whole catalog, floating windows, dialogs | 65 % | 25 |
| Agent control | CLI, JSON control channel, MCP server (headless + bridged), offscreen UI renders | 90 % (ahead of the incumbent) | 5 |
| Release engineering | Signed macOS universal, Windows x64/x86, Linux AppImage/deb/rpm/tar/Flatpak, FreeBSD, Web/WASM on every push to `release` | 80 % | 5 |
## Current focus
1. Land the engine parity batch (Edit/View/Track/Clip/Event/Options/Setup menu items).
2. Automation write modes during playback (touch/latch/write from fader moves).
3. Loop record and QuickPunch; input monitoring.
4. Plugin hosting (CLAP first, it is MIT-licensed and Rust-friendly).
5. Elastic Audio in realtime; Beat Detective workflow window.
6. README screenshots and the first alpha release.
## Milestones
- **M0–M3 (done):** workspace, gates, model, IO, DSP, mix engine, command engine, Edit/Mix UI.
- **M4 (done):** realtime playback, metering, plugins, bounce.
- **M5 (in progress):** editing depth (tools, modes, playlists, fades) — most items landed.
- **M6 (in progress):** recording.
- **M7 (in progress):** MIDI editing.
- **M8 (done for the first cut):** CLI, control channel, MCP, agent acceptance test.
- **M9:** AudioSuite breadth, Elastic Audio, Beat Detective.
- **M10:** alpha release on all platforms.
- **M11:** third-party plugin hosting, video, surround.
+4
View File
@@ -21,3 +21,7 @@ log.workspace = true
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
rfd.workspace = true
[build-dependencies]
# Icon + VERSIONINFO for soundcraft.exe; build.rs only uses it when the target is Windows.
winresource = "0.1"
+30
View File
@@ -0,0 +1,30 @@
//! Windows only: embed the app icon and version info (VERSIONINFO) into `soundcraft.exe`, so it
//! shows in Explorer, the taskbar, the Start menu and Alt-Tab.
//!
//! On every other target this does nothing. A missing resource compiler is a warning, so a
//! cross-compile from macOS or Linux still links, unless `SOUNDCRAFT_REQUIRE_WINRES=1` turns it
//! into an error (for release builds).
fn main() {
println!("cargo:rerun-if-changed=build.rs");
println!("cargo:rerun-if-changed=../../assets/app-icon/soundcraft.ico");
println!("cargo:rerun-if-env-changed=SOUNDCRAFT_REQUIRE_WINRES");
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("windows") {
return;
}
let mut res = winresource::WindowsResource::new();
res.set_icon("../../assets/app-icon/soundcraft.ico")
.set("ProductName", "SoundCraft")
.set("FileDescription", "SoundCraft digital audio workstation")
.set("CompanyName", "Learning Machines LLC")
.set("LegalCopyright", "Copyright (c) the SoundCraft authors. MIT OR Apache-2.0.")
.set("OriginalFilename", "soundcraft.exe")
.set("InternalName", "soundcraft");
if let Err(e) = res.compile() {
if std::env::var_os("SOUNDCRAFT_REQUIRE_WINRES").is_some() {
eprintln!("embedding Windows resources failed: {e}");
std::process::exit(1);
}
println!("cargo:warning=soundcraft.exe built without icon/version resources: {e}");
}
}
+14 -1
View File
@@ -121,6 +121,16 @@ fn services() -> Services {
}
}
/// The window, Dock, taskbar and Alt-Tab icon. macOS gets Apple's icon grid (transparent margin);
/// elsewhere the full tile. Regenerate with `packaging/icons.sh`.
fn app_icon() -> Option<egui::IconData> {
#[cfg(target_os = "macos")]
let png: &[u8] = include_bytes!("../../../assets/app-icon/soundcraft-macos-512.png");
#[cfg(not(target_os = "macos"))]
let png: &[u8] = include_bytes!("../../../assets/app-icon/hicolor/256x256/apps/ai.storyteller.soundcraft.png");
eframe::icon_data::from_png_bytes(png).map_err(|e| log::warn!("app icon: {e}")).ok()
}
fn main() -> eframe::Result {
let args: Vec<String> = std::env::args().skip(1).collect();
if args.iter().any(|a| a == "--version" || a == "-V") {
@@ -146,7 +156,7 @@ fn main() -> eframe::Result {
.collect();
let engine = if demo { soundcraft_engine::demo::demo_engine() } else { Engine::default() };
let options = eframe::NativeOptions {
let mut options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_title("SoundCraft")
.with_inner_size([1600.0, 1000.0])
@@ -155,6 +165,9 @@ fn main() -> eframe::Result {
.with_app_id("ai.storyteller.soundcraft"),
..Default::default()
};
if let Some(icon) = app_icon() {
options.viewport = options.viewport.with_icon(icon);
}
eframe::run_native(
"SoundCraft",
options,
+44
View File
@@ -0,0 +1,44 @@
# SoundCraft app icon (placeholder)
**A nightingale singing**, as an engraving-style head-and-shoulders portrait in the Crafting Apps'
owl-template framing (craftrules `standards/icon-design.md`): a full-bleed colour field, the bird
in three-quarter view with its bill open and its eye on the viewer, its breast and wing running off
the bottom and right edges, three song arcs leaving the bill.
This is a **placeholder** until the owner draws SoundCraft's icon in ArtCraft like the other apps'
icons. Replace `icon.svg` and run `packaging/icons.sh`; nothing else needs to change.
## Palette
Exactly three colours:
| Colour | Hex | Used for |
|---|---|---|
| Ink | `#0b0b0c` | Line work and contour |
| Paper | `#efe9dc` | The bird and the song arcs |
| SoundCraft teal (app colour) | `#14a9c4` | The full-bleed background field |
## Geometry
A 512 × 512 viewBox, clipped to a rounded square with `rx=112`. Windows and Linux use the full
tile. The macOS icons (`soundcraft.icns`, `soundcraft-macos-512.png`) sit on Apple's grid: an
824/1024 body with a transparent margin.
## Provenance and licence
Drawn from scratch as hand-placed SVG paths by the SoundCraft contributors; no source image was
traced or copied. MIT OR Apache-2.0, like the code (see `ATTRIBUTION.md`).
## Files
| File | What it is |
|---|---|
| `icon.svg` | The canonical vector |
| `soundcraft-1024.png` | 1024 px render |
| `soundcraft-macos-512.png` | Runtime window/Dock icon on macOS (Apple grid margin) |
| `soundcraft.icns` | macOS bundle icon |
| `soundcraft.ico` | Windows icon (16–256 px), embedded in `soundcraft.exe` and used by the MSI |
| `hicolor/<size>x<size>/apps/ai.storyteller.soundcraft.png` | Linux/FreeBSD icon theme, 16–512 px, plus `scalable/` |
Regenerate everything with `packaging/icons.sh` (needs `resvg`, and `iconutil` on macOS for the
`.icns`).
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 767 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><title>SoundCraft</title><defs><clipPath id="t"><rect width="512" height="512" rx="112"/></clipPath></defs><g clip-path="url(#t)"><rect width="512" height="512" fill="#14a9c4"/><path fill="#efe9dc" d="M79.8 175.5L80.3 172.9L80.5 170.6L80.7 168.3L80.9 166.1L81.1 164.0L81.3 162.0L81.4 160.0L81.5 158.0L81.5 156.0L81.5 154.0L81.4 152.0L81.3 150.0L81.1 148.0L80.9 145.9L80.7 143.7L80.5 141.4L80.3 139.1L79.8 136.5L79.8 136.5L77.7 138.2L76.1 140.1L74.6 142.1L73.4 144.2L72.4 146.5L71.6 148.8L71.0 151.2L70.6 153.6L70.5 156.0L70.6 158.4L71.0 160.8L71.6 163.2L72.4 165.5L73.4 167.8L74.6 169.9L76.1 171.9L77.7 173.8L79.8 175.5Z"/><path fill="#efe9dc" d="M55.7 185.2L55.9 181.6L55.9 178.1L55.9 174.8L55.9 171.6L55.9 168.4L55.9 165.2L56.0 162.1L56.0 159.1L56.0 156.0L56.0 152.9L56.0 149.9L55.9 146.8L55.9 143.6L55.9 140.4L55.9 137.2L55.9 133.9L55.9 130.4L55.7 126.8L55.7 126.8L53.1 129.4L51.0 132.4L49.2 135.4L47.6 138.7L46.3 142.0L45.3 145.4L44.6 148.9L44.1 152.4L44.0 156.0L44.1 159.6L44.6 163.1L45.3 166.6L46.3 170.0L47.6 173.3L49.2 176.6L51.0 179.6L53.1 182.6L55.7 185.2Z"/><path fill="#efe9dc" d="M31.6 195.0L31.5 190.2L31.2 185.7L31.0 181.3L30.8 177.0L30.7 172.7L30.6 168.5L30.5 164.3L30.5 160.1L30.5 156.0L30.5 151.9L30.5 147.7L30.6 143.5L30.7 139.3L30.8 135.0L31.0 130.7L31.2 126.3L31.5 121.8L31.6 117.0L31.6 117.0L28.5 120.7L25.9 124.7L23.8 128.8L21.9 133.1L20.3 137.5L19.1 142.1L18.2 146.7L17.7 151.3L17.5 156.0L17.7 160.7L18.2 165.3L19.1 169.9L20.3 174.5L21.9 178.9L23.8 183.2L25.9 187.3L28.5 191.3L31.6 195.0Z"/><path fill="#efe9dc" stroke="#0b0b0c" stroke-width="9" stroke-linejoin="round" d="M170 238C150 300 132 380 128 520L520 520 L520 300C470 268 420 210 392 160C360 104 300 78 248 84C206 89 178 112 168 150C162 176 160 210 170 238Z"/><path fill="#efe9dc" stroke="#0b0b0c" stroke-width="8" stroke-linejoin="round" d="M262 330C300 290 370 270 430 280C470 287 500 300 530 318L530 540 L300 540C280 470 262 400 262 330Z"/><path fill="#0b0b0c" d="M296.0 302.0L296.9 304.6L298.1 307.1L299.3 309.5L300.7 311.8L302.1 314.1L303.7 316.2L305.5 318.4L307.3 320.4L309.4 322.3L311.5 324.0L313.9 325.7L316.3 327.2L319.0 328.5L321.7 329.6L324.6 330.6L327.6 331.3L330.7 331.9L334.0 332.0L334.0 332.0L331.4 330.0L329.0 328.2L326.7 326.5L324.5 324.7L322.4 323.0L320.4 321.3L318.4 319.6L316.5 317.9L314.6 316.2L312.7 314.6L310.8 312.9L308.9 311.3L306.9 309.7L304.9 308.1L302.8 306.5L300.6 304.9L298.4 303.4L296.0 302.0Z"/><path fill="#0b0b0c" d="M340.0 306.0L340.9 308.6L342.1 311.1L343.3 313.5L344.7 315.8L346.1 318.1L347.7 320.2L349.5 322.4L351.3 324.4L353.4 326.3L355.5 328.0L357.9 329.7L360.3 331.2L363.0 332.5L365.7 333.6L368.6 334.6L371.6 335.3L374.7 335.9L378.0 336.0L378.0 336.0L375.4 334.0L373.0 332.2L370.7 330.5L368.5 328.7L366.4 327.0L364.4 325.3L362.4 323.6L360.5 321.9L358.6 320.2L356.7 318.6L354.8 316.9L352.9 315.3L350.9 313.7L348.9 312.1L346.8 310.5L344.6 308.9L342.4 307.4L340.0 306.0Z"/><path fill="#0b0b0c" d="M384.0 310.0L384.9 312.6L386.1 315.1L387.3 317.5L388.7 319.8L390.1 322.1L391.7 324.2L393.5 326.4L395.3 328.4L397.4 330.3L399.5 332.0L401.9 333.7L404.3 335.2L407.0 336.5L409.7 337.6L412.6 338.6L415.6 339.3L418.7 339.9L422.0 340.0L422.0 340.0L419.4 338.0L417.0 336.2L414.7 334.5L412.5 332.7L410.4 331.0L408.4 329.3L406.4 327.6L404.5 325.9L402.6 324.2L400.7 322.6L398.8 320.9L396.9 319.3L394.9 317.7L392.9 316.1L390.8 314.5L388.6 312.9L386.4 311.4L384.0 310.0Z"/><path fill="#0b0b0c" d="M428.0 314.0L428.9 316.6L430.1 319.1L431.3 321.5L432.7 323.8L434.1 326.1L435.7 328.2L437.5 330.4L439.3 332.4L441.4 334.3L443.5 336.0L445.9 337.7L448.3 339.2L451.0 340.5L453.7 341.6L456.6 342.6L459.6 343.3L462.7 343.9L466.0 344.0L466.0 344.0L463.4 342.0L461.0 340.2L458.7 338.5L456.5 336.7L454.4 335.0L452.4 333.3L450.4 331.6L448.5 329.9L446.6 328.2L444.7 326.6L442.8 324.9L440.9 323.3L438.9 321.7L436.9 320.1L434.8 318.5L432.6 316.9L430.4 315.4L428.0 314.0Z"/><path fill="#0b0b0c" d="M472.0 318.0L472.9 320.6L474.1 323.1L475.3 325.5L476.7 327.8L478.1 330.1L479.7 332.2L481.5 334.4L483.3 336.4L485.4 338.3L487.5 340.0L489.9 341.7L492.3 343.2L495.0 344.5L497.7 345.6L500.6 346.6L503.6 347.3L506.7 347.9L510.0 348.0L510.0 348.0L507.4 346.0L505.0 344.2L502.7 342.5L500.5 340.7L498.4 339.0L496.4 337.3L494.4 335.6L492.5 333.9L490.6 332.2L488.7 330.6L486.8 328.9L484.9 327.3L482.9 325.7L480.9 324.1L478.8 322.5L476.6 320.9L474.4 319.4L472.0 318.0Z"/><path fill="#0b0b0c" d="M296.0 353.0L296.8 355.7L297.9 358.2L299.1 360.7L300.4 363.0L301.8 365.3L303.4 367.6L305.1 369.7L307.0 371.7L309.0 373.6L311.2 375.4L313.6 377.1L316.1 378.5L318.8 379.8L321.5 380.9L324.5 381.8L327.5 382.5L330.7 383.0L334.0 383.0L334.0 383.0L331.5 380.9L329.1 379.0L326.8 377.2L324.6 375.4L322.6 373.7L320.6 371.9L318.7 370.2L316.8 368.5L315.0 366.9L313.1 365.2L311.1 363.6L309.2 362.0L307.2 360.4L305.1 358.8L303.0 357.3L300.8 355.8L298.5 354.3L296.0 353.0Z"/><path fill="#0b0b0c" d="M342.0 357.0L342.8 359.7L343.9 362.2L345.1 364.7L346.4 367.0L347.8 369.3L349.4 371.Line truncated

After

Width:  |  Height:  |  Size: 18 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><title>SoundCraft</title><defs><clipPath id="t"><rect width="512" height="512" rx="112"/></clipPath></defs><g clip-path="url(#t)"><rect width="512" height="512" fill="#14a9c4"/><path fill="#efe9dc" d="M79.8 175.5L80.3 172.9L80.5 170.6L80.7 168.3L80.9 166.1L81.1 164.0L81.3 162.0L81.4 160.0L81.5 158.0L81.5 156.0L81.5 154.0L81.4 152.0L81.3 150.0L81.1 148.0L80.9 145.9L80.7 143.7L80.5 141.4L80.3 139.1L79.8 136.5L79.8 136.5L77.7 138.2L76.1 140.1L74.6 142.1L73.4 144.2L72.4 146.5L71.6 148.8L71.0 151.2L70.6 153.6L70.5 156.0L70.6 158.4L71.0 160.8L71.6 163.2L72.4 165.5L73.4 167.8L74.6 169.9L76.1 171.9L77.7 173.8L79.8 175.5Z"/><path fill="#efe9dc" d="M55.7 185.2L55.9 181.6L55.9 178.1L55.9 174.8L55.9 171.6L55.9 168.4L55.9 165.2L56.0 162.1L56.0 159.1L56.0 156.0L56.0 152.9L56.0 149.9L55.9 146.8L55.9 143.6L55.9 140.4L55.9 137.2L55.9 133.9L55.9 130.4L55.7 126.8L55.7 126.8L53.1 129.4L51.0 132.4L49.2 135.4L47.6 138.7L46.3 142.0L45.3 145.4L44.6 148.9L44.1 152.4L44.0 156.0L44.1 159.6L44.6 163.1L45.3 166.6L46.3 170.0L47.6 173.3L49.2 176.6L51.0 179.6L53.1 182.6L55.7 185.2Z"/><path fill="#efe9dc" d="M31.6 195.0L31.5 190.2L31.2 185.7L31.0 181.3L30.8 177.0L30.7 172.7L30.6 168.5L30.5 164.3L30.5 160.1L30.5 156.0L30.5 151.9L30.5 147.7L30.6 143.5L30.7 139.3L30.8 135.0L31.0 130.7L31.2 126.3L31.5 121.8L31.6 117.0L31.6 117.0L28.5 120.7L25.9 124.7L23.8 128.8L21.9 133.1L20.3 137.5L19.1 142.1L18.2 146.7L17.7 151.3L17.5 156.0L17.7 160.7L18.2 165.3L19.1 169.9L20.3 174.5L21.9 178.9L23.8 183.2L25.9 187.3L28.5 191.3L31.6 195.0Z"/><path fill="#efe9dc" stroke="#0b0b0c" stroke-width="9" stroke-linejoin="round" d="M170 238C150 300 132 380 128 520L520 520 L520 300C470 268 420 210 392 160C360 104 300 78 248 84C206 89 178 112 168 150C162 176 160 210 170 238Z"/><path fill="#efe9dc" stroke="#0b0b0c" stroke-width="8" stroke-linejoin="round" d="M262 330C300 290 370 270 430 280C470 287 500 300 530 318L530 540 L300 540C280 470 262 400 262 330Z"/><path fill="#0b0b0c" d="M296.0 302.0L296.9 304.6L298.1 307.1L299.3 309.5L300.7 311.8L302.1 314.1L303.7 316.2L305.5 318.4L307.3 320.4L309.4 322.3L311.5 324.0L313.9 325.7L316.3 327.2L319.0 328.5L321.7 329.6L324.6 330.6L327.6 331.3L330.7 331.9L334.0 332.0L334.0 332.0L331.4 330.0L329.0 328.2L326.7 326.5L324.5 324.7L322.4 323.0L320.4 321.3L318.4 319.6L316.5 317.9L314.6 316.2L312.7 314.6L310.8 312.9L308.9 311.3L306.9 309.7L304.9 308.1L302.8 306.5L300.6 304.9L298.4 303.4L296.0 302.0Z"/><path fill="#0b0b0c" d="M340.0 306.0L340.9 308.6L342.1 311.1L343.3 313.5L344.7 315.8L346.1 318.1L347.7 320.2L349.5 322.4L351.3 324.4L353.4 326.3L355.5 328.0L357.9 329.7L360.3 331.2L363.0 332.5L365.7 333.6L368.6 334.6L371.6 335.3L374.7 335.9L378.0 336.0L378.0 336.0L375.4 334.0L373.0 332.2L370.7 330.5L368.5 328.7L366.4 327.0L364.4 325.3L362.4 323.6L360.5 321.9L358.6 320.2L356.7 318.6L354.8 316.9L352.9 315.3L350.9 313.7L348.9 312.1L346.8 310.5L344.6 308.9L342.4 307.4L340.0 306.0Z"/><path fill="#0b0b0c" d="M384.0 310.0L384.9 312.6L386.1 315.1L387.3 317.5L388.7 319.8L390.1 322.1L391.7 324.2L393.5 326.4L395.3 328.4L397.4 330.3L399.5 332.0L401.9 333.7L404.3 335.2L407.0 336.5L409.7 337.6L412.6 338.6L415.6 339.3L418.7 339.9L422.0 340.0L422.0 340.0L419.4 338.0L417.0 336.2L414.7 334.5L412.5 332.7L410.4 331.0L408.4 329.3L406.4 327.6L404.5 325.9L402.6 324.2L400.7 322.6L398.8 320.9L396.9 319.3L394.9 317.7L392.9 316.1L390.8 314.5L388.6 312.9L386.4 311.4L384.0 310.0Z"/><path fill="#0b0b0c" d="M428.0 314.0L428.9 316.6L430.1 319.1L431.3 321.5L432.7 323.8L434.1 326.1L435.7 328.2L437.5 330.4L439.3 332.4L441.4 334.3L443.5 336.0L445.9 337.7L448.3 339.2L451.0 340.5L453.7 341.6L456.6 342.6L459.6 343.3L462.7 343.9L466.0 344.0L466.0 344.0L463.4 342.0L461.0 340.2L458.7 338.5L456.5 336.7L454.4 335.0L452.4 333.3L450.4 331.6L448.5 329.9L446.6 328.2L444.7 326.6L442.8 324.9L440.9 323.3L438.9 321.7L436.9 320.1L434.8 318.5L432.6 316.9L430.4 315.4L428.0 314.0Z"/><path fill="#0b0b0c" d="M472.0 318.0L472.9 320.6L474.1 323.1L475.3 325.5L476.7 327.8L478.1 330.1L479.7 332.2L481.5 334.4L483.3 336.4L485.4 338.3L487.5 340.0L489.9 341.7L492.3 343.2L495.0 344.5L497.7 345.6L500.6 346.6L503.6 347.3L506.7 347.9L510.0 348.0L510.0 348.0L507.4 346.0L505.0 344.2L502.7 342.5L500.5 340.7L498.4 339.0L496.4 337.3L494.4 335.6L492.5 333.9L490.6 332.2L488.7 330.6L486.8 328.9L484.9 327.3L482.9 325.7L480.9 324.1L478.8 322.5L476.6 320.9L474.4 319.4L472.0 318.0Z"/><path fill="#0b0b0c" d="M296.0 353.0L296.8 355.7L297.9 358.2L299.1 360.7L300.4 363.0L301.8 365.3L303.4 367.6L305.1 369.7L307.0 371.7L309.0 373.6L311.2 375.4L313.6 377.1L316.1 378.5L318.8 379.8L321.5 380.9L324.5 381.8L327.5 382.5L330.7 383.0L334.0 383.0L334.0 383.0L331.5 380.9L329.1 379.0L326.8 377.2L324.6 375.4L322.6 373.7L320.6 371.9L318.7 370.2L316.8 368.5L315.0 366.9L313.1 365.2L311.1 363.6L309.2 362.0L307.2 360.4L305.1 358.8L303.0 357.3L300.8 355.8L298.5 354.3L296.0 353.0Z"/><path fill="#0b0b0c" d="M342.0 357.0L342.8 359.7L343.9 362.2L345.1 364.7L346.4 367.0L347.8 369.3L349.4 371.Line truncated

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

+3 -2
View File
@@ -28,8 +28,9 @@ pub struct Recorder {
pub channels: usize,
}
/// Cap: one hour of 8-channel 192 kHz audio.
const MAX_SAMPLES: usize = 3600 * 192_000 * 8;
/// Cap: one hour of 8-channel 192 kHz audio; 64 Mi samples (256 MB of f32) on 32-bit targets
/// (wasm32, i686), where the 64-bit product doesn't fit in `usize`.
const MAX_SAMPLES: usize = if usize::BITS >= 64 { (3600u64 * 192_000 * 8) as usize } else { 64 << 20 };
impl Recorder {
/// Open the default input device. Errors when there is none (the caller shows a message).
+9
View File
@@ -0,0 +1,9 @@
# Release playbook
The release playbook shared by every crafting app lives in craftrules:
[`../craftrules/release/playbook.md`](../../craftrules/release/playbook.md)
(`storytold/craftrules`). It is the canonical copy; don't duplicate it here.
SoundCraft's implementation (`.github/workflows/release.yml`, `packaging/`,
`cargo xtask version`) was copied from DesignCraft/PhotoCraft and renamed. SoundCraft-specific
details are in [releasing.md](releasing.md).
+201
View File
@@ -0,0 +1,201 @@
# Releasing SoundCraft
Every push to the `release` branch runs `.github/workflows/release.yml`. The workflow builds
signed installers for macOS, Windows, Linux, FreeBSD and the web, then creates or updates a
**draft** GitHub Release named `SoundCraft v<version>`. Nobody sees a draft until a maintainer
publishes it.
This is SoundCraft's implementation of the shared [release playbook](release-playbook.md).
User-facing names say **SoundCraft**. Files, binaries and ids stay lowercase
(`soundcraft-<version>-<platform>-<arch>.<ext>`, `ai.storyteller.soundcraft`).
## Cutting a release
1. **Bump the version** on `main`. The only place it lives is `[workspace.package] version` in
the root `Cargo.toml`; the app reads it from `CARGO_PKG_VERSION` (*About SoundCraft*,
`soundcraft --version`, `soundcraft-cli --version`).
```sh
cargo xtask version # prints the current version, e.g. 0.1.0
cargo xtask version set 0.2.0 # or 0.2.0-rc.1; updates Cargo.toml and Cargo.lock
```
Commit the change (`Cargo.toml` + `Cargo.lock`) through the normal review flow.
2. **Merge `main` into `release`** (or fast-forward it) and push. The workflow starts by itself.
3. **Wait for the draft.** After about 30 to 60 minutes (notarization and the FreeBSD VM are the
slow parts), the Releases page has a draft `SoundCraft v0.2.0`, tagged `v0.2.0` on the pushed
commit, with every artifact and `SHA256SUMS.txt`. The notes are generated from the merged PRs.
4. **Check it.** Download an installer or two and look at the job summaries. Any `::warning::`
there means a signing secret was missing and that artifact is unsigned.
5. **Publish** the draft in the GitHub UI. Publishing creates the `v0.2.0` tag. Versions with a
pre-release suffix (`-rc.1`) are marked as pre-releases.
Pushing to `release` again before you publish rebuilds the same draft and replaces its assets.
After the draft is published, the workflow refuses to touch that version again, so bump it first.
**Test runs:** *Actions → Release → Run workflow* runs the whole pipeline by hand. The optional
`version` input (such as `0.2.0-rc.1`) overrides `Cargo.toml` for that run only. The jobs apply it
with `cargo xtask version set` before building, so the binaries report it too. The run still needs
the `release` environment, which only the `release` branch can use, so pick that branch in the
dialog.
## What gets built
| Platform | Artifacts | Built on |
|---|---|---|
| macOS 11+ (universal: Apple silicon + Intel) | `soundcraft-<v>-macos-universal.dmg`, `soundcraft-cli-<v>-macos-universal.zip` | `macos-15` |
| Windows 10+ x64 | `soundcraft-<v>-windows-x64.msi`, `soundcraft-<v>-windows-x64-portable.zip` | `windows-latest` |
| Windows 10+ x86 (32-bit) | `soundcraft-<v>-windows-x86.msi`, `soundcraft-<v>-windows-x86-portable.zip` | `windows-latest` |
| Windows 11 ARM64 | `soundcraft-<v>-windows-arm64.msi`, `soundcraft-<v>-windows-arm64-portable.zip` | `windows-latest` (cross-compiled; `windows-arm64.yml` installs and runs it on ARM64) |
| Linux x86_64 | `soundcraft-<v>-linux-x86_64.{AppImage,deb,rpm,tar.gz}` | `ubuntu-22.04` |
| Linux aarch64 | `soundcraft-<v>-linux-aarch64.{AppImage,deb,rpm,tar.gz}` | `ubuntu-22.04-arm` |
| FreeBSD 14 x86_64 | `soundcraft-<v>-freebsd-x86_64.tar.gz` (a `/usr/local` tree) | FreeBSD 14.3 VM on `ubuntu-latest` |
| Web | `soundcraft-web-<v>.zip` (static site; see [`packaging/web/README.md`](../packaging/web/README.md)) | `ubuntu-latest` |
The Flatpak manifest (`packaging/linux/flatpak/ai.storyteller.soundcraft.yml`) is kept ready for a
Flathub submission and validated in CI, but not built there (see Linux below).
### Audio libraries
SoundCraft plays and records through [cpal](https://github.com/RustAudio/cpal): CoreAudio on
macOS, WASAPI on Windows, ALSA on Linux and FreeBSD (PulseAudio and PipeWire through their ALSA
plugins), and WebAudio in the browser.
- **Linux** builds need the ALSA headers (`libasound2-dev` and `pkg-config` on Ubuntu). The binary
links `libasound.so.2`, so the `.deb` depends on `libasound2 | libasound2t64` and the `.rpm` on
`alsa-lib`; the AppImage and tarball expect it on the host (every desktop distro has it).
- **FreeBSD** builds need `alsa-lib` (`pkg install alsa-lib`); at runtime `alsa-plugins-oss`
routes ALSA to the OSS `sound(4)` devices. Without a device the player falls back to its
silent clock, so the transport still runs.
- **Flatpak**: alsa-lib and its PulseAudio plugin come with the freedesktop runtime; the
manifest grants `--socket=pulseaudio`.
- **Web**: browsers start audio only after a user gesture, so the first click in the page may be
needed before sound plays. The web build has no recording.
### macOS
`packaging/macos/package.sh` builds `aarch64-apple-darwin` and `x86_64-apple-darwin` with
`MACOSX_DEPLOYMENT_TARGET=11.0`, joins them with `lipo`, and assembles `SoundCraft.app`:
- `Info.plist` is generated from `Info.plist.in`. The bundle id is `ai.storyteller.soundcraft`.
It declares the `.scraft` session type (`ai.storyteller.soundcraft.session`, MIME
`application/x-soundcraft-session`, role Editor, rank Owner) and lists WAV, AIFF/AIFC, FLAC,
MP3 and MIDI as Viewer/Alternate, so SoundCraft appears under *Open With* without taking over
the default player. `NSMicrophoneUsageDescription` explains the recording prompt.
- **Signing** goes inside-out with the hardened runtime and a secure timestamp. The executable is
signed first, then the bundle. There's no `--deep` on the final signature. `entitlements.plist`
holds a single exception, `com.apple.security.device.audio-input`, without which the hardened
runtime blocks recording.
- **Notarization:** the app is zipped and sent with `xcrun notarytool submit --wait`, then the
ticket is stapled to the app. The app goes on a DMG (`hdiutil`, with an `Applications` link).
The DMG is signed, notarized and stapled too.
- **CLI:** the universal `soundcraft-cli` is signed with the hardened runtime, zipped and
notarized.
Locally, without certificates, the script signs ad-hoc (`codesign -s -`) and skips notarization:
```sh
packaging/macos/package.sh # universal; needs both rustup targets
packaging/macos/package.sh --arch aarch64 # quicker, host-only
open dist/release/soundcraft-*-macos-*.dmg
```
### Windows
`packaging/windows/package.ps1 -Arch x64|x86|arm64` builds with `-C target-feature=+crt-static`
(in `CARGO_TARGET_<TRIPLE>_RUSTFLAGS`, so host build scripts aren't affected), so neither the MSI
nor the portable zip needs the Visual C++ redistributable.
- `apps/soundcraft/build.rs` embeds the icon (`assets/app-icon/soundcraft.ico`) and VERSIONINFO
with `winresource`, only when targeting Windows. Release packaging sets
`SOUNDCRAFT_REQUIRE_WINRES=1` so a missing resource compiler fails the build.
- The script checks both PE headers: the machine type matches `-Arch`, `soundcraft.exe` is a GUI
app (no console window) and `soundcraft-cli.exe` a console app.
- `soundcraft.wxs` (WiX v5) is a per-machine install into Program Files with an advertised Start
Menu shortcut. SoundCraft becomes the default app for `.scraft` and is listed under *Open with*
for `.wav`, `.aif`, `.aiff`, `.flac`, `.mp3`, `.mid` and `.midi`. It also registers App Paths
(Win+R `soundcraft`). The MSI version is the numeric `X.Y.Z`; same-version upgrades let release
candidates replace each other. The upgrade code `EE53D3E5-D0AE-40F9-808D-9417FDEBF61A` is fixed
forever.
- **Signing:** `packaging/windows/sign.ps1` signs both `.exe` files and then the `.msi` with
`signtool` (SHA-256, RFC 3161 timestamp), using a `.pfx` (`WINDOWS_CERTIFICATE*`) if present,
otherwise Azure Trusted Signing (all six `AZURE_*`), otherwise nothing (a warning).
Locally on Windows: `dotnet tool install -g wix --version 5.0.2`, then
`pwsh packaging/windows/package.ps1 -Arch x64`.
### Linux
`packaging/linux/package.sh` stages one FHS tree and makes every format from it: both binaries,
`ai.storyteller.soundcraft.desktop` (with `MimeType=` for sessions, WAV, AIFF, FLAC, MP3 and
MIDI), hicolor icons from 16 px to 512 px plus a scalable SVG, AppStream metainfo, and a
shared-mime-info file declaring `application/x-soundcraft-session` (`*.scraft`).
- **AppImage** (maintained `AppImage/appimagetool`, static runtime: no libfuse2 needed).
- **.deb** and **.rpm** by [nfpm](https://nfpm.goreleaser.com/) from one `nfpm.yaml`; the
`postinst.sh` hook refreshes the desktop, MIME and icon caches.
- **.tar.gz** for people who manage their own `/opt` or `~/.local`.
- **Flatpak**: `packaging/linux/flatpak/ai.storyteller.soundcraft.yml` builds from source on the
freedesktop 25.08 runtime with Wayland/X11, `dri`, PulseAudio and `xdg-music`; other files go
through the portals. A real build needs vendored crates (`cargo-sources.json` from
`flatpak-cargo-generator.py`); the commands are in the manifest's header.
Binaries are built on Ubuntu 22.04 and need **glibc ≥ 2.35**. X11, Wayland, xkbcommon, Vulkan
and EGL are loaded at runtime, so the packages declare them as dependencies or recommendations.
Locally (on Linux): install nfpm, then `packaging/linux/package.sh` (or `--formats "deb tar"`).
### FreeBSD
GitHub has no FreeBSD runners, so the `freebsd` job builds inside a FreeBSD 14.3 VM
(`vmactions/freebsd-vm`, pinned by commit) and runs `packaging/freebsd/package.sh`, which stages
a `/usr/local`-style tree (binaries, desktop entry, MIME type, metainfo, icons, docs) as a
`.tar.gz`. Install with `tar -xzf soundcraft-*-freebsd-x86_64.tar.gz --strip-components 1 -C /usr/local`.
`.github/workflows/freebsd.yml` builds and tests the workspace there on every push to `main`.
### Web
`packaging/web/package.sh` runs `trunk build --release` in `apps/soundcraft-web` (output in
`dist/web`, `public_url = "./"`) and zips the site with sample `_headers` and `.htaccess` files and
the hosting guide. `apps/soundcraft-web` runs the same `SoundApp` as the desktop app on the
synthesised demo session, rendering with wgpu (WebGPU, or WebGL2 with `?webgl`) and playing
through WebAudio.
## Secrets
All secrets live in the repository's **`release` environment** (*Settings → Environments →
release*), restricted to the `release` branch. Every job in `release.yml` declares
`environment: release`. Each secret is optional: if one is missing, that platform's artifacts
are unsigned and the run shows a `::warning::`. Scripts never echo secret values; certificates
are written with `umask 077` and deleted in `always()`/`finally` steps.
| Secret | Used for |
|---|---|
| `APPLE_CERTIFICATE` | base64 `.p12` with "Developer ID Application: Learning Machines LLC (DJ6XS33FX8)" |
| `APPLE_CERTIFICATE_PASSWORD` | password for that `.p12` |
| `KEYCHAIN_PASSWORD` | password for the temporary CI keychain (random if unset) |
| `APPLE_ID` | Apple ID used by `notarytool` |
| `APPLE_PASSWORD` | app-specific password for that Apple ID |
| `APPLE_TEAM_ID` | `DJ6XS33FX8` |
| `WINDOWS_CERTIFICATE` / `WINDOWS_CERTIFICATE_PASSWORD` | base64 `.pfx` (unused: Windows uses Azure) |
| `AZURE_TENANT_ID`, `AZURE_CLIENT_ID`, `AZURE_CLIENT_SECRET` | service principal for Azure Trusted Signing |
| `AZURE_SIGNING_ENDPOINT`, `AZURE_SIGNING_ACCOUNT`, `AZURE_CERT_PROFILE` | Trusted Signing endpoint, account and profile |
`GITHUB_TOKEN` creates the release. Only the final job gets `contents: write`. Provisioning the
environment, ruleset and secrets for a new repo: craftrules `release/signing-setup.md`.
## Icons
`assets/app-icon/icon.svg` is the canonical icon: a **placeholder** singing nightingale, drawn as
original SVG in the three-colour icon palette (ink, paper, SoundCraft teal `#14a9c4`); see
`assets/app-icon/README.md`. `packaging/icons.sh` regenerates the 1024 px PNG, the macOS 512 px
window icon, the `.icns`, the `.ico` (packed by `cargo xtask ico`) and the hicolor PNGs from it.
It needs `resvg`, plus `iconutil` on macOS. The outputs are committed, so packaging never needs
those tools. `apps/soundcraft/src/main.rs` sets the runtime window/Dock/taskbar icon from them.
## Checks
`.github/workflows/packaging-lint.yml` runs in seconds on any change to `packaging/`, the
workflows or the icons: actionlint, shellcheck, a PowerShell parse, xmllint (WiX, plists,
AppStream, MIME), `desktop-file-validate`, `appstreamcli validate`, and a YAML check of the
Flatpak manifest.
+3
View File
@@ -0,0 +1,3 @@
# shellcheck config for the packaging scripts (CI: .github/workflows/packaging-lint.yml).
source-path=SCRIPTDIR
external-sources=true
+59
View File
@@ -0,0 +1,59 @@
# shellcheck shell=bash
# Shared setup for the packaging scripts. Source it: `. "$(dirname "$0")/../env.sh"`.
#
# Exports:
# ROOT workspace root
# VERSION [workspace.package] version from Cargo.toml (override: SOUNDCRAFT_VERSION)
# DIST output directory for release artifacts (default: $ROOT/dist/release)
# SOUNDCRAFT_BUILD_SHA git commit (recorded in the macOS Info.plist and the job summary)
# SOUNDCRAFT_BUILD_DATE UTC build date, YYYY-MM-DD (AppStream release date)
# CARGO_TARGET_DIR cargo's target dir (default: $ROOT/target)
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
export ROOT
# The version lives in exactly one place: `[workspace.package] version` in the root Cargo.toml.
# (`cargo xtask version` prints the same thing; awk avoids compiling xtask here.)
workspace_version() {
awk '
/^\[/ { in_pkg = ($0 == "[workspace.package]") ; next }
in_pkg && $1 == "version" { gsub(/[" ]/, "", $3); print $3; exit }
' "$ROOT/Cargo.toml"
}
VERSION="${SOUNDCRAFT_VERSION:-$(workspace_version)}"
if [ -z "$VERSION" ]; then
echo "error: could not read [workspace.package] version from $ROOT/Cargo.toml" >&2
exit 1
fi
export VERSION
DIST="${DIST:-$ROOT/dist/release}"
mkdir -p "$DIST"
export DIST
if [ -z "${SOUNDCRAFT_BUILD_SHA:-}" ]; then
SOUNDCRAFT_BUILD_SHA="$(git -C "$ROOT" rev-parse HEAD 2>/dev/null || true)"
fi
export SOUNDCRAFT_BUILD_SHA
export SOUNDCRAFT_BUILD_DATE="${SOUNDCRAFT_BUILD_DATE:-$(date -u +%Y-%m-%d)}"
export CARGO_TARGET_DIR="${CARGO_TARGET_DIR:-$ROOT/target}"
# Emit a GitHub Actions warning (plain stderr outside Actions).
warn() {
if [ -n "${GITHUB_ACTIONS:-}" ]; then echo "::warning::$*"; else echo "warning: $*" >&2; fi
}
# Copy licence and readme files that exist into a package directory.
copy_docs() {
local dest="$1" f
for f in README.md LICENSE LICENSE-MIT LICENSE-APACHE NOTICE ATTRIBUTION.md COPYRIGHT; do
if [ -f "$ROOT/$f" ]; then cp "$ROOT/$f" "$dest/"; fi
done
}
# Portable SHA-256 of a file (prints just the hash).
sha256() {
if command -v sha256sum >/dev/null; then sha256sum "$1" | cut -d' ' -f1; else shasum -a 256 "$1" | cut -d' ' -f1; fi
}
+68
View File
@@ -0,0 +1,68 @@
#!/usr/bin/env bash
# Build and package SoundCraft for FreeBSD:
#
# $DIST/soundcraft-<version>-freebsd-x86_64.tar.gz a /usr/local-style tree:
# soundcraft-<version>-freebsd-x86_64/{bin, share/applications, share/icons, share/mime,
# share/metainfo, share/doc/soundcraft}
#
# Install by copying the tree's contents into /usr/local, e.g.
# tar -xzf soundcraft-*-freebsd-x86_64.tar.gz --strip-components 1 -C /usr/local
#
# Usage: packaging/freebsd/package.sh [--skip-build]
#
# Needs: bash, cargo and the build dependencies listed in .github/workflows/freebsd.yml (alsa-lib for
# cpal's audio I/O; at runtime alsa-plugins-oss routes ALSA to FreeBSD's OSS sound(4)). FreeBSD's
# install(1) has no -D, so directories are created first.
set -euo pipefail
# shellcheck source=../env.sh
. "$(dirname "${BASH_SOURCE[0]}")/../env.sh"
LINUX="$ROOT/packaging/linux"
APP_ID=ai.storyteller.soundcraft
SKIP_BUILD=0
while [ $# -gt 0 ]; do
case "$1" in
--skip-build) SKIP_BUILD=1; shift ;;
-h | --help) sed -n '2,15p' "$0"; exit 0 ;;
*) echo "unknown argument: $1" >&2; exit 2 ;;
esac
done
# FreeBSD calls x86_64 "amd64"; release names use the Rust/Linux spelling.
case "$(uname -m)" in
amd64 | x86_64) ARCH=x86_64 ;;
arm64 | aarch64) ARCH=aarch64 ;;
*) echo "unsupported architecture $(uname -m)" >&2; exit 2 ;;
esac
BASENAME="soundcraft-$VERSION-freebsd-$ARCH"
echo "==> SoundCraft $VERSION for FreeBSD $ARCH"
if [ "$SKIP_BUILD" = 0 ]; then
(cd "$ROOT" && cargo build --release --locked -p soundcraft -p soundcraft-cli)
fi
BIN="$CARGO_TARGET_DIR/release"
WORK="$CARGO_TARGET_DIR/freebsd-package"
STAGE="$WORK/$BASENAME"
rm -rf "$WORK"
# ---- stage a /usr/local-style tree ---------------------------------------------------------------
mkdir -p "$STAGE/bin" "$STAGE/share/applications" "$STAGE/share/mime/packages" "$STAGE/share/metainfo" \
"$STAGE/share/icons" "$STAGE/share/doc/soundcraft"
install -m 755 "$BIN/soundcraft" "$BIN/soundcraft-cli" "$STAGE/bin/"
strip "$STAGE/bin/soundcraft" "$STAGE/bin/soundcraft-cli" 2>/dev/null || true
# The desktop entry, MIME type and metainfo are the freedesktop files the Linux packages use.
install -m 644 "$LINUX/$APP_ID.desktop" "$STAGE/share/applications/$APP_ID.desktop"
install -m 644 "$LINUX/$APP_ID.mime.xml" "$STAGE/share/mime/packages/$APP_ID.xml"
sed -e "s/@VERSION@/$VERSION/g" -e "s/@DATE@/$SOUNDCRAFT_BUILD_DATE/g" \
"$LINUX/$APP_ID.metainfo.xml.in" >"$STAGE/share/metainfo/$APP_ID.metainfo.xml"
cp -R "$ROOT/assets/app-icon/hicolor" "$STAGE/share/icons/"
copy_docs "$STAGE/share/doc/soundcraft"
# ---- .tar.gz -------------------------------------------------------------------------------------
tar -C "$WORK" -czf "$DIST/$BASENAME.tar.gz" "$BASENAME"
echo "wrote $DIST/$BASENAME.tar.gz"
"$STAGE/bin/soundcraft-cli" --version
echo "==> done"
ls -lh "$DIST"
+58
View File
@@ -0,0 +1,58 @@
#!/usr/bin/env bash
# Regenerate every app icon from assets/app-icon/icon.svg.
#
# Needs: resvg (brew install resvg / cargo install resvg). On macOS, iconutil also writes the
# .icns. The outputs are committed, so builds and packaging never need these tools.
#
# packaging/icons.sh
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
DIR="$ROOT/assets/app-icon"
SVG="$DIR/icon.svg"
ID="ai.storyteller.soundcraft"
TMP="$(mktemp -d)"
trap 'rm -rf "$TMP"' EXIT
command -v resvg >/dev/null || { echo "error: resvg not found (brew install resvg)" >&2; exit 1; }
# The artwork is a full-bleed 512 tile (rx=112). Windows and Linux use it as is. macOS icons sit on
# Apple's grid: an 824/1024 body with a transparent margin, so widen the viewBox to 512*1024/824.
MAC="$TMP/mac.svg"
sed 's/viewBox="0 0 512 512"/viewBox="-62.136 -62.136 636.272 636.272"/' "$SVG" >"$MAC"
grep -q 'viewBox="-62.136' "$MAC" || { echo "error: expected viewBox=\"0 0 512 512\" in $SVG" >&2; exit 1; }
render() { resvg -w "$2" -h "$2" "$1" "$3" </dev/null; }
render "$SVG" 1024 "$DIR/soundcraft-1024.png"
# Runtime window/Dock icon on macOS (embedded by apps/soundcraft); other platforms use hicolor 256.
render "$MAC" 512 "$DIR/soundcraft-macos-512.png"
# Linux hicolor theme.
for s in 16 24 32 48 64 128 256 512; do
mkdir -p "$DIR/hicolor/${s}x${s}/apps"
render "$SVG" "$s" "$DIR/hicolor/${s}x${s}/apps/$ID.png"
done
mkdir -p "$DIR/hicolor/scalable/apps"
cp "$SVG" "$DIR/hicolor/scalable/apps/$ID.svg"
# Windows .ico.
ICO_PNGS=()
for s in 16 20 24 32 40 48 64 128 256; do
render "$SVG" "$s" "$TMP/ico-$s.png"
ICO_PNGS+=("$TMP/ico-$s.png")
done
(cd "$ROOT" && cargo run -q -p xtask -- ico "$DIR/soundcraft.ico" "${ICO_PNGS[@]}")
# macOS .icns.
if command -v iconutil >/dev/null; then
SET="$TMP/soundcraft.iconset"
mkdir -p "$SET"
for s in 16 32 128 256 512; do
render "$MAC" "$s" "$SET/icon_${s}x${s}.png"
render "$MAC" $((s * 2)) "$SET/icon_${s}x${s}@2x.png"
done
iconutil -c icns -o "$DIR/soundcraft.icns" "$SET"
else
echo "warning: iconutil not found (macOS only); soundcraft.icns not regenerated" >&2
fi
echo "icons written to $DIR"
@@ -0,0 +1,14 @@
[Desktop Entry]
Type=Application
Name=SoundCraft
GenericName=Digital Audio Workstation
Comment=Record, edit and mix audio and MIDI
Exec=soundcraft %F
TryExec=soundcraft
Icon=ai.storyteller.soundcraft
Terminal=false
StartupNotify=true
StartupWMClass=ai.storyteller.soundcraft
Categories=AudioVideo;Audio;Midi;Mixer;Sequencer;Recorder;
MimeType=application/x-soundcraft-session;audio/x-wav;audio/wav;audio/vnd.wave;audio/x-aiff;audio/aiff;audio/flac;audio/x-flac;audio/mpeg;audio/midi;audio/x-midi;
Keywords=daw;audio;mixer;recording;multitrack;midi;editor;music;
@@ -0,0 +1,63 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- AppStream metadata (software centres, Flathub). Template: package.sh and the Flatpak
manifest substitute @VERSION@ and @DATE@ from Cargo.toml and the build date. -->
<component type="desktop-application">
<id>ai.storyteller.soundcraft</id>
<metadata_license>CC0-1.0</metadata_license>
<project_license>MIT OR Apache-2.0</project_license>
<name>SoundCraft</name>
<summary>Record, edit and mix audio and MIDI</summary>
<developer id="ai.storyteller">
<name>Learning Machines LLC</name>
</developer>
<description>
<p>
SoundCraft is an open-source, native digital audio workstation. Record, edit and mix
multitrack audio and MIDI in Edit and Mix windows that work the way studio engineers expect.
</p>
<ul>
<li>Non-destructive clip editing with fades, playlists, grids and markers</li>
<li>A mixer with inserts, sends, buses, automation and metering</li>
<li>Built-in EQ, dynamics, reverb, delay, modulation and instruments</li>
<li>Imports WAV, AIFF, FLAC, MP3 and MIDI; bounces to disk</li>
<li>A headless command line and an MCP server for scripting and agents</li>
</ul>
</description>
<launchable type="desktop-id">ai.storyteller.soundcraft.desktop</launchable>
<icon type="stock">ai.storyteller.soundcraft</icon>
<!-- Only url types known to appstream 0.15 (Ubuntu 22.04, where CI validates): no vcs-browser. -->
<url type="homepage">https://getartcraft.com/apps/soundcraft</url>
<url type="bugtracker">https://github.com/storytold/soundcraft/issues</url>
<url type="help">https://github.com/storytold/soundcraft</url>
<url type="contact">https://discord.gg/artcraft</url>
<categories>
<category>AudioVideo</category>
<category>Audio</category>
</categories>
<keywords>
<keyword>daw</keyword>
<keyword>audio</keyword>
<keyword>mixer</keyword>
<keyword>recording</keyword>
<keyword>midi</keyword>
</keywords>
<branding>
<color type="primary" scheme_preference="light">#8fd9e6</color>
<color type="primary" scheme_preference="dark">#0b7385</color>
</branding>
<requires>
<display_length compare="ge">760</display_length>
</requires>
<supports>
<control>keyboard</control>
<control>pointing</control>
</supports>
<provides>
<binary>soundcraft</binary>
<mediatype>application/x-soundcraft-session</mediatype>
</provides>
<content_rating type="oars-1.1"/>
<releases>
<release version="@VERSION@" date="@DATE@"/>
</releases>
</component>
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- shared-mime-info definition for SoundCraft's session format. Audio and MIDI types come from
the system database. Installed to /usr/share/mime/packages/ (update-mime-database runs on
install). -->
<mime-info xmlns="http://www.freedesktop.org/standards/shared-mime-info">
<mime-type type="application/x-soundcraft-session">
<comment>SoundCraft session</comment>
<sub-class-of type="application/json"/>
<icon name="ai.storyteller.soundcraft"/>
<glob pattern="*.scraft"/>
</mime-type>
</mime-info>
@@ -0,0 +1,64 @@
# Flatpak manifest for SoundCraft, ready for a Flathub submission.
#
# Flatpak builds run offline, so the crate sources must be vendored first. From the repo root:
#
# curl -fsSLO https://raw.githubusercontent.com/flatpak/flatpak-builder-tools/master/cargo/flatpak-cargo-generator.py
# python3 flatpak-cargo-generator.py Cargo.lock -o packaging/linux/flatpak/cargo-sources.json
# flatpak-builder --user --install --force-clean build-flatpak packaging/linux/flatpak/ai.storyteller.soundcraft.yml
# flatpak run ai.storyteller.soundcraft
#
# For Flathub, replace the `dir` source with a `git` source pinned to a release tag and commit.
#
# Audio: cpal talks to ALSA. alsa-lib (headers in the Sdk, the library in the Platform) and its
# PulseAudio plugin ship with the freedesktop runtime, which routes ALSA through the PulseAudio
# socket (PipeWire's pulse server on modern desktops), so no extra module is needed.
id: ai.storyteller.soundcraft
runtime: org.freedesktop.Platform
runtime-version: '25.08'
sdk: org.freedesktop.Sdk
sdk-extensions:
- org.freedesktop.Sdk.Extension.rust-stable
command: soundcraft
finish-args:
- --share=ipc
- --socket=wayland
- --socket=fallback-x11
- --device=dri
# Playback and recording (ALSA through the PulseAudio/PipeWire socket).
- --socket=pulseaudio
# Open/Save dialogs use the file-chooser portal (rfd), and drag-and-drop uses the document
# portal, so broad filesystem access isn't needed. Music is granted for sessions and their
# "Audio Files" folders opened by path from the command line.
- --filesystem=xdg-music
build-options:
append-path: /usr/lib/sdk/rust-stable/bin
modules:
- name: soundcraft
buildsystem: simple
build-options:
env:
CARGO_HOME: /run/build/soundcraft/cargo
CARGO_NET_OFFLINE: 'true'
build-commands:
- cargo --offline build --release --locked -p soundcraft -p soundcraft-cli
- install -Dm755 target/release/soundcraft /app/bin/soundcraft
- install -Dm755 target/release/soundcraft-cli /app/bin/soundcraft-cli
- install -Dm644 packaging/linux/ai.storyteller.soundcraft.desktop /app/share/applications/ai.storyteller.soundcraft.desktop
- install -Dm644 packaging/linux/ai.storyteller.soundcraft.mime.xml /app/share/mime/packages/ai.storyteller.soundcraft.xml
- mkdir -p /app/share/icons && cp -R assets/app-icon/hicolor /app/share/icons/
- >-
version=$(awk '/^\[/{p=($0=="[workspace.package]");next} p&&$1=="version"{gsub(/[" ]/,"",$3);print $3;exit}' Cargo.toml);
install -d /app/share/metainfo;
sed -e "s/@VERSION@/$version/" -e "s/@DATE@/$(date -u +%Y-%m-%d)/"
packaging/linux/ai.storyteller.soundcraft.metainfo.xml.in > /app/share/metainfo/ai.storyteller.soundcraft.metainfo.xml
sources:
- type: dir
path: ../../..
skip:
- target
- dist
- corpus
- plan
- build-flatpak
- .flatpak-builder
- cargo-sources.json
+75
View File
@@ -0,0 +1,75 @@
# .deb and .rpm packages (nfpm: https://nfpm.goreleaser.com). Run by package.sh, which stages
# the file tree, substitutes its path for ${STAGE} (nfpm doesn't expand env vars in `src`) and
# exports VERSION and NFPM_ARCH.
#
# nfpm package -f packaging/linux/nfpm.yaml -p deb -t dist/release/
name: soundcraft
arch: ${NFPM_ARCH}
platform: linux
version: ${VERSION}
# semver: 0.2.0-rc.1 becomes 0.2.0~rc.1 in deb/rpm, so pre-releases sort before the release.
version_schema: semver
release: 1
section: sound
priority: optional
maintainer: ${SOUNDCRAFT_MAINTAINER}
vendor: Learning Machines LLC
homepage: https://getartcraft.com/apps/soundcraft
license: MIT OR Apache-2.0
description: |-
Open-source, native digital audio workstation. Record, edit and mix
multitrack audio and MIDI in Edit and Mix windows; built-in EQ, dynamics,
reverb, delay and instruments; imports WAV, AIFF, FLAC, MP3 and MIDI.
Includes soundcraft-cli, a headless editor, bounce tool and MCP server.
# The app links glibc, libgcc_s and ALSA (libasound, for audio I/O through cpal; PulseAudio and
# PipeWire are reached through their ALSA plugins). Windowing (X11/Wayland, xkbcommon) and the GPU
# (Vulkan, EGL/GL) are loaded at runtime, so they are dependencies/recommendations here rather
# than ELF NEEDED entries.
overrides:
deb:
depends:
- libc6 (>= 2.35)
- libgcc-s1
- libasound2 | libasound2t64
- libxkbcommon0
- libvulkan1 | libegl1
recommends:
- libwayland-client0
- libx11-6
- libx11-xcb1
- libxcursor1
- libxi6
- libxrandr2
- mesa-vulkan-drivers
- xdg-desktop-portal
- pipewire-alsa | pulseaudio
rpm:
depends:
- alsa-lib
- libxkbcommon
- (vulkan-loader or libglvnd-egl)
recommends:
- libwayland-client
- libX11
- libXcursor
- libXi
- libXrandr
- mesa-vulkan-drivers
- xdg-desktop-portal
- (pipewire-alsa or alsa-plugins-pulseaudio)
contents:
- src: ${STAGE}/usr/bin/soundcraft
dst: /usr/bin/soundcraft
file_info: { mode: 0755 }
- src: ${STAGE}/usr/bin/soundcraft-cli
dst: /usr/bin/soundcraft-cli
file_info: { mode: 0755 }
- src: ${STAGE}/usr/share/
dst: /usr/share/
type: tree
scripts:
postinstall: packaging/linux/postinst.sh
postremove: packaging/linux/postinst.sh
+120
View File
@@ -0,0 +1,120 @@
#!/usr/bin/env bash
# Build and package SoundCraft for Linux (<arch> is x86_64 or aarch64):
#
# $DIST/soundcraft-<version>-linux-<arch>.AppImage any distro with glibc >= the build host's
# $DIST/soundcraft-<version>-linux-<arch>.deb Debian, Ubuntu, Mint, Pop!_OS, ...
# $DIST/soundcraft-<version>-linux-<arch>.rpm Fedora, openSUSE, RHEL, ...
# $DIST/soundcraft-<version>-linux-<arch>.tar.gz plain FHS-style tree (bin/, share/)
#
# Usage: packaging/linux/package.sh [--skip-build] [--formats "appimage deb rpm tar"]
#
# Needs: cargo; nfpm for deb/rpm (https://nfpm.goreleaser.com); appimagetool for the AppImage
# (downloaded into $CARGO_TARGET_DIR if missing). Build on an old distro (CI: Ubuntu 22.04,
# glibc 2.35) so the binaries run on newer ones. Optional: desktop-file-validate, appstreamcli.
set -euo pipefail
# shellcheck source=../env.sh
. "$(dirname "${BASH_SOURCE[0]}")/../env.sh"
HERE="$ROOT/packaging/linux"
APP_ID=ai.storyteller.soundcraft
SKIP_BUILD=0
FORMATS="appimage deb rpm tar"
while [ $# -gt 0 ]; do
case "$1" in
--skip-build) SKIP_BUILD=1; shift ;;
--formats) FORMATS="$2"; shift 2 ;;
-h | --help) sed -n '2,15p' "$0"; exit 0 ;;
*) echo "unknown argument: $1" >&2; exit 2 ;;
esac
done
ARCH="$(uname -m)"
case "$ARCH" in
x86_64) DEB_ARCH=amd64 ;;
aarch64 | arm64) ARCH=aarch64; DEB_ARCH=arm64 ;;
*) echo "unsupported architecture $ARCH" >&2; exit 2 ;;
esac
export SOUNDCRAFT_MAINTAINER="${SOUNDCRAFT_MAINTAINER:-SoundCraft maintainers <[email protected]>}"
BASENAME="soundcraft-$VERSION-linux-$ARCH"
echo "==> SoundCraft $VERSION for Linux $ARCH ($FORMATS)"
if [ "$SKIP_BUILD" = 0 ]; then
(cd "$ROOT" && cargo build --release --locked -p soundcraft -p soundcraft-cli)
fi
BIN="$CARGO_TARGET_DIR/release"
WORK="$CARGO_TARGET_DIR/linux-package"
STAGE="$WORK/root"
rm -rf "$WORK"
# ---- stage an FHS tree (shared by every format) -------------------------------------------------
install -Dm755 "$BIN/soundcraft" "$STAGE/usr/bin/soundcraft"
install -Dm755 "$BIN/soundcraft-cli" "$STAGE/usr/bin/soundcraft-cli"
strip "$STAGE/usr/bin/soundcraft" "$STAGE/usr/bin/soundcraft-cli" 2>/dev/null || true
install -Dm644 "$HERE/$APP_ID.desktop" "$STAGE/usr/share/applications/$APP_ID.desktop"
install -Dm644 "$HERE/$APP_ID.mime.xml" "$STAGE/usr/share/mime/packages/$APP_ID.xml"
mkdir -p "$STAGE/usr/share/metainfo"
sed -e "s/@VERSION@/$VERSION/g" -e "s/@DATE@/$SOUNDCRAFT_BUILD_DATE/g" \
"$HERE/$APP_ID.metainfo.xml.in" >"$STAGE/usr/share/metainfo/$APP_ID.metainfo.xml"
mkdir -p "$STAGE/usr/share/icons"
cp -R "$ROOT/assets/app-icon/hicolor" "$STAGE/usr/share/icons/"
mkdir -p "$STAGE/usr/share/doc/soundcraft"
copy_docs "$STAGE/usr/share/doc/soundcraft"
if command -v desktop-file-validate >/dev/null; then
desktop-file-validate "$STAGE/usr/share/applications/$APP_ID.desktop"
fi
if command -v appstreamcli >/dev/null; then
appstreamcli validate --no-net --explain "$STAGE/usr/share/metainfo/$APP_ID.metainfo.xml"
fi
has() { case " $FORMATS " in *" $1 "*) return 0 ;; *) return 1 ;; esac; }
# ---- .tar.gz ------------------------------------------------------------------------------------
if has tar; then
mkdir -p "$WORK/tar"
cp -R "$STAGE/usr" "$WORK/tar/$BASENAME"
tar -C "$WORK/tar" -czf "$DIST/$BASENAME.tar.gz" "$BASENAME"
echo "wrote $DIST/$BASENAME.tar.gz"
fi
# ---- .deb / .rpm --------------------------------------------------------------------------------
if has deb || has rpm; then
command -v nfpm >/dev/null || { echo "error: nfpm not found (https://nfpm.goreleaser.com/install/)" >&2; exit 1; }
export VERSION
export NFPM_ARCH="$DEB_ARCH"
# nfpm expands env vars in fields like `version` and `arch`, but not in `contents[].src`.
sed "s|\${STAGE}|$STAGE|g" "$HERE/nfpm.yaml" >"$WORK/nfpm.yaml"
for fmt in deb rpm; do
if has "$fmt"; then (cd "$ROOT" && nfpm package -f "$WORK/nfpm.yaml" -p "$fmt" -t "$DIST/$BASENAME.$fmt"); fi
done
fi
# ---- AppImage -----------------------------------------------------------------------------------
if has appimage; then
APPDIR="$WORK/SoundCraft.AppDir"
cp -R "$STAGE" "$APPDIR"
mv "$APPDIR/usr/share/doc" "$WORK/doc-unused"
ln -s usr/bin/soundcraft "$APPDIR/AppRun"
cp "$HERE/$APP_ID.desktop" "$APPDIR/$APP_ID.desktop"
cp "$ROOT/assets/app-icon/hicolor/256x256/apps/$APP_ID.png" "$APPDIR/$APP_ID.png"
ln -s "$APP_ID.png" "$APPDIR/.DirIcon"
TOOL="${APPIMAGETOOL:-$(command -v appimagetool || true)}"
if [ -z "$TOOL" ]; then
TOOL="$CARGO_TARGET_DIR/appimagetool-$ARCH.AppImage"
if [ ! -x "$TOOL" ]; then
curl -fsSL -o "$TOOL" "https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-$ARCH.AppImage"
chmod +x "$TOOL"
fi
fi
OUT="$DIST/$BASENAME.AppImage"
# Extract-and-run: works without FUSE (containers, CI). The output embeds the static runtime,
# so users don't need libfuse2 either.
ARCH="$ARCH" APPIMAGE_EXTRACT_AND_RUN=1 "$TOOL" --no-appstream "$APPDIR" "$OUT"
echo "wrote $OUT"
fi
"$STAGE/usr/bin/soundcraft-cli" --version
echo "==> done"
ls -lh "$DIST"
+13
View File
@@ -0,0 +1,13 @@
#!/bin/sh
# deb/rpm post-install and post-remove: refresh the desktop, MIME and icon caches.
# Every tool is optional; missing ones are skipped.
if command -v update-desktop-database >/dev/null 2>&1; then
update-desktop-database -q /usr/share/applications || true
fi
if command -v update-mime-database >/dev/null 2>&1; then
update-mime-database /usr/share/mime || true
fi
if command -v gtk-update-icon-cache >/dev/null 2>&1; then
gtk-update-icon-cache -q -t -f /usr/share/icons/hicolor || true
fi
exit 0
+120
View File
@@ -0,0 +1,120 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<!-- Template: packaging/macos/package.sh substitutes @VERSION@ (full, e.g. 0.2.0-rc.1),
@SHORT_VERSION@ (numeric X.Y.Z) and @BUILD_SHA@. -->
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleDisplayName</key>
<string>SoundCraft</string>
<key>CFBundleName</key>
<string>SoundCraft</string>
<key>CFBundleExecutable</key>
<string>SoundCraft</string>
<key>CFBundleIdentifier</key>
<string>ai.storyteller.soundcraft</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleShortVersionString</key>
<string>@SHORT_VERSION@</string>
<key>CFBundleVersion</key>
<string>@SHORT_VERSION@</string>
<key>SoundCraftVersion</key>
<string>@VERSION@</string>
<key>SoundCraftBuildCommit</key>
<string>@BUILD_SHA@</string>
<key>CFBundleIconFile</key>
<string>SoundCraft</string>
<key>LSApplicationCategoryType</key>
<string>public.app-category.music</string>
<!-- Must match MACOSX_DEPLOYMENT_TARGET in package.sh. Big Sur is the first release on
Apple silicon and has the Metal feature set wgpu targets. -->
<key>LSMinimumSystemVersion</key>
<string>11.0</string>
<key>NSHighResolutionCapable</key>
<true/>
<key>NSSupportsAutomaticGraphicsSwitching</key>
<true/>
<key>NSRequiresAquaSystemAppearance</key>
<false/>
<!-- Shown by macOS the first time SoundCraft opens an input device (recording). -->
<key>NSMicrophoneUsageDescription</key>
<string>SoundCraft records audio from the input you choose.</string>
<key>NSHumanReadableCopyright</key>
<string>Copyright © the SoundCraft authors. MIT OR Apache-2.0.</string>
<!-- SoundCraft's own format: a JSON session file next to an "Audio Files" folder. -->
<key>UTExportedTypeDeclarations</key>
<array>
<dict>
<key>UTTypeIdentifier</key>
<string>ai.storyteller.soundcraft.session</string>
<key>UTTypeDescription</key>
<string>SoundCraft Session</string>
<key>UTTypeConformsTo</key>
<array>
<string>public.data</string>
<string>public.content</string>
</array>
<key>UTTypeIconFile</key>
<string>SoundCraft</string>
<key>UTTypeTagSpecification</key>
<dict>
<key>public.filename-extension</key>
<array><string>scraft</string></array>
<key>public.mime-type</key>
<array><string>application/x-soundcraft-session</string></array>
</dict>
</dict>
</array>
<key>CFBundleDocumentTypes</key>
<array>
<dict>
<key>CFBundleTypeName</key>
<string>SoundCraft Session</string>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>LSHandlerRank</key>
<string>Owner</string>
<key>CFBundleTypeIconFile</key>
<string>SoundCraft</string>
<key>LSItemContentTypes</key>
<array><string>ai.storyteller.soundcraft.session</string></array>
</dict>
<!-- Audio and MIDI: offered under "Open With" (imported into a session), never taking over
the user's default player. -->
<dict>
<key>CFBundleTypeName</key>
<string>Audio File</string>
<key>CFBundleTypeRole</key>
<string>Viewer</string>
<key>LSHandlerRank</key>
<string>Alternate</string>
<key>LSItemContentTypes</key>
<array>
<string>com.microsoft.waveform-audio</string>
<string>public.aiff-audio</string>
<string>public.aifc-audio</string>
<string>org.xiph.flac</string>
<string>public.mp3</string>
</array>
</dict>
<dict>
<key>CFBundleTypeName</key>
<string>MIDI File</string>
<key>CFBundleTypeRole</key>
<string>Viewer</string>
<key>LSHandlerRank</key>
<string>Alternate</string>
<key>LSItemContentTypes</key>
<array><string>public.midi-audio</string></array>
</dict>
</array>
</dict>
</plist>
+13
View File
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<!-- Hardened-runtime entitlements for SoundCraft. The app is pure Rust, loads no unsigned code,
needs no JIT, and is not sandboxed (it opens files by path, from the command line and
drag-and-drop). Add exceptions here only with a reason in a comment. -->
<plist version="1.0">
<dict>
<!-- Recording: the hardened runtime blocks audio input (CoreAudio via cpal) without this,
even after the user grants microphone access (NSMicrophoneUsageDescription). -->
<key>com.apple.security.device.audio-input</key>
<true/>
</dict>
</plist>
+50
View File
@@ -0,0 +1,50 @@
#!/usr/bin/env bash
# CI only: import the Developer ID certificate into a temporary keychain so codesign can use it.
#
# Inputs (env): APPLE_CERTIFICATE (base64 .p12), APPLE_CERTIFICATE_PASSWORD, KEYCHAIN_PASSWORD
# (optional; random if unset), RUNNER_TEMP (or TMPDIR).
# Outputs: MACOS_SIGN_IDENTITY (SHA-1 of the identity) and MACOS_KEYCHAIN, appended to
# $GITHUB_ENV when running in Actions, otherwise printed.
#
# Missing certificate: prints a warning and exits 0, so package.sh falls back to ad-hoc signing.
set -euo pipefail
umask 077
# shellcheck source=../env.sh
. "$(dirname "${BASH_SOURCE[0]}")/../env.sh"
if [ -z "${APPLE_CERTIFICATE:-}" ]; then
warn "APPLE_CERTIFICATE is not set: macOS builds will be ad-hoc signed and not notarized"
exit 0
fi
TMP="${RUNNER_TEMP:-${TMPDIR:-/tmp}}"
KEYCHAIN="$TMP/soundcraft-signing.keychain-db"
CERT="$TMP/soundcraft-signing.p12"
KC_PASS="${KEYCHAIN_PASSWORD:-$(openssl rand -hex 24)}"
trap 'rm -f "$CERT"' EXIT
printf '%s' "$APPLE_CERTIFICATE" | base64 --decode >"$CERT"
security create-keychain -p "$KC_PASS" "$KEYCHAIN"
security set-keychain-settings -lut 21600 "$KEYCHAIN"
security unlock-keychain -p "$KC_PASS" "$KEYCHAIN"
security import "$CERT" -P "${APPLE_CERTIFICATE_PASSWORD:-}" -T /usr/bin/codesign -T /usr/bin/security -t cert -f pkcs12 -k "$KEYCHAIN"
rm -f "$CERT"
# Let codesign use the key without a UI prompt.
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KC_PASS" "$KEYCHAIN" >/dev/null
# Put it on the search list (keeping the login keychain) so codesign and notarytool find it.
# shellcheck disable=SC2046
security list-keychains -d user -s "$KEYCHAIN" $(security list-keychains -d user | tr -d '"')
IDENTITY="$(security find-identity -v -p codesigning "$KEYCHAIN" | awk '/Developer ID Application/ { print $2; exit }')"
if [ -z "$IDENTITY" ]; then
security find-identity -v -p codesigning "$KEYCHAIN" >&2
echo "error: no 'Developer ID Application' identity in APPLE_CERTIFICATE" >&2
exit 1
fi
echo "Imported signing identity $IDENTITY"
if [ -n "${GITHUB_ENV:-}" ]; then
{ echo "MACOS_SIGN_IDENTITY=$IDENTITY"; echo "MACOS_KEYCHAIN=$KEYCHAIN"; } >>"$GITHUB_ENV"
else
echo "MACOS_SIGN_IDENTITY=$IDENTITY"
echo "MACOS_KEYCHAIN=$KEYCHAIN"
fi
+162
View File
@@ -0,0 +1,162 @@
#!/usr/bin/env bash
# Build, sign and (optionally) notarize the macOS release artifacts:
#
# $DIST/soundcraft-<version>-macos-<arch>.dmg SoundCraft.app on a drag-to-Applications DMG
# $DIST/soundcraft-cli-<version>-macos-<arch>.zip the headless CLI
#
# Usage: packaging/macos/package.sh [--arch universal|aarch64|x86_64] [--skip-build]
#
# Signing (env):
# MACOS_SIGN_IDENTITY codesign identity (name or SHA-1). Default "-" = ad-hoc (local testing;
# Gatekeeper will reject the result on other Macs). In CI, import-cert.sh sets it.
# MACOS_KEYCHAIN keychain holding the identity (optional)
# Notarization (env; all three needed, and a real identity):
# APPLE_ID, APPLE_PASSWORD (app-specific password), APPLE_TEAM_ID
set -euo pipefail
# shellcheck source=../env.sh
. "$(dirname "${BASH_SOURCE[0]}")/../env.sh"
HERE="$ROOT/packaging/macos"
ARCH=universal
SKIP_BUILD=0
while [ $# -gt 0 ]; do
case "$1" in
--arch) ARCH="$2"; shift 2 ;;
--skip-build) SKIP_BUILD=1; shift ;;
-h | --help) sed -n '2,16p' "$0"; exit 0 ;;
*) echo "unknown argument: $1" >&2; exit 2 ;;
esac
done
case "$ARCH" in
universal) TARGETS=(aarch64-apple-darwin x86_64-apple-darwin) ;;
aarch64) TARGETS=(aarch64-apple-darwin) ;;
x86_64) TARGETS=(x86_64-apple-darwin) ;;
*) echo "unknown --arch $ARCH" >&2; exit 2 ;;
esac
# Keep in sync with LSMinimumSystemVersion in Info.plist.in.
export MACOSX_DEPLOYMENT_TARGET=11.0
IDENTITY="${MACOS_SIGN_IDENTITY:--}"
SHORT_VERSION="${VERSION%%-*}"
WORK="$CARGO_TARGET_DIR/macos-package"
APP="$WORK/SoundCraft.app"
DMG="$DIST/soundcraft-$VERSION-macos-$ARCH.dmg"
CLI_ZIP="$DIST/soundcraft-cli-$VERSION-macos-$ARCH.zip"
NOTARIZE=0
if [ "$IDENTITY" = "-" ]; then
warn "macOS: ad-hoc signing (no MACOS_SIGN_IDENTITY); artifacts are not notarized"
elif [ -n "${APPLE_ID:-}" ] && [ -n "${APPLE_PASSWORD:-}" ] && [ -n "${APPLE_TEAM_ID:-}" ]; then
NOTARIZE=1
else
warn "macOS: APPLE_ID / APPLE_PASSWORD / APPLE_TEAM_ID incomplete; signed but not notarized"
fi
echo "==> SoundCraft $VERSION for macOS ($ARCH), identity: $IDENTITY, notarize: $NOTARIZE"
# ---- build -------------------------------------------------------------------------------------
if [ "$SKIP_BUILD" = 0 ]; then
args=()
for t in "${TARGETS[@]}"; do args+=(--target "$t"); done
(cd "$ROOT" && cargo build --release --locked -p soundcraft -p soundcraft-cli "${args[@]}")
fi
rm -rf "$WORK"
mkdir -p "$WORK/bin"
for bin in soundcraft soundcraft-cli; do
inputs=()
for t in "${TARGETS[@]}"; do inputs+=("$CARGO_TARGET_DIR/$t/release/$bin"); done
lipo -create -output "$WORK/bin/$bin" "${inputs[@]}"
lipo -info "$WORK/bin/$bin"
done
# ---- signing helpers ---------------------------------------------------------------------------
sign() {
# Hardened runtime + secure timestamp for a real identity; ad-hoc can't be timestamped.
local ts=(--timestamp)
[ "$IDENTITY" = "-" ] && ts=(--timestamp=none)
local kc=()
[ -n "${MACOS_KEYCHAIN:-}" ] && kc=(--keychain "$MACOS_KEYCHAIN")
codesign --force --sign "$IDENTITY" ${kc[@]+"${kc[@]}"} "${ts[@]}" "$@"
}
notarize() {
local file="$1" out id status
echo "==> notarizing $(basename "$file") (this can take a few minutes)"
out="$(xcrun notarytool submit "$file" --apple-id "$APPLE_ID" --password "$APPLE_PASSWORD" \
--team-id "$APPLE_TEAM_ID" --wait --timeout 1h --output-format json)" || true
echo "$out"
id="$(printf '%s' "$out" | plutil -extract id raw -o - - 2>/dev/null || true)"
status="$(printf '%s' "$out" | plutil -extract status raw -o - - 2>/dev/null || true)"
if [ "$status" != "Accepted" ]; then
if [ -n "$id" ]; then
xcrun notarytool log "$id" --apple-id "$APPLE_ID" --password "$APPLE_PASSWORD" --team-id "$APPLE_TEAM_ID" || true
fi
echo "error: notarization of $(basename "$file") failed (status: ${status:-unknown})" >&2
exit 1
fi
}
# ---- SoundCraft.app ----------------------------------------------------------------------------
echo "==> assembling $APP"
mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources"
# Executable and icon carry the display name (CFBundleExecutable / CFBundleIconFile).
cp "$WORK/bin/soundcraft" "$APP/Contents/MacOS/SoundCraft"
cp "$ROOT/assets/app-icon/soundcraft.icns" "$APP/Contents/Resources/SoundCraft.icns"
sed -e "s/@VERSION@/$VERSION/g" -e "s/@SHORT_VERSION@/$SHORT_VERSION/g" \
-e "s/@BUILD_SHA@/${SOUNDCRAFT_BUILD_SHA:-unknown}/g" \
"$HERE/Info.plist.in" >"$APP/Contents/Info.plist"
plutil -lint "$APP/Contents/Info.plist"
printf 'APPL????' >"$APP/Contents/PkgInfo"
# Sign inside-out: nested code first, then the bundle itself (no --deep on the final signature).
# Today the only nested code is the main executable; frameworks/helpers would be signed here too.
sign --options runtime --entitlements "$HERE/entitlements.plist" "$APP/Contents/MacOS/SoundCraft"
sign --options runtime --entitlements "$HERE/entitlements.plist" "$APP"
codesign --verify --strict --deep --verbose=2 "$APP"
if [ "$NOTARIZE" = 1 ]; then
ditto -c -k --keepParent "$APP" "$WORK/SoundCraft-notarize.zip"
notarize "$WORK/SoundCraft-notarize.zip"
xcrun stapler staple "$APP"
xcrun stapler validate "$APP"
spctl --assess --type execute -vvv "$APP"
fi
# ---- DMG ---------------------------------------------------------------------------------------
echo "==> building $DMG"
STAGE="$WORK/dmg"
mkdir -p "$STAGE"
ditto "$APP" "$STAGE/SoundCraft.app"
ln -s /Applications "$STAGE/Applications"
rm -f "$DMG" "$WORK/raw.dmg"
# makehybrid + convert builds the image without attaching a device, unlike `create -srcfolder`,
# which is flaky on CI runners ("Resource busy") and hangs in sandboxed sessions.
hdiutil makehybrid -hfs -hfs-volume-name "SoundCraft $VERSION" -hfs-openfolder "$STAGE" -o "$WORK/raw.dmg" "$STAGE"
hdiutil convert "$WORK/raw.dmg" -format UDZO -imagekey zlib-level=9 -o "$DMG"
rm -f "$WORK/raw.dmg"
sign "$DMG"
codesign --verify --strict --verbose=2 "$DMG"
if [ "$NOTARIZE" = 1 ]; then
notarize "$DMG"
xcrun stapler staple "$DMG"
xcrun stapler validate "$DMG"
spctl --assess --type open --context context:primary-signature -vvv "$DMG"
fi
# ---- CLI ---------------------------------------------------------------------------------------
echo "==> building $CLI_ZIP"
CLI_DIR="$WORK/soundcraft-cli-$VERSION-macos-$ARCH"
mkdir -p "$CLI_DIR"
cp "$WORK/bin/soundcraft-cli" "$CLI_DIR/"
copy_docs "$CLI_DIR"
sign --options runtime "$CLI_DIR/soundcraft-cli"
codesign --verify --strict --verbose=2 "$CLI_DIR/soundcraft-cli"
rm -f "$CLI_ZIP"
ditto -c -k --keepParent "$CLI_DIR" "$CLI_ZIP"
# A bare Mach-O can't carry a stapled ticket; Gatekeeper looks the notarization up online.
if [ "$NOTARIZE" = 1 ]; then notarize "$CLI_ZIP"; fi
"$WORK/bin/soundcraft-cli" --version
echo "==> done"
ls -lh "$DMG" "$CLI_ZIP"
+16
View File
@@ -0,0 +1,16 @@
# Apache (see HOSTING.md). Needs mod_mime; mod_deflate and mod_headers are optional.
AddType application/wasm .wasm
AddType text/javascript .js
<IfModule mod_deflate.c>
AddOutputFilterByType DEFLATE application/wasm text/javascript text/html
</IfModule>
<IfModule mod_headers.c>
<FilesMatch "\.(wasm|js)$">
Header set Cache-Control "public, max-age=31536000, immutable"
</FilesMatch>
<FilesMatch "^index\.html$">
Header set Cache-Control "no-cache"
</FilesMatch>
</IfModule>
+91
View File
@@ -0,0 +1,91 @@
# Hosting SoundCraft for the web
`soundcraft-web-<version>.zip` (from the GitHub release, or `packaging/web/package.sh`) holds a
static site in `soundcraft-web-<version>/`:
| File | What it is |
|---|---|
| `index.html` | The page. It loads everything through relative URLs. |
| `soundcraft-web-<hash>.js` | wasm-bindgen glue (generated, ES module) |
| `soundcraft-web-<hash>_bg.wasm` | The app (size: see the release; roughly a third of that with compression) |
| `_headers`, `.htaccess` | Sample header rules for Netlify/Cloudflare Pages and Apache |
There is no server-side code. Upload the folder's contents anywhere that serves static files.
## Any path works
All URLs in `index.html` are relative (`public_url = "./"` in `apps/soundcraft-web/Trunk.toml`),
so the site works at a domain root (`https://example.com/`), under a prefix
(`https://example.com/tools/soundcraft/`) and from a CDN bucket. The asset names carry a content
hash, so they can be cached forever. Only `index.html` needs revalidation.
## Required server settings
- **MIME type:** serve `.wasm` as `application/wasm`. Browsers refuse to stream-compile it under
any other type, and the app then loads slowly or not at all. Serve `.js` as `text/javascript`.
Most hosts already do both. For nginx, check that `mime.types` has `application/wasm wasm;`.
- **Compression:** turn on gzip or Brotli for `.wasm`, `.js` and `.html`. That takes the
download to roughly a third. You can also precompress (`brotli -k *.wasm`) and let
the server send `Content-Encoding: br`.
- **Caching:** `Cache-Control: public, max-age=31536000, immutable` on the hashed `.wasm` and
`.js` files, and `no-cache` on `index.html`.
- **HTTPS:** WebGPU (and the clipboard) only work in a secure context, which means `https://`
or `http://localhost`. Over plain HTTP elsewhere, the app falls back to WebGL2.
- **No special isolation headers:** SoundCraft plays audio through WebAudio without `SharedArrayBuffer`, so it doesn't
need `Cross-Origin-Opener-Policy` or `Cross-Origin-Embedder-Policy`. If your site already sends
COEP `require-corp`, also send `Cross-Origin-Resource-Policy: same-origin` (or `cross-origin`
when the files live on a CDN) on the app's files.
nginx example:
```nginx
location /soundcraft/ {
types { application/wasm wasm; text/javascript js; text/html html; }
gzip on;
gzip_types application/wasm text/javascript text/html;
location ~* \.(wasm|js)$ { add_header Cache-Control "public, max-age=31536000, immutable"; }
location ~* index\.html$ { add_header Cache-Control "no-cache"; }
}
```
Local test: `python3 -m http.server 8765` inside the folder, then open http://localhost:8765/.
## Embedding in a page (iframe)
```html
<iframe
src="https://example.com/soundcraft/"
title="SoundCraft"
style="width: 100%; height: 720px; border: 0;"
allow="fullscreen; autoplay; clipboard-read; clipboard-write"
allowfullscreen>
</iframe>
```
- The app fills the iframe and follows its size, so size the iframe and not the app.
- Keyboard shortcuts go to the iframe after the user clicks into it, as with any embedded app.
- **Sandboxed iframes** need at least
`sandbox="allow-scripts allow-same-origin"`.
- Don't send `X-Frame-Options: DENY` or a `frame-ancestors` CSP that excludes the embedding page.
## Renderer selection and fallback flags
SoundCraft renders with wgpu. It uses **WebGPU** when the browser has it and falls back to
**WebGL2** on its own. URL query flags override this, and they work on the iframe `src` too:
| Flag | Effect |
|---|---|
| *(none)* | WebGPU if available, otherwise WebGL2 |
| `?webgl` | Force the WebGL2 backend (useful when a WebGPU driver misbehaves) |
| `?empty` | Start with an empty session instead of the synthesised demo session |
For example: `<iframe src="https://example.com/soundcraft/?webgl" ...>`.
A browser with neither WebGPU nor WebGL2 gets a message in place of the app.
## Audio
Playback goes through WebAudio. Browsers only start an `AudioContext` after a user gesture, so
the first click or key press in the page may be needed before sound is heard. In an iframe,
`allow="autoplay"` lets the embedding page grant that up front. The web build has no recording,
no native file dialogs and no control channel; it opens the synthesised demo session.
+13
View File
@@ -0,0 +1,13 @@
# Netlify / Cloudflare Pages header rules (see HOSTING.md). Other hosts: set the same headers.
/*
X-Content-Type-Options: nosniff
/*.wasm
Content-Type: application/wasm
Cache-Control: public, max-age=31536000, immutable
/*.js
Cache-Control: public, max-age=31536000, immutable
/index.html
Cache-Control: no-cache
+39
View File
@@ -0,0 +1,39 @@
#!/usr/bin/env bash
# Build the browser version and zip it: $DIST/soundcraft-web-<version>.zip
#
# Usage: packaging/web/package.sh [--skip-build]
#
# Needs: trunk (brew install trunk / cargo install trunk --locked) and the wasm32-unknown-unknown
# target. The zip holds a self-contained static site in soundcraft-web-<version>/ that works
# from any URL path and inside an <iframe>. Hosting notes: packaging/web/README.md.
set -euo pipefail
# shellcheck source=../env.sh
. "$(dirname "${BASH_SOURCE[0]}")/../env.sh"
HERE="$ROOT/packaging/web"
if [ "${1:-}" != "--skip-build" ]; then
command -v trunk >/dev/null || { echo "error: trunk not found (cargo install trunk --locked)" >&2; exit 1; }
(cd "$ROOT/apps/soundcraft-web" && trunk build --release)
fi
SITE="$ROOT/dist/web"
[ -f "$SITE/index.html" ] || { echo "error: $SITE/index.html missing; run without --skip-build" >&2; exit 1; }
# Paths must be relative so the site works under any prefix (public_url = "./" in Trunk.toml).
if grep -Eq '(src|href)="/[^/]' "$SITE/index.html"; then
echo "error: $SITE/index.html has root-absolute URLs; it would break when served from a sub-path" >&2
exit 1
fi
NAME="soundcraft-web-$VERSION"
WORK="$CARGO_TARGET_DIR/web-package"
rm -rf "$WORK"
mkdir -p "$WORK/$NAME"
cp -R "$SITE/." "$WORK/$NAME/"
# Sample server configs (MIME type, caching, compression); harmless where unused.
cp "$HERE/_headers" "$HERE/.htaccess" "$WORK/$NAME/"
cp "$HERE/README.md" "$WORK/$NAME/HOSTING.md"
copy_docs "$WORK/$NAME"
rm -f "$DIST/$NAME.zip"
(cd "$WORK" && zip -qr9 "$DIST/$NAME.zip" "$NAME")
echo "wrote $DIST/$NAME.zip"
ls -lh "$DIST/$NAME.zip"
+125
View File
@@ -0,0 +1,125 @@
<#
.SYNOPSIS
Build, sign and package SoundCraft for Windows.
.DESCRIPTION
Produces, in $env:DIST (default: dist/release):
soundcraft-<version>-windows-<arch>.msi per-machine installer (WiX v5)
soundcraft-<version>-windows-<arch>-portable.zip soundcraft.exe + soundcraft-cli.exe
The binaries link the C runtime statically (+crt-static), so neither the MSI nor the portable
zip needs the Visual C++ redistributable. Signing is delegated to sign.ps1 (skipped with a
warning when no signing secrets are set).
Needs: Rust (MSVC toolchain + the target), the Windows SDK (rc.exe, signtool.exe),
and WiX v5: dotnet tool install --global wix --version 5.0.2
.EXAMPLE
pwsh packaging/windows/package.ps1 -Arch x64
pwsh packaging/windows/package.ps1 -Arch x86 -SkipBuild
pwsh packaging/windows/package.ps1 -Arch arm64 # cross-compiled; needs the MSVC ARM64 build tools
#>
param(
[ValidateSet('x64', 'x86', 'arm64')] [string] $Arch = 'x64',
[switch] $SkipBuild
)
$ErrorActionPreference = 'Stop'
$Root = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
function Invoke-Native([string] $What, [scriptblock] $Block) {
Write-Output "==> $What"
& $Block
if ($LASTEXITCODE -ne 0) { throw "$What failed with exit code $LASTEXITCODE" }
}
# The version lives in one place: [workspace.package] version in the root Cargo.toml.
$Version = $env:SOUNDCRAFT_VERSION
if (-not $Version) {
$inPkg = $false
foreach ($line in Get-Content (Join-Path $Root 'Cargo.toml')) {
if ($line -match '^\s*\[') { $inPkg = ($line.Trim() -eq '[workspace.package]'); continue }
if ($inPkg -and $line -match '^\s*version\s*=\s*"([^"]+)"') { $Version = $Matches[1]; break }
}
}
if (-not $Version) { throw 'could not read [workspace.package] version from Cargo.toml' }
# MSI ProductVersion is numeric (major.minor.build); pre-release tags are dropped there.
$MsiVersion = ($Version -split '-')[0]
$Target = switch ($Arch) { 'x64' { 'x86_64-pc-windows-msvc' } 'x86' { 'i686-pc-windows-msvc' } 'arm64' { 'aarch64-pc-windows-msvc' } }
$Dist = if ($env:DIST) { $env:DIST } else { Join-Path $Root 'dist\release' }
$TargetDir = if ($env:CARGO_TARGET_DIR) { $env:CARGO_TARGET_DIR } else { Join-Path $Root 'target' }
New-Item -ItemType Directory -Force -Path $Dist | Out-Null
if (-not $env:SOUNDCRAFT_BUILD_SHA) { $env:SOUNDCRAFT_BUILD_SHA = (git -C $Root rev-parse HEAD 2>$null) }
if (-not $env:SOUNDCRAFT_BUILD_DATE) { $env:SOUNDCRAFT_BUILD_DATE = (Get-Date).ToUniversalTime().ToString('yyyy-MM-dd') }
Write-Output "SoundCraft $Version for Windows $Arch ($Target)"
if (-not $SkipBuild) {
# Static CRT: no VC++ redistributable needed. Scoped to the target so host build scripts and
# proc-macros are unaffected.
$flagVar = 'CARGO_TARGET_' + ($Target.ToUpper() -replace '-', '_') + '_RUSTFLAGS'
[Environment]::SetEnvironmentVariable($flagVar, '-C target-feature=+crt-static')
# Fail the build (rather than warn) if the icon/VERSIONINFO can't be embedded.
$env:SOUNDCRAFT_REQUIRE_WINRES = '1'
Invoke-Native "cargo build ($Target)" { cargo build --release --locked -p soundcraft -p soundcraft-cli --target $Target }
}
$Bin = Join-Path $TargetDir "$Target\release"
# Check both binaries' PE headers before packaging. Machine (COFF header) must match -Arch, so an
# x64 build can never ship labelled arm64. Subsystem (optional header): 2 = Windows GUI, 3 = console.
# The app must be GUI (no console window opens with it); the CLI must stay console so its output
# reaches the terminal.
function Get-PeHeader([string] $Path) {
$bytes = [System.IO.File]::ReadAllBytes($Path)
$pe = [BitConverter]::ToInt32($bytes, 0x3C)
return @{ Machine = [BitConverter]::ToUInt16($bytes, $pe + 4); Subsystem = [BitConverter]::ToUInt16($bytes, $pe + 0x5C) }
}
$Machine = switch ($Arch) { 'x64' { 0x8664 } 'x86' { 0x14C } 'arm64' { 0xAA64 } }
foreach ($check in @(@('soundcraft.exe', 2), @('soundcraft-cli.exe', 3))) {
$h = Get-PeHeader (Join-Path $Bin $check[0])
if ($h.Machine -ne $Machine) { throw "$($check[0]) is for machine 0x$('{0:X}' -f $h.Machine), expected 0x$('{0:X}' -f $Machine) ($Arch)" }
if ($h.Subsystem -ne $check[1]) { throw "$($check[0]) has PE subsystem $($h.Subsystem), expected $($check[1])" }
Write-Output "ok $($check[0]): $Arch, PE subsystem $($h.Subsystem)"
}
$Stage = Join-Path $TargetDir "windows-package\$Arch"
Remove-Item -Recurse -Force $Stage -ErrorAction SilentlyContinue
New-Item -ItemType Directory -Force -Path $Stage | Out-Null
Copy-Item (Join-Path $Bin 'soundcraft.exe'), (Join-Path $Bin 'soundcraft-cli.exe') $Stage
& (Join-Path $PSScriptRoot 'sign.ps1') (Join-Path $Stage 'soundcraft.exe') (Join-Path $Stage 'soundcraft-cli.exe')
# ---- MSI ---------------------------------------------------------------------------------------
$Msi = Join-Path $Dist "soundcraft-$Version-windows-$Arch.msi"
Invoke-Native 'wix build' {
wix build (Join-Path $PSScriptRoot 'soundcraft.wxs') -arch $Arch `
-d "Version=$MsiVersion" -d "BinDir=$Stage" -d "IconPath=$(Join-Path $Root 'assets\app-icon\soundcraft.ico')" `
-o $Msi
}
# wix writes its debug symbols (.wixpdb) next to the MSI; keep them out of the release assets.
Remove-Item -Force -ErrorAction SilentlyContinue ([IO.Path]::ChangeExtension($Msi, '.wixpdb'))
& (Join-Path $PSScriptRoot 'sign.ps1') $Msi
# ---- portable zip ------------------------------------------------------------------------------
$Portable = Join-Path $TargetDir "windows-package\soundcraft-$Version-windows-$Arch-portable"
Remove-Item -Recurse -Force $Portable -ErrorAction SilentlyContinue
New-Item -ItemType Directory -Force -Path $Portable | Out-Null
Copy-Item (Join-Path $Stage '*.exe') $Portable
foreach ($f in 'README.md', 'LICENSE', 'LICENSE-MIT', 'LICENSE-APACHE', 'NOTICE', 'ATTRIBUTION.md') {
$p = Join-Path $Root $f
if (Test-Path $p) { Copy-Item $p $Portable }
}
$Zip = Join-Path $Dist "soundcraft-$Version-windows-$Arch-portable.zip"
Remove-Item -Force $Zip -ErrorAction SilentlyContinue
Compress-Archive -Path $Portable -DestinationPath $Zip
# Smoke-test the CLI when this machine can run it. An ARM64 build made on an x64 runner can't run
# here; .github/workflows/windows-arm64.yml installs and runs it on ARM64 instead.
$HostArch = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString().ToLowerInvariant()
if ($Arch -ne 'arm64' -or $HostArch -eq 'arm64') {
Invoke-Native 'soundcraft-cli --version' { & (Join-Path $Stage 'soundcraft-cli.exe') --version }
} else {
Write-Output "skipping soundcraft-cli --version: an $Arch build doesn't run on this $HostArch machine"
}
Get-Item $Msi, $Zip | Format-Table Name, Length
+95
View File
@@ -0,0 +1,95 @@
<#
.SYNOPSIS
Authenticode-sign files with signtool, using whichever signing material is configured.
.DESCRIPTION
Tries, in order:
1. A code-signing certificate file: WINDOWS_CERTIFICATE (base64 .pfx), WINDOWS_CERTIFICATE_PASSWORD
2. Azure Trusted Signing: AZURE_TENANT_ID, AZURE_CLIENT_ID, AZURE_CLIENT_SECRET,
AZURE_SIGNING_ENDPOINT (e.g. https://eus.codesigning.azure.net),
AZURE_SIGNING_ACCOUNT, AZURE_CERT_PROFILE
With neither, it prints a warning and leaves the files unsigned (exit code 0), so test builds
still produce installers.
This is the one place to adapt when the signing setup changes (an HSM/cloud key, a different
timestamp server, ...). Optional overrides: WINDOWS_TIMESTAMP_URL, SIGNTOOL (path to signtool.exe).
.EXAMPLE
pwsh packaging/windows/sign.ps1 dist/soundcraft.exe dist/soundcraft.msi
#>
param(
[Parameter(Mandatory = $true, ValueFromRemainingArguments = $true)]
[string[]] $Files
)
$ErrorActionPreference = 'Stop'
function Write-Warn([string] $Message) {
if ($env:GITHUB_ACTIONS) { Write-Output "::warning::$Message" } else { Write-Warning $Message }
}
function Find-SignTool {
if ($env:SIGNTOOL) { return $env:SIGNTOOL }
$onPath = Get-Command signtool.exe -ErrorAction SilentlyContinue
if ($onPath) { return $onPath.Source }
$kits = Join-Path ${env:ProgramFiles(x86)} 'Windows Kits\10\bin'
$found = Get-ChildItem -Path $kits -Recurse -Filter signtool.exe -ErrorAction SilentlyContinue |
Where-Object { $_.FullName -match '\\x64\\signtool\.exe$' } |
Sort-Object FullName -Descending | Select-Object -First 1
if (-not $found) { throw 'signtool.exe not found (install the Windows SDK)' }
return $found.FullName
}
function Invoke-SignTool([string[]] $Arguments) {
& $script:SignTool @Arguments
if ($LASTEXITCODE -ne 0) { throw "signtool $($Arguments[0]) failed with exit code $LASTEXITCODE" }
}
$Files = $Files | ForEach-Object { (Resolve-Path $_).Path }
$haveCert = [bool]$env:WINDOWS_CERTIFICATE
$azureVars = 'AZURE_TENANT_ID', 'AZURE_CLIENT_ID', 'AZURE_CLIENT_SECRET', 'AZURE_SIGNING_ENDPOINT', 'AZURE_SIGNING_ACCOUNT', 'AZURE_CERT_PROFILE'
$haveAzure = -not ($azureVars | Where-Object { -not [Environment]::GetEnvironmentVariable($_) })
if (-not $haveCert -and -not $haveAzure) {
Write-Warn "Windows signing secrets not set (WINDOWS_CERTIFICATE or AZURE_*): leaving unsigned: $($Files -join ', ')"
exit 0
}
$script:SignTool = Find-SignTool
$common = @('sign', '/v', '/fd', 'SHA256', '/td', 'SHA256', '/d', 'SoundCraft', '/du', 'https://github.com/storytold/soundcraft')
$tmp = Join-Path ([IO.Path]::GetTempPath()) "soundcraft-sign-$PID"
New-Item -ItemType Directory -Force -Path $tmp | Out-Null
try {
if ($haveCert) {
Write-Output "Signing with WINDOWS_CERTIFICATE: $($Files -join ', ')"
$pfx = Join-Path $tmp 'cert.pfx'
[IO.File]::WriteAllBytes($pfx, [Convert]::FromBase64String($env:WINDOWS_CERTIFICATE))
$ts = if ($env:WINDOWS_TIMESTAMP_URL) { $env:WINDOWS_TIMESTAMP_URL } else { 'http://timestamp.digicert.com' }
$signArgs = $common + @('/tr', $ts, '/f', $pfx)
if ($env:WINDOWS_CERTIFICATE_PASSWORD) { $signArgs += @('/p', $env:WINDOWS_CERTIFICATE_PASSWORD) }
Invoke-SignTool ($signArgs + $Files)
}
else {
Write-Output "Signing with Azure Trusted Signing ($env:AZURE_SIGNING_ACCOUNT / $env:AZURE_CERT_PROFILE): $($Files -join ', ')"
# The dlib authenticates with DefaultAzureCredential, which reads AZURE_TENANT_ID,
# AZURE_CLIENT_ID and AZURE_CLIENT_SECRET from the environment.
$pkg = Join-Path $tmp 'trusted-signing.zip'
Invoke-WebRequest -Uri 'https://www.nuget.org/api/v2/package/Microsoft.Trusted.Signing.Client' -OutFile $pkg
Expand-Archive -Path $pkg -DestinationPath (Join-Path $tmp 'client') -Force
$dlib = Get-ChildItem -Path (Join-Path $tmp 'client') -Recurse -Filter Azure.CodeSigning.Dlib.dll |
Where-Object { $_.FullName -match '\\x64\\' } | Select-Object -First 1
if (-not $dlib) { throw 'Azure.CodeSigning.Dlib.dll not found in Microsoft.Trusted.Signing.Client' }
$metadata = Join-Path $tmp 'metadata.json'
@{
Endpoint = $env:AZURE_SIGNING_ENDPOINT
CodeSigningAccountName = $env:AZURE_SIGNING_ACCOUNT
CertificateProfileName = $env:AZURE_CERT_PROFILE
} | ConvertTo-Json | Set-Content -Path $metadata -Encoding utf8
$ts = if ($env:WINDOWS_TIMESTAMP_URL) { $env:WINDOWS_TIMESTAMP_URL } else { 'http://timestamp.acs.microsoft.com' }
Invoke-SignTool ($common + @('/tr', $ts, '/dlib', $dlib.FullName, '/dmdf', $metadata) + $Files)
}
Invoke-SignTool (@('verify', '/pa', '/v') + $Files)
}
finally {
Remove-Item -Recurse -Force $tmp -ErrorAction SilentlyContinue
}
+91
View File
@@ -0,0 +1,91 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
SoundCraft MSI (WiX Toolset v5: `dotnet tool install -g wix`). Built by package.ps1:
wix build soundcraft.wxs -arch x64 -d Version=1.2.3 -d BinDir=... -d IconPath=... -o out.msi
Per-machine install into Program Files, a Start Menu shortcut, `.scraft` sessions owned by
SoundCraft, and SoundCraft offered under "Open with" for WAV/AIFF/FLAC/MP3 and MIDI files
(without taking over the user's default player for them).
-->
<Wix xmlns="http://wixtoolset.org/schemas/v4/wxs">
<Package
Name="SoundCraft"
Manufacturer="Learning Machines LLC"
Version="$(var.Version)"
UpgradeCode="EE53D3E5-D0AE-40F9-808D-9417FDEBF61A"
Scope="perMachine"
Compressed="yes"
Language="1033">
<SummaryInformation Description="SoundCraft digital audio workstation" Manufacturer="Learning Machines LLC" />
<!-- Same-version upgrades let successive pre-releases of one X.Y.Z replace each other
(MSI versions are numeric, so 1.2.3-rc.1 and 1.2.3-rc.2 are both 1.2.3). -->
<MajorUpgrade AllowSameVersionUpgrades="yes"
DowngradeErrorMessage="A newer version of SoundCraft is already installed." />
<MediaTemplate EmbedCab="yes" CompressionLevel="high" />
<Icon Id="SoundcraftIcon.ico" SourceFile="$(var.IconPath)" />
<Property Id="ARPPRODUCTICON" Value="SoundcraftIcon.ico" />
<Property Id="ARPURLINFOABOUT" Value="https://getartcraft.com/apps/soundcraft" />
<Property Id="ARPURLUPDATEINFO" Value="https://getartcraft.com/apps/soundcraft" />
<Property Id="ARPHELPLINK" Value="https://discord.gg/artcraft" />
<StandardDirectory Id="ProgramFiles6432Folder">
<Directory Id="INSTALLFOLDER" Name="SoundCraft" />
</StandardDirectory>
<StandardDirectory Id="ProgramMenuFolder" />
<Feature Id="Main" Title="SoundCraft" Level="1">
<ComponentGroupRef Id="SoundcraftFiles" />
</Feature>
</Package>
<Fragment>
<ComponentGroup Id="SoundcraftFiles" Directory="INSTALLFOLDER">
<Component Id="SoundcraftApp" Guid="091136D7-205E-41A8-9D27-5E231F7CBBD4">
<File Id="SoundcraftExe" Source="$(var.BinDir)\soundcraft.exe" KeyPath="yes">
<Shortcut Id="StartMenuShortcut" Directory="ProgramMenuFolder" Name="SoundCraft"
Description="SoundCraft digital audio workstation" WorkingDirectory="INSTALLFOLDER" Advertise="yes"
Icon="SoundcraftIcon.ico" IconIndex="0" />
</File>
<!-- .scraft: SoundCraft's own session format; SoundCraft is its default app. -->
<ProgId Id="SoundCraft.Session" Description="SoundCraft Session" Icon="SoundcraftExe" IconIndex="0">
<Extension Id="scraft" ContentType="application/x-soundcraft-session">
<Verb Id="open" Command="Open" TargetFile="SoundcraftExe" Argument="&quot;%1&quot;" />
</Extension>
</ProgId>
<!-- Audio and MIDI files: offered under "Open with" (imported into a session), never forced
as the default. -->
<ProgId Id="SoundCraft.Audio" Description="Audio File" Icon="SoundcraftExe" IconIndex="0" />
<RegistryValue Root="HKLM" Key="Software\Classes\SoundCraft.Audio\shell\open\command" Value="&quot;[#SoundcraftExe]&quot; &quot;%1&quot;" Type="string" />
<ProgId Id="SoundCraft.Midi" Description="MIDI File" Icon="SoundcraftExe" IconIndex="0" />
<RegistryValue Root="HKLM" Key="Software\Classes\SoundCraft.Midi\shell\open\command" Value="&quot;[#SoundcraftExe]&quot; &quot;%1&quot;" Type="string" />
<RegistryValue Root="HKLM" Key="Software\Classes\.wav\OpenWithProgids" Name="SoundCraft.Audio" Value="" Type="string" />
<RegistryValue Root="HKLM" Key="Software\Classes\.aif\OpenWithProgids" Name="SoundCraft.Audio" Value="" Type="string" />
<RegistryValue Root="HKLM" Key="Software\Classes\.aiff\OpenWithProgids" Name="SoundCraft.Audio" Value="" Type="string" />
<RegistryValue Root="HKLM" Key="Software\Classes\.flac\OpenWithProgids" Name="SoundCraft.Audio" Value="" Type="string" />
<RegistryValue Root="HKLM" Key="Software\Classes\.mp3\OpenWithProgids" Name="SoundCraft.Audio" Value="" Type="string" />
<RegistryValue Root="HKLM" Key="Software\Classes\.mid\OpenWithProgids" Name="SoundCraft.Midi" Value="" Type="string" />
<RegistryValue Root="HKLM" Key="Software\Classes\.midi\OpenWithProgids" Name="SoundCraft.Midi" Value="" Type="string" />
<!-- "Open with" app list and the Run dialog (Win+R "soundcraft"). -->
<RegistryValue Root="HKLM" Key="Software\Classes\Applications\soundcraft.exe" Name="FriendlyAppName" Value="SoundCraft" Type="string" />
<RegistryValue Root="HKLM" Key="Software\Classes\Applications\soundcraft.exe\shell\open\command" Value="&quot;[#SoundcraftExe]&quot; &quot;%1&quot;" Type="string" />
<RegistryValue Root="HKLM" Key="Software\Classes\Applications\soundcraft.exe\SupportedTypes" Name=".scraft" Value="" Type="string" />
<RegistryValue Root="HKLM" Key="Software\Classes\Applications\soundcraft.exe\SupportedTypes" Name=".wav" Value="" Type="string" />
<RegistryValue Root="HKLM" Key="Software\Classes\Applications\soundcraft.exe\SupportedTypes" Name=".aif" Value="" Type="string" />
<RegistryValue Root="HKLM" Key="Software\Classes\Applications\soundcraft.exe\SupportedTypes" Name=".aiff" Value="" Type="string" />
<RegistryValue Root="HKLM" Key="Software\Classes\Applications\soundcraft.exe\SupportedTypes" Name=".flac" Value="" Type="string" />
<RegistryValue Root="HKLM" Key="Software\Classes\Applications\soundcraft.exe\SupportedTypes" Name=".mp3" Value="" Type="string" />
<RegistryValue Root="HKLM" Key="Software\Classes\Applications\soundcraft.exe\SupportedTypes" Name=".mid" Value="" Type="string" />
<RegistryValue Root="HKLM" Key="Software\Classes\Applications\soundcraft.exe\SupportedTypes" Name=".midi" Value="" Type="string" />
<RegistryValue Root="HKLM" Key="Software\Microsoft\Windows\CurrentVersion\App Paths\soundcraft.exe" Value="[#SoundcraftExe]" Type="string" />
</Component>
<Component Id="SoundcraftCli" Guid="4552049A-C2F0-4AB0-BAE6-A62C3348F4B4">
<File Id="SoundcraftCliExe" Source="$(var.BinDir)\soundcraft-cli.exe" KeyPath="yes" />
</Component>
</ComponentGroup>
</Fragment>
</Wix>
+106
View File
@@ -0,0 +1,106 @@
//! `cargo xtask ico <out.ico> <in.png>…`: pack PNG images into a Windows `.ico`.
//!
//! Vista and later read PNG-compressed icon entries, so each PNG is stored as is. The size comes
//! from the PNG's IHDR chunk; 256 px is written as 0, per the ICO format.
use std::path::Path;
/// Width and height from a PNG's IHDR chunk.
pub fn png_size(png: &[u8]) -> Result<(u32, u32), String> {
const SIG: &[u8] = b"\x89PNG\r\n\x1a\n";
if png.len() < 24 || &png[..8] != SIG || &png[12..16] != b"IHDR" {
return Err("not a PNG file".into());
}
let w = u32::from_be_bytes([png[16], png[17], png[18], png[19]]);
let h = u32::from_be_bytes([png[20], png[21], png[22], png[23]]);
Ok((w, h))
}
/// An ICO file holding `pngs`, which must be square and at most 256 px.
pub fn pack(pngs: &[Vec<u8>]) -> Result<Vec<u8>, String> {
if pngs.is_empty() || pngs.len() > u16::MAX as usize {
return Err("need at least one PNG".into());
}
let mut out = Vec::new();
out.extend_from_slice(&0u16.to_le_bytes()); // reserved
out.extend_from_slice(&1u16.to_le_bytes()); // type: icon
out.extend_from_slice(&(pngs.len() as u16).to_le_bytes());
let mut offset = 6 + 16 * pngs.len();
for png in pngs {
let (w, h) = png_size(png)?;
if w != h || w == 0 || w > 256 {
return Err(format!("icon images must be square and at most 256 px, got {w}x{h}"));
}
let dim = if w == 256 { 0 } else { w as u8 };
out.extend_from_slice(&[dim, dim, 0, 0]); // width, height, palette size, reserved
out.extend_from_slice(&1u16.to_le_bytes()); // colour planes
out.extend_from_slice(&32u16.to_le_bytes()); // bits per pixel
out.extend_from_slice(&(png.len() as u32).to_le_bytes());
out.extend_from_slice(&(offset as u32).to_le_bytes());
offset += png.len();
}
for png in pngs {
out.extend_from_slice(png);
}
Ok(out)
}
/// `cargo xtask ico <out.ico> <in.png>…`; `args` are the words after `ico`.
pub fn run(args: &[String]) -> Result<(), String> {
let [out, inputs @ ..] = args else {
return Err("usage: cargo xtask ico <out.ico> <in.png>…".into());
};
let pngs = inputs.iter().map(|p| std::fs::read(p).map_err(|e| format!("read {p}: {e}"))).collect::<Result<Vec<_>, _>>()?;
let ico = pack(&pngs)?;
std::fs::write(Path::new(out), &ico).map_err(|e| format!("write {out}: {e}"))?;
println!("{out}: {} images, {} bytes", pngs.len(), ico.len());
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn fake_png(size: u32) -> Vec<u8> {
let mut v = b"\x89PNG\r\n\x1a\n\0\0\0\x0dIHDR".to_vec();
v.extend_from_slice(&size.to_be_bytes());
v.extend_from_slice(&size.to_be_bytes());
v.extend_from_slice(&[8, 6, 0, 0, 0, 1, 2, 3, 4]);
v
}
#[test]
fn packs_directory_and_payloads() {
let a = fake_png(16);
let b = fake_png(256);
let ico = pack(&[a.clone(), b.clone()]).unwrap();
assert_eq!(&ico[..6], &[0, 0, 1, 0, 2, 0]);
// Entry 0: 16 px at offset 6 + 32.
assert_eq!(&ico[6..8], &[16, 16]);
assert_eq!(u32::from_le_bytes(ico[14..18].try_into().unwrap()), a.len() as u32);
assert_eq!(u32::from_le_bytes(ico[18..22].try_into().unwrap()), 38);
// Entry 1: 256 px stored as 0, right after the first payload.
assert_eq!(&ico[22..24], &[0, 0]);
assert_eq!(u32::from_le_bytes(ico[34..38].try_into().unwrap()), 38 + a.len() as u32);
assert_eq!(&ico[38..38 + a.len()], &a[..]);
assert_eq!(&ico[38 + a.len()..], &b[..]);
}
#[test]
fn rejects_bad_input() {
assert!(pack(&[]).is_err());
assert!(pack(&[fake_png(512)]).is_err());
assert!(pack(&[b"GIF89a".to_vec()]).is_err());
assert_eq!(png_size(&fake_png(48)).unwrap(), (48, 48));
}
#[test]
fn committed_icon_is_valid() {
let root = Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap_or(Path::new("."));
let path = root.join("assets/app-icon/soundcraft.ico");
let Ok(ico) = std::fs::read(&path) else { return };
assert_eq!(&ico[..4], &[0, 0, 1, 0]);
let n = u16::from_le_bytes([ico[4], ico[5]]) as usize;
assert!(n >= 4, "expected several sizes in {}", path.display());
}
}
+5
View File
@@ -1,5 +1,8 @@
//! `cargo xtask <ci|layers|assets|parity|wasm>` — repository gates.
mod ico;
mod version;
use std::path::{Path, PathBuf};
use std::process::{Command, ExitCode};
@@ -149,6 +152,8 @@ fn main() -> ExitCode {
Some("assets") => assets(),
Some("parity") => parity(),
Some("wasm") => wasm(),
Some("version") => version::run(args.get(1..).unwrap_or(&[])),
Some("ico") => ico::run(args.get(1..).unwrap_or(&[])),
_ => Err("usage: cargo xtask <ci|layers|assets|parity|wasm|version>".into()),
};
match r {
+168
View File
@@ -0,0 +1,168 @@
//! `cargo xtask version [set X.Y.Z[-pre]]`: the workspace version, the single source of truth.
//!
//! The version lives in `[workspace.package] version` in the root `Cargo.toml`; every crate
//! inherits it with `version.workspace = true`, and the packaging scripts read it from here.
use std::path::{Path, PathBuf};
use std::process::Command;
/// The `version = "…"` value inside `[workspace.package]`.
pub fn read(manifest: &str) -> Result<String, String> {
let (_, line) = find_line(manifest)?;
let v = line.split_once('=').map(|(_, v)| v.trim().trim_matches('"').to_string()).unwrap_or_default();
if v.is_empty() {
return Err("empty [workspace.package] version".into());
}
Ok(v)
}
/// `manifest` with the workspace version replaced; everything else is byte-for-byte unchanged.
pub fn replace(manifest: &str, new: &str) -> Result<String, String> {
validate(new)?;
let (idx, _) = find_line(manifest)?;
let mut out = String::with_capacity(manifest.len() + 8);
for (i, line) in manifest.split_inclusive('\n').enumerate() {
if i == idx {
let eol = if line.ends_with("\r\n") {
"\r\n"
} else if line.ends_with('\n') {
"\n"
} else {
""
};
out.push_str(&format!("version = \"{new}\"{eol}"));
} else {
out.push_str(line);
}
}
Ok(out)
}
/// Semver without build metadata: `MAJOR.MINOR.PATCH` plus an optional `-pre.release` tag.
pub fn validate(v: &str) -> Result<(), String> {
let bad = || Err(format!("`{v}` is not a version like 1.2.3 or 1.2.3-rc.1"));
let (core, pre) = match v.split_once('-') {
Some((c, p)) => (c, Some(p)),
None => (v, None),
};
let nums: Vec<&str> = core.split('.').collect();
if nums.len() != 3 || nums.iter().any(|n| n.is_empty() || !n.bytes().all(|b| b.is_ascii_digit()) || (n.len() > 1 && n.starts_with('0'))) {
return bad();
}
if let Some(p) = pre
&& (p.is_empty() || p.split('.').any(|id| id.is_empty() || !id.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-')))
{
return bad();
}
Ok(())
}
/// Index and text of the `version` line in `[workspace.package]`.
fn find_line(manifest: &str) -> Result<(usize, &str), String> {
let mut in_section = false;
for (i, line) in manifest.lines().enumerate() {
let t = line.trim();
if t.starts_with('[') {
in_section = t == "[workspace.package]";
continue;
}
if in_section
&& let Some(rest) = t.strip_prefix("version")
&& rest.trim_start().starts_with('=')
{
return Ok((i, line));
}
}
Err("no `version = \"…\"` in [workspace.package] of the root Cargo.toml".into())
}
/// The workspace root (the parent of `xtask/`).
fn root() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).parent().map(Path::to_path_buf).unwrap_or_else(|| PathBuf::from("."))
}
fn cargo_update(root: &Path, offline: bool) -> Result<(), String> {
let mut c = Command::new(std::env::var("CARGO").unwrap_or_else(|_| "cargo".into()));
c.args(["update", "--workspace"]).current_dir(root);
if offline {
c.arg("--offline");
}
let st = c.status().map_err(|e| format!("cargo update: {e}"))?;
if st.success() { Ok(()) } else { Err(format!("cargo update --workspace{} failed", if offline { " --offline" } else { "" })) }
}
/// `cargo xtask version [set X.Y.Z[-pre]]`; `args` are the words after `version`.
pub fn run(args: &[String]) -> Result<(), String> {
let root = root();
let path = root.join("Cargo.toml");
let text = std::fs::read_to_string(&path).map_err(|e| format!("read {}: {e}", path.display()))?;
let args: Vec<&str> = args.iter().map(String::as_str).collect();
match args.as_slice() {
[] => {
println!("{}", read(&text)?);
Ok(())
}
["set", new] => {
let old = read(&text)?;
let updated = replace(&text, new)?;
// Write atomically: a half-written root manifest breaks every build in the workspace.
let dir = root.join("target");
std::fs::create_dir_all(&dir).map_err(|e| format!("create {}: {e}", dir.display()))?;
let tmp = dir.join("Cargo.toml.xtask-version");
std::fs::write(&tmp, updated).map_err(|e| format!("write {}: {e}", tmp.display()))?;
std::fs::rename(&tmp, &path).map_err(|e| format!("replace {}: {e}", path.display()))?;
// Refresh the workspace members' entries in Cargo.lock (no dependency upgrades).
if cargo_update(&root, true).is_err() {
cargo_update(&root, false)?;
}
println!("version: {old} -> {new}");
Ok(())
}
_ => Err("usage: cargo xtask version [set X.Y.Z[-pre]]".into()),
}
}
#[cfg(test)]
mod tests {
use super::*;
const MANIFEST: &str = "[workspace]\nmembers = [\"a\"]\n\n[workspace.package]\nversion = \"0.1.0\"\nedition = \"2024\"\n\n[workspace.dependencies]\nfoo = { version = \"1\" }\n";
#[test]
fn reads_the_workspace_version() {
assert_eq!(read(MANIFEST).unwrap(), "0.1.0");
assert!(read("[package]\nversion = \"1.0.0\"\n").is_err());
}
#[test]
fn replaces_only_the_workspace_version() {
let out = replace(MANIFEST, "1.2.3-rc.1").unwrap();
assert_eq!(read(&out).unwrap(), "1.2.3-rc.1");
assert_eq!(out, MANIFEST.replace("version = \"0.1.0\"", "version = \"1.2.3-rc.1\""));
assert!(out.contains("foo = { version = \"1\" }"));
}
#[test]
fn keeps_crlf_line_endings() {
let crlf = MANIFEST.replace('\n', "\r\n");
let out = replace(&crlf, "2.0.0").unwrap();
assert_eq!(out, crlf.replace("\"0.1.0\"", "\"2.0.0\""));
}
#[test]
fn validates_versions() {
for ok in ["0.1.0", "1.20.300", "1.0.0-rc.1", "1.0.0-alpha", "1.0.0-x-y.2"] {
assert!(validate(ok).is_ok(), "{ok}");
}
for bad in ["1.0", "1.0.0.0", "v1.0.0", "01.0.0", "1.0.0-", "1.0.0-a..b", "1.0.0+meta", "1.a.0", ""] {
assert!(validate(bad).is_err(), "{bad}");
}
assert!(replace(MANIFEST, "nope").is_err());
}
#[test]
fn the_real_manifest_has_a_valid_version() {
let text = std::fs::read_to_string(root().join("Cargo.toml")).unwrap();
validate(&read(&text).unwrap()).unwrap();
}
}