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 <noreply@anthropic.com>
|
|
@ -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
|
||||||
|
|
@ -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'"
|
||||||
|
|
@ -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"
|
||||||
|
|
@ -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,8 +9,9 @@ Assets must be original, public domain (CC0) or permissively licensed. See `AGEN
|
||||||
## Icons
|
## Icons
|
||||||
|
|
||||||
All UI icons (tools, transport, track buttons) are drawn procedurally in
|
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
|
`crates/ui-egui/src/icons.rs` by the SoundCraft contributors (MIT OR Apache-2.0). No UI icon image
|
||||||
files are used.
|
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
|
## 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.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.svg` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms |
|
||||||
| `docs/brand/artcraft-mark-black.png` | 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 |
|
||||||
|
|
|
||||||
|
|
@ -3357,6 +3357,7 @@ dependencies = [
|
||||||
"soundcraft-engine",
|
"soundcraft-engine",
|
||||||
"soundcraft-playback",
|
"soundcraft-playback",
|
||||||
"soundcraft-ui-egui",
|
"soundcraft-ui-egui",
|
||||||
|
"winresource",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -3435,6 +3436,7 @@ name = "soundcraft-mix"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"log",
|
"log",
|
||||||
|
"rayon",
|
||||||
"soundcraft-audio-io",
|
"soundcraft-audio-io",
|
||||||
"soundcraft-dsp",
|
"soundcraft-dsp",
|
||||||
"soundcraft-midi",
|
"soundcraft-midi",
|
||||||
|
|
@ -3868,10 +3870,12 @@ version = "1.1.6+spec-1.1.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "920602543f0911ab71da12c50d59701da54c196d1a2bf5cb4b75667f137a406a"
|
checksum = "920602543f0911ab71da12c50d59701da54c196d1a2bf5cb4b75667f137a406a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"indexmap",
|
||||||
"serde_core",
|
"serde_core",
|
||||||
"serde_spanned",
|
"serde_spanned",
|
||||||
"toml_datetime",
|
"toml_datetime",
|
||||||
"toml_parser",
|
"toml_parser",
|
||||||
|
"toml_writer",
|
||||||
"winnow",
|
"winnow",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -3905,6 +3909,12 @@ dependencies = [
|
||||||
"winnow",
|
"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]]
|
[[package]]
|
||||||
name = "tracing"
|
name = "tracing"
|
||||||
version = "0.1.44"
|
version = "0.1.44"
|
||||||
|
|
@ -4942,6 +4952,16 @@ dependencies = [
|
||||||
"memchr",
|
"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]]
|
[[package]]
|
||||||
name = "wit-bindgen"
|
name = "wit-bindgen"
|
||||||
version = "0.57.1"
|
version = "0.57.1"
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -21,3 +21,7 @@ log.workspace = true
|
||||||
|
|
||||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||||
rfd.workspace = true
|
rfd.workspace = true
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
# Icon + VERSIONINFO for soundcraft.exe; build.rs only uses it when the target is Windows.
|
||||||
|
winresource = "0.1"
|
||||||
|
|
|
||||||
|
|
@ -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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 {
|
fn main() -> eframe::Result {
|
||||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||||
if args.iter().any(|a| a == "--version" || a == "-V") {
|
if args.iter().any(|a| a == "--version" || a == "-V") {
|
||||||
|
|
@ -146,7 +156,7 @@ fn main() -> eframe::Result {
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let engine = if demo { soundcraft_engine::demo::demo_engine() } else { Engine::default() };
|
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()
|
viewport: egui::ViewportBuilder::default()
|
||||||
.with_title("SoundCraft")
|
.with_title("SoundCraft")
|
||||||
.with_inner_size([1600.0, 1000.0])
|
.with_inner_size([1600.0, 1000.0])
|
||||||
|
|
@ -155,6 +165,9 @@ fn main() -> eframe::Result {
|
||||||
.with_app_id("ai.storyteller.soundcraft"),
|
.with_app_id("ai.storyteller.soundcraft"),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
|
if let Some(icon) = app_icon() {
|
||||||
|
options.viewport = options.viewport.with_icon(icon);
|
||||||
|
}
|
||||||
eframe::run_native(
|
eframe::run_native(
|
||||||
"SoundCraft",
|
"SoundCraft",
|
||||||
options,
|
options,
|
||||||
|
|
|
||||||
|
|
@ -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`).
|
||||||
|
After Width: | Height: | Size: 7.7 KiB |
|
After Width: | Height: | Size: 767 B |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 80 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
|
@ -28,8 +28,9 @@ pub struct Recorder {
|
||||||
pub channels: usize,
|
pub channels: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Cap: one hour of 8-channel 192 kHz audio.
|
/// Cap: one hour of 8-channel 192 kHz audio; 64 Mi samples (256 MB of f32) on 32-bit targets
|
||||||
const MAX_SAMPLES: usize = 3600 * 192_000 * 8;
|
/// (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 {
|
impl Recorder {
|
||||||
/// Open the default input device. Errors when there is none (the caller shows a message).
|
/// Open the default input device. Errors when there is none (the caller shows a message).
|
||||||
|
|
|
||||||
|
|
@ -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).
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
# shellcheck config for the packaging scripts (CI: .github/workflows/packaging-lint.yml).
|
||||||
|
source-path=SCRIPTDIR
|
||||||
|
external-sources=true
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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 <soundcraft@storyteller.ai>}"
|
||||||
|
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"
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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"
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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"
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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=""%1"" />
|
||||||
|
</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=""[#SoundcraftExe]" "%1"" 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=""[#SoundcraftExe]" "%1"" 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=""[#SoundcraftExe]" "%1"" 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>
|
||||||
|
|
@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
//! `cargo xtask <ci|layers|assets|parity|wasm>` — repository gates.
|
//! `cargo xtask <ci|layers|assets|parity|wasm>` — repository gates.
|
||||||
|
|
||||||
|
mod ico;
|
||||||
|
mod version;
|
||||||
|
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::process::{Command, ExitCode};
|
use std::process::{Command, ExitCode};
|
||||||
|
|
||||||
|
|
@ -149,6 +152,8 @@ fn main() -> ExitCode {
|
||||||
Some("assets") => assets(),
|
Some("assets") => assets(),
|
||||||
Some("parity") => parity(),
|
Some("parity") => parity(),
|
||||||
Some("wasm") => wasm(),
|
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()),
|
_ => Err("usage: cargo xtask <ci|layers|assets|parity|wasm|version>".into()),
|
||||||
};
|
};
|
||||||
match r {
|
match r {
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||