From 7f9394eec1c18e1032bdce85b8da489907170e9d Mon Sep 17 00:00:00 2001 From: brxs Date: Sat, 8 Aug 2026 15:13:58 -0700 Subject: [PATCH 1/4] feat: add Linux AppImage shipping foundation --- .github/CODEOWNERS | 3 + .github/workflows/ci.yml | 96 +++++- .github/workflows/macos-release.yml | 144 ++++++++ README.md | 6 + docs/cross-platform-ci-and-release.md | 16 +- docs/linux-qualification-checklist.md | 97 ++++++ docs/linux.md | 146 ++++++++ docs/native-packaging.md | 10 + justfile | 16 +- scripts/build-linux-appimage.sh | 63 ++++ scripts/build-macos-release.sh | 3 + scripts/release_artifact.py | 12 +- scripts/tests/test_release_artifact.py | 76 +++-- scripts/tests/test_verify_linux_appimage.py | 72 ++++ scripts/verify_linux_appimage.py | 166 ++++++++++ src-tauri/Cargo.toml | 5 + src-tauri/src/generation.rs | 10 +- src-tauri/src/lib.rs | 3 + src-tauri/src/midi/drivers.rs | 30 +- src-tauri/src/midi/mod.rs | 14 +- src-tauri/src/platform_diagnostics.rs | 349 ++++++++++++++++++++ src-tauri/src/runtime_launch.rs | 59 ++++ src-tauri/src/sidecar.rs | 13 + src-tauri/tauri.conf.json | 10 +- src-tauri/tauri.linux.conf.json | 28 ++ src-tauri/tauri.macos.conf.json | 24 ++ 26 files changed, 1412 insertions(+), 59 deletions(-) create mode 100644 docs/linux-qualification-checklist.md create mode 100644 docs/linux.md create mode 100755 scripts/build-linux-appimage.sh create mode 100644 scripts/tests/test_verify_linux_appimage.py create mode 100755 scripts/verify_linux_appimage.py create mode 100644 src-tauri/src/platform_diagnostics.rs create mode 100644 src-tauri/src/runtime_launch.rs create mode 100644 src-tauri/tauri.linux.conf.json create mode 100644 src-tauri/tauri.macos.conf.json diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 3a92533..ed1b809 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -4,6 +4,9 @@ /justfile @protocol-works/engineering /scripts/create-release.sh @protocol-works/engineering /scripts/build-macos-release.sh @protocol-works/engineering +/scripts/build-linux-appimage.sh @protocol-works/engineering /scripts/freeze-sidecar.sh @protocol-works/engineering +/scripts/release_artifact.py @protocol-works/engineering +/scripts/verify_linux_appimage.py @protocol-works/engineering /src-tauri/entitlements.plist @protocol-works/engineering /src-tauri/tauri*.conf.json @protocol-works/engineering diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 892bd6a..7f19698 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -51,12 +51,16 @@ jobs: - name: Check release tooling formatting run: >- uv run --project backend --frozen --only-group ci ruff format --check - scripts/release_artifact.py scripts/tests/test_release_artifact.py + scripts/release_artifact.py scripts/verify_linux_appimage.py + scripts/tests/test_release_artifact.py + scripts/tests/test_verify_linux_appimage.py - name: Lint release tooling run: >- uv run --project backend --frozen --only-group ci ruff check - scripts/release_artifact.py scripts/tests/test_release_artifact.py + scripts/release_artifact.py scripts/verify_linux_appimage.py + scripts/tests/test_release_artifact.py + scripts/tests/test_verify_linux_appimage.py - name: Check portable Python formatting working-directory: backend @@ -152,3 +156,91 @@ jobs: run: >- cargo clippy --locked --workspace --all-targets --manifest-path src-tauri/Cargo.toml -- -D warnings + + linux_appimage: + name: Linux AppImage contract (Ubuntu 22.04) + runs-on: ubuntu-22.04 + timeout-minutes: 90 + + steps: + - name: Check out source and test corpus + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + lfs: true + persist-credentials: false + + - name: Set up Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 24 + cache: npm + cache-dependency-path: frontend/package-lock.json + + - name: Install pinned native build dependencies + shell: bash + run: | + set -euo pipefail + sudo apt-get update + sudo apt-get install --yes \ + binutils \ + build-essential \ + libasound2-dev \ + libayatana-appindicator3-dev \ + libfuse2 \ + libgtk-3-dev \ + libssl-dev \ + libudev-dev \ + libwebkit2gtk-4.1-dev \ + libxdo-dev \ + librsvg2-dev \ + xvfb + rustup toolchain install stable --profile minimal --no-self-update + rustup default stable + cargo install tauri-cli --version '=2.11.2' --locked + npm ci --prefix frontend + + - name: Build and audit AppImage + env: + LSDJ_LINUX_AUDIT_PATH: ${{ runner.temp }}/linux-package-audit.json + shell: bash + run: scripts/build-linux-appimage.sh + + - name: Smoke AppImage with spaces, Unicode, and isolated XDG roots + shell: bash + run: | + set -euo pipefail + shopt -s nullglob + APPIMAGES=(src-tauri/target/release/bundle/appimage/*.AppImage) + [[ "${#APPIMAGES[@]}" -eq 1 ]] + + PROFILE="$RUNNER_TEMP/DJ Name 音楽" + export HOME="$PROFILE/home" + export XDG_CONFIG_HOME="$PROFILE/config 空間" + export XDG_DATA_HOME="$PROFILE/data 音楽" + export XDG_CACHE_HOME="$PROFILE/cache 音楽" + export XDG_RUNTIME_DIR="$RUNNER_TEMP/xdg-runtime" + mkdir -p \ + "$HOME" \ + "$XDG_CONFIG_HOME" \ + "$XDG_DATA_HOME" \ + "$XDG_CACHE_HOME" \ + "$XDG_RUNTIME_DIR" + chmod 700 "$XDG_RUNTIME_DIR" + + set +e + APPIMAGE_EXTRACT_AND_RUN=1 timeout --signal=TERM --kill-after=5s 15s \ + xvfb-run --auto-servernum "${APPIMAGES[0]}" + STATUS=$? + set -e + [[ "$STATUS" -eq 0 || "$STATUS" -eq 124 || "$STATUS" -eq 143 ]] + test -d "$XDG_CONFIG_HOME/lsdj" + test -d "$XDG_DATA_HOME/lsdj" + test -d "$XDG_CACHE_HOME/lsdj" + + - name: Upload package audit evidence + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: linux-package-audit + path: ${{ runner.temp }}/linux-package-audit.json + if-no-files-found: error + retention-days: 14 diff --git a/.github/workflows/macos-release.yml b/.github/workflows/macos-release.yml index 7cdb514..f7c5213 100644 --- a/.github/workflows/macos-release.yml +++ b/.github/workflows/macos-release.yml @@ -248,14 +248,151 @@ jobs: "${LSDJ_CERTIFICATE_PATH:-}" \ "${LSDJ_API_KEY_PATH:-}" + produce_linux: + name: Produce Linux x86_64 AppImage + needs: validate + if: >- + needs.validate.result == 'success' && + github.repository == 'protocol-works/lsdj' && + startsWith(github.ref, 'refs/tags/v') + # Ubuntu 22.04 (glibc 2.35) is the oldest supported base. Building here, + # rather than `ubuntu-latest`, prevents a newer host ABI from silently + # raising the AppImage floor. + runs-on: ubuntu-22.04 + timeout-minutes: 120 + + steps: + - name: Check out the approved release tag + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + lfs: true + persist-credentials: false + + - name: Set up Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 24 + cache: npm + cache-dependency-path: frontend/package-lock.json + + - name: Set up Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: "3.13" + + - name: Install pinned native build dependencies + shell: bash + run: | + set -euo pipefail + sudo apt-get update + sudo apt-get install --yes \ + binutils \ + build-essential \ + libasound2-dev \ + libayatana-appindicator3-dev \ + libfuse2 \ + libgtk-3-dev \ + libssl-dev \ + libudev-dev \ + libwebkit2gtk-4.1-dev \ + libxdo-dev \ + librsvg2-dev \ + xvfb + rustup toolchain install stable --profile minimal --no-self-update + rustup default stable + cargo install tauri-cli --version '=2.11.2' --locked + npm ci --prefix frontend + + - name: Test fail-closed portable runtime launch + shell: bash + run: >- + cargo test --locked --workspace --features managed-runtime + --manifest-path src-tauri/Cargo.toml runtime_launch + + - name: Build and audit AppImage + env: + LSDJ_RELEASE_VERSION: ${{ github.ref_name }} + LSDJ_LINUX_AUDIT_PATH: ${{ runner.temp }}/linux-package-audit.json + shell: bash + run: scripts/build-linux-appimage.sh + + - name: Smoke AppImage with isolated XDG paths + shell: bash + run: | + set -euo pipefail + shopt -s nullglob + APPIMAGES=(src-tauri/target/release/bundle/appimage/*.AppImage) + [[ "${#APPIMAGES[@]}" -eq 1 ]] + + PROFILE="$RUNNER_TEMP/DJ Name 音楽" + export HOME="$PROFILE/home" + export XDG_CONFIG_HOME="$PROFILE/config 空間" + export XDG_DATA_HOME="$PROFILE/data 音楽" + export XDG_CACHE_HOME="$PROFILE/cache 音楽" + export XDG_RUNTIME_DIR="$RUNNER_TEMP/xdg-runtime" + mkdir -p \ + "$HOME" \ + "$XDG_CONFIG_HOME" \ + "$XDG_DATA_HOME" \ + "$XDG_CACHE_HOME" \ + "$XDG_RUNTIME_DIR" + chmod 700 "$XDG_RUNTIME_DIR" + + set +e + APPIMAGE_EXTRACT_AND_RUN=1 timeout --signal=TERM --kill-after=5s 15s \ + xvfb-run --auto-servernum "${APPIMAGES[0]}" + STATUS=$? + set -e + [[ "$STATUS" -eq 0 || "$STATUS" -eq 124 || "$STATUS" -eq 143 ]] || { + echo "AppImage desktop smoke exited unexpectedly: $STATUS" >&2 + exit 1 + } + test -d "$XDG_CONFIG_HOME/lsdj" + test -d "$XDG_DATA_HOME/lsdj" + test -d "$XDG_CACHE_HOME/lsdj" + + - name: Package verified release artifact + env: + LSDJ_RELEASE_REVISION: ${{ needs.validate.outputs.revision }} + shell: bash + run: | + set -euo pipefail + shopt -s nullglob + APPIMAGES=(src-tauri/target/release/bundle/appimage/*.AppImage) + [[ "${#APPIMAGES[@]}" -eq 1 ]] + python scripts/release_artifact.py create \ + --producer linux-x64 \ + --release-tag "$GITHUB_REF_NAME" \ + --revision "$LSDJ_RELEASE_REVISION" \ + --asset "${APPIMAGES[0]}" \ + --output-dir release-artifacts/linux-x64 + + - name: Upload verified Linux producer bundle + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: release-linux-x64 + path: release-artifacts/linux-x64 + if-no-files-found: error + retention-days: 14 + + - name: Upload Linux package audit evidence + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: linux-package-audit + path: ${{ runner.temp }}/linux-package-audit.json + if-no-files-found: error + retention-days: 14 + publish: name: Verify and publish complete release needs: - validate - produce_macos + - produce_linux if: >- needs.validate.result == 'success' && needs.produce_macos.result == 'success' && + needs.produce_linux.result == 'success' && github.repository == 'protocol-works/lsdj' && startsWith(github.ref, 'refs/tags/v') runs-on: ubuntu-latest @@ -280,6 +417,12 @@ jobs: name: release-macos-arm64 path: release-input/macos-arm64 + - name: Download Linux producer bundle + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: release-linux-x64 + path: release-input/linux-x64 + - name: Verify complete required producer set env: LSDJ_RELEASE_REVISION: ${{ needs.validate.outputs.revision }} @@ -289,6 +432,7 @@ jobs: python scripts/release_artifact.py verify \ --input-root release-input \ --required-producer macos-arm64 \ + --required-producer linux-x64 \ --release-tag "$GITHUB_REF_NAME" \ --revision "$LSDJ_RELEASE_REVISION" \ --output-dir verified-release diff --git a/README.md b/README.md index 5755f63..f7d1ee7 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,12 @@ pads and finished tracks come from Stable Audio 3. See All common tasks live in the [`justfile`](justfile) — run `just` to list them. +Linux x86_64 AppImage support is under active qualification. Packaging and +hosted CI are available, but a public Linux release remains gated on the +portable MRT2/Stable Audio backends, licensing, and real NVIDIA/audio/FLX4 +evidence. See [the Linux support status](docs/linux.md); do not infer hardware +support from a green hosted build. + ## Setup ```sh diff --git a/docs/cross-platform-ci-and-release.md b/docs/cross-platform-ci-and-release.md index 48b8173..a782c4e 100644 --- a/docs/cross-platform-ci-and-release.md +++ b/docs/cross-platform-ci-and-release.md @@ -54,21 +54,29 @@ contract. A fake result must not be reported as hardware qualification. ## Release producer/publisher boundary -The tag workflow keeps macOS as the only required release artifact initially. -It has three stages: +The tag workflow requires macOS and Linux release artifacts. It has three +stages: 1. `validate` accepts only a calendar-version `v*` tag whose commit is contained in `main`. -2. `produce-macos` waits behind the protected `macos-release` Environment, +2. Independent producers build their platform artifacts. `produce-macos` waits + behind the protected `macos-release` Environment, freezes the backend, imports ephemeral signing material, builds, signs, notarizes, staples, and verifies the app and DMG. It then uploads one Actions artifact containing the DMG, `SHA256SUMS.txt`, and metadata binding the - producer to the tag and exact source revision. + producer to the tag and exact source revision. `produce-linux` builds the + x86_64 AppImage on Ubuntu 22.04 (glibc 2.35), verifies its desktop/resource + layout and ELF dependencies, performs an isolated-XDG virtual-X11 smoke, and + uploads the AppImage with the same checksum/tag/revision contract. 3. `publish` is the only job with `contents: write`. It downloads every required producer bundle, requires the producer set to match exactly, recomputes all sizes and SHA-256 digests, and verifies tag/revision/platform metadata before it creates a GitHub Release. +Linux is fail-closed: a skipped or failed producer prevents the publisher from +running. This automated package smoke does not replace issue #112's NVIDIA, +Wayland/Xorg, audio, MIDI/FLX4, or suspend/resume hardware gate. + The publisher creates an unpublished draft, uploads the complete verified file set, checks GitHub's returned asset names, sizes, upload state, and SHA-256 digest, and only then makes the release public. A missing digest fails closed. diff --git a/docs/linux-qualification-checklist.md b/docs/linux-qualification-checklist.md new file mode 100644 index 0000000..86574f1 --- /dev/null +++ b/docs/linux-qualification-checklist.md @@ -0,0 +1,97 @@ +# Linux release qualification — issue #112 + +Hosted CI is not hardware qualification. Attach this completed record to issue +#112 for each proposed minimum configuration and for both a real Wayland and a +real Xorg session. + +## Exact environment + +- [ ] LSDJ tag and source revision: +- [ ] AppImage filename and SHA-256: +- [ ] Ubuntu version and kernel: +- [ ] Session type and desktop/compositor: +- [ ] CPU and RAM: +- [ ] GPU and VRAM: +- [ ] NVIDIA driver, PyTorch, CUDA runtime, MRT2 dependency/model revisions: +- [ ] Stable Audio/TFLite/model/LoRA revisions: +- [ ] Main/cue audio devices, sample formats/rates/channels, buffer frames: +- [ ] MIDI controller model, firmware, and raw ALSA port names: +- [ ] Relevant udev/session ACL configuration: + +## Clean install and desktop + +- [ ] On a clean Ubuntu 22.04+ x86_64 user account, checksum verification and + executable-bit setup lead to a normal AppImage launch without developer + tools, system Python, Git, a shell command, or a CUDA toolkit. +- [ ] Native titlebar/window behavior, file/folder dialogs, opener, Trash, and + every notification used by LSDJ behave correctly. +- [ ] Repeat on Wayland and Xorg, including paths/home names with spaces and + non-ASCII characters. +- [ ] Normal exit, forced app exit, and failed worker startup leave no worker + descendants. + +## Runtime/model installation + +- [ ] First download shows exact revisions, terms/links, storage, backend, and + driver compatibility before work begins. +- [ ] MRT2 and Stable Audio runtimes/models install from verified pins into XDG + assets/staging roots; corrupt, interrupted, cancelled, and failed updates + retain the previous verified version. +- [ ] Offline launch after successful installation does not invoke or require + system Python, `uv`, Git, shell tools, a CUDA toolkit, or network access. +- [ ] Insufficient disk, RAM/VRAM, incompatible driver, authentication, and + verification failures are actionable and redact credentials. + +## Audio and lifecycle + +- [ ] ALSA direct and PipeWire-ALSA paths enumerate and play the intended + devices; record which path each device used. +- [ ] Validate default-device selection/change, 48 kHz and non-48 kHz devices, + f32/i16/u16 where offered, mono/stereo/multichannel conversion, and + unsupported-layout errors. +- [ ] FLX4 combined routing sends master to channels 1/2 and cue to 3/4; split + main/cue routing also works. +- [ ] Unplug/replug, PipeWire restart, default-device change, suspend/resume, + and app restart recover clearly without callback stalls. + +## MIDI and FLX4 + +- [ ] FLX4 is usable without an unsafe blanket udev rule; record any required + group/session ACL or device-specific `uaccess` rule. +- [ ] ALSA port-name variants normalize for matching while the raw port remains + selectable and reconnects to the same device. +- [ ] Validate hotplug/reconnect, transport, mixer controls, jog wheels, pad + modes, LEDs, position-query SysEx, and the in-app MIDI monitor. +- [ ] DDJ-400 remains best-effort regression evidence, not a release blocker. + +## Sustained MRT2 performance + +- [ ] Run both decks for at least 10 minutes at 25 frames (approximately 1 s). +- [ ] Run both armed decks for at least 10 minutes at 5 frames (approximately + 200 ms). +- [ ] Both runs have zero **engine-reported** underruns. +- [ ] Capture p50/p95/p99 generation latency, queue depth, audio buffer settings, + CPU/RAM/VRAM, temperature, and throttling notes. + +## Stable Audio parity while decks remain live + +- [ ] Music and SFX generation. +- [ ] Audio-to-audio, continuation, and inpainting. +- [ ] Small and Medium models, positive/negative prompts, all exposed sampling + controls, LoRA selection/application, preview, output naming, and corrupt + output validation. +- [ ] Cancellation and long-duration validation, including the supported Medium + maximum, without blocking the audio callback or causing deck underruns. +- [ ] Record CPU/RAM use and the queue/constrain/pause policy used to protect + both live decks. + +## Release decision + +- [ ] #108 licensing/acknowledgement release gate complete. +- [ ] #110 production PyTorch MRT2 adapter complete and qualified. +- [ ] #111 Stable Audio TFLite adapter complete and qualified. +- [ ] Linux producer bundle, native dependency audit, checksum, and deterministic + tag/revision metadata pass the single-publisher verification. +- [ ] Known limitations and measured minimum CPU/RAM/GPU/VRAM/driver/storage + requirements are published. +- [ ] Issue #112 records an explicit go/no-go decision and links this evidence. diff --git a/docs/linux.md b/docs/linux.md new file mode 100644 index 0000000..6f41974 --- /dev/null +++ b/docs/linux.md @@ -0,0 +1,146 @@ +# Linux AppImage support + +LSDJ's Linux target is Ubuntu 22.04 or newer on x86_64 with a supported NVIDIA +GPU. The AppImage packaging, desktop configuration, XDG storage contract, and +fail-closed release producer are implemented as part of issue #112. A public +Linux release remains gated on the production PyTorch MRT2 backend (#110), the +portable Stable Audio TFLite backend (#111), the model licensing flow (#108), +and the real-hardware checklist below. + +Passing hosted CI proves that the shell compiles, the AppImage extracts, its +desktop metadata/resources are present, and it can open in a virtual X11 +session with paths containing spaces and Unicode. It does **not** qualify an +NVIDIA driver, PipeWire/ALSA device, Wayland compositor, FLX4, suspend/resume, +or model performance. + +## Install and verify + +Download the `.AppImage` and the release's `SHA256SUMS.txt`/producer metadata +from the same GitHub Release. Verify the checksum before launch, then make the +file executable and open it: + +```sh +sha256sum --check linux-x64-SHA256SUMS.txt +chmod +x LSDJ_*.AppImage +./LSDJ_*.AppImage +``` + +The AppImage is the only supported Linux package. `.deb`, `.rpm`, Flatpak, +Snap, ARM64, AMD/Intel GPU acceleration, and JACK-specific integration are not +part of the supported target. Other distributions may work, but are community +configurations until separately qualified. + +The application package does not invoke or require a system Python, `uv`, Git, +a shell, or a CUDA toolkit. Model adapters are installed into app-owned storage +from pinned, checksum-verified artifacts. If a managed adapter is absent or +invalid, the corresponding service reports unavailable instead of falling back +to a command from `PATH`. Missing adapters use the stable diagnostic identifiers +`runtime.unavailable.mrt2` and `runtime.unavailable.stableAudio`; user-facing +surfaces must localize those identifiers rather than displaying them verbatim. +A compatible NVIDIA **driver** is still required for MRT2; the minimum version +and VRAM floor remain unset until the #110 hardware qualification records +measured results. + +If FUSE is unavailable, AppImage's standard extract-and-run mode is a useful +diagnostic fallback: + +```sh +APPIMAGE_EXTRACT_AND_RUN=1 ./LSDJ_*.AppImage +``` + +The release gate still tests ordinary AppImage packaging; extract-and-run is +not a substitute for the clean-machine qualification. + +## Storage and first run + +Rust resolves the roots once and passes them explicitly to every service. The +default Linux layout is: + +| Purpose | Default path | +| --- | --- | +| Configuration | `$XDG_CONFIG_HOME/lsdj` or `~/.config/lsdj` | +| Durable data | `$XDG_DATA_HOME/lsdj` or `~/.local/share/lsdj` | +| Models/runtimes | `$XDG_DATA_HOME/lsdj/assets` | +| Same-filesystem install staging | `$XDG_DATA_HOME/lsdj/staging` | +| Disposable cache | `$XDG_CACHE_HOME/lsdj` or `~/.cache/lsdj` | + +The model manager owns first download, verification, installation, update, +rollback, cancellation, and recovery. Downloads that require upstream terms or +credentials remain blocked until #108's current-revision acknowledgement flow +authorizes them. A failed or interrupted update must leave the prior verified +runtime usable. + +## Audio: ALSA and PipeWire + +The Rust audio host uses CPAL's ALSA backend. On a PipeWire desktop, the +distribution's PipeWire ALSA compatibility layer routes those streams; a +PulseAudio desktop follows the same ALSA-facing application path. LSDJ does not +invoke `pw-*`, `pactl`, `aplay`, or another external audio utility. + +The in-app `platform_diagnostics` response records whether `/dev/snd`, the +PipeWire socket, and the Pulse socket are visible. It reports evidence only; a +socket's presence is not a successful audio-device test. The following stable +advisory codes are intended for localized UI/support surfaces: + +- `linux.audio.alsaDevicesMissing` +- `linux.session.notDetected` +- `linux.distribution.notSupported` + +Default-device changes, 44.1/48 kHz conversion, stereo and FLX4 four-channel +routing, device removal, PipeWire restart, and suspend/resume must all be +verified on real systems before release. + +## MIDI and device permissions + +Linux MIDI uses ALSA sequencer through `midir`. Port matching preserves the raw +ALSA name used to open the device while normalizing case, punctuation, and ALSA +client/port suffixes for FLX4/DDJ-400 identification. + +`platform_diagnostics` reports `/dev/snd/seq` as `available`, +`permissionDenied`, or `missing` without opening a sequencer client. Its stable +advisory codes are: + +- `linux.midi.sequencerPermissionDenied` +- `linux.midi.sequencerMissing` + +Ubuntu desktop sessions normally grant sound-device access through logind/udev. +If access is denied, first reconnect the controller and sign out/in so the +session ACL can refresh. On a system administered through the traditional +`audio` group, an administrator may add the user to that group and require a +new login. Do not install a blanket world-writable udev rule. A custom rule, if +the distribution truly needs one, must match the controller's measured vendor +and product IDs and grant active-session `uaccess`; record that rule and the +`udevadm info` evidence in the qualification report. + +## Desktop integration + +The Linux overlay uses the desktop's normal decorated titlebar and produces a +single Audio/Music `.desktop` entry and icon. Tauri's native dialog, opener, and +trash integrations remain scoped through Rust; the webview receives no general +filesystem/opener permission. Hosted CI verifies the packaged entry point, +resource layout, executable bits, ELF dependency inventory, and a virtual-X11 +launch using isolated XDG roots with spaces and non-ASCII characters. + +Real Wayland and Xorg sessions must still validate window behavior, native file +and folder dialogs, opener/trash behavior, notifications used by the app, +multi-monitor/scale behavior, and clean shutdown. No notification behavior is +claimed merely because the package launches in Xvfb. + +## Diagnostics and support bundle facts + +The `platform_diagnostics` command exposes: + +- OS/architecture and Ubuntu support classification; +- detected Wayland/X11 session type; +- the resolved config/data/cache/assets/staging roots; +- ALSA, PipeWire/Pulse socket, and MIDI-sequencer evidence; +- `developerFallbackAllowed` (always `false` in the AppImage); and +- runtime mode (`managed` for the Linux package). + +These facts contain no tokens and do not execute external diagnostic tools. +Model/runtime revisions, NVIDIA driver/VRAM, generation latency, queue depth, +and underruns belong to the #110/#111 service diagnostics once those adapters +are integrated. + +See [the Linux qualification checklist](linux-qualification-checklist.md) for +the evidence required before calling the platform supported. diff --git a/docs/native-packaging.md b/docs/native-packaging.md index 52e7029..c3836ec 100644 --- a/docs/native-packaging.md +++ b/docs/native-packaging.md @@ -8,6 +8,16 @@ Spike C ([`docs/spike-c-midi.md`](spike-c-midi.md), the Tauri MIDI app) — so t steps below are reproducible on a Mac with an Apple Developer ID and are also enforced by the protected release workflow. +Linux uses the same shared Tauri base with +[`tauri.linux.conf.json`](../src-tauri/tauri.linux.conf.json), while macOS-only +window and bundle settings live in +[`tauri.macos.conf.json`](../src-tauri/tauri.macos.conf.json). The Linux +AppImage is built on Ubuntu 22.04 through `just tauri-linux-release`. Its +`managed-runtime` feature refuses every developer-tool fallback until #110/#111 +supply an explicit verified adapter executable. Packaging, XDG/audio/MIDI +diagnostics, and qualification details are in +[`linux.md`](linux.md). + ## 1. Freeze the backend runtime ```sh diff --git a/justfile b/justfile index 0ba2f6e..3add377 100644 --- a/justfile +++ b/justfile @@ -72,7 +72,12 @@ build: # default `uv run` sidecar/generation commands use the backend project dir; # override each command in dev, or point both at a freeze with LSDJ_BACKEND_BIN. tauri-dev: build - cd src-tauri && cargo tauri dev + cd src-tauri && cargo tauri dev --config tauri.macos.conf.json + +# Linux developer shell. Model services use source-tree overrides in dev; the +# distributable AppImage instead compiles the fail-closed managed-runtime seam. +tauri-linux-dev: build + cd src-tauri && cargo tauri dev --config tauri.linux.conf.json # Freeze the shared Python backend into an ONEDIR binary for bundling # (src-tauri/sidecar-dist/lsdj_backend). It serves deck inference, model tooling, @@ -90,7 +95,7 @@ freeze-sidecar: freeze-backend # useful for local testing only; use `just tauri-release` for anything sent to # another Mac. tauri-build: build - cd src-tauri && cargo tauri build + cd src-tauri && cargo tauri build --config tauri.macos.conf.json # Distributable macOS build. Fails closed unless a Developer ID Application # identity and Apple notarization credentials are configured, then verifies the @@ -98,6 +103,13 @@ tauri-build: build tauri-release: ./scripts/build-macos-release.sh +# Linux x86_64 AppImage built on an Ubuntu 22.04-compatible host. Model +# runtimes remain app-managed and fail closed until their verified #110/#111 +# adapters supply explicit executables; no packaged path invokes developer +# Python/uv/Git/shell tooling. +tauri-linux-release: + ./scripts/build-linux-appimage.sh + # Create and push the next protected vYYYY.MM.N tag from a clean, current main. # Remote calendar-version tags are the ledger; no version file or bump is needed. # The tag starts the macOS signing workflow and its Engineering approval gate. diff --git a/scripts/build-linux-appimage.sh b/scripts/build-linux-appimage.sh new file mode 100755 index 0000000..d99c9fb --- /dev/null +++ b/scripts/build-linux-appimage.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +# Build and verify LSDJ's Ubuntu 22.04-compatible x86_64 AppImage. Model +# runtimes/weights are external, verified, and app-managed; this shell exists +# only on the release builder and is not part of the installed runtime. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +LINUX_CONFIG="$REPO_ROOT/src-tauri/tauri.linux.conf.json" +RELEASE_VERSION="${LSDJ_RELEASE_VERSION:-}" +AUDIT_PATH="${LSDJ_LINUX_AUDIT_PATH:-$REPO_ROOT/src-tauri/target/release/bundle/appimage/linux-package-audit.json}" + +fail() { + echo "Linux release: $*" >&2 + exit 1 +} + +[ "$(uname -s)" = "Linux" ] || fail "must be built on Linux" +[ "$(uname -m)" = "x86_64" ] || fail "must be built for x86_64" + +GLIBC_DESCRIPTION="$(getconf GNU_LIBC_VERSION 2>/dev/null || true)" +[[ "$GLIBC_DESCRIPTION" =~ ^glibc\ ([0-9]+)\.([0-9]+)$ ]] || fail \ + "an auditable glibc build host is required" +GLIBC_MAJOR="${BASH_REMATCH[1]}" +GLIBC_MINOR="${BASH_REMATCH[2]}" +if (( GLIBC_MAJOR > 2 || (GLIBC_MAJOR == 2 && GLIBC_MINOR > 35) )); then + fail "build host glibc $GLIBC_MAJOR.$GLIBC_MINOR is newer than Ubuntu 22.04's 2.35 floor" +fi + +VERSION_ARGS=() +if [ -n "$RELEASE_VERSION" ]; then + [[ "$RELEASE_VERSION" =~ ^v?([0-9]{4})\.(0[1-9]|1[0-2])\.([1-9][0-9]*)$ ]] || fail \ + "LSDJ_RELEASE_VERSION must look like vYYYY.MM.N with a positive release number" + RELEASE_YEAR=$((10#${BASH_REMATCH[1]})) + RELEASE_MONTH=$((10#${BASH_REMATCH[2]})) + RELEASE_NUMBER=$((10#${BASH_REMATCH[3]})) + RELEASE_VERSION="${RELEASE_YEAR}.${RELEASE_MONTH}.${RELEASE_NUMBER}" + VERSION_ARGS=(--config "{\"version\":\"$RELEASE_VERSION\"}") +fi + +echo "Linux release: building frontend" +npm run build --prefix "$REPO_ROOT/frontend" + +echo "Linux release: building AppImage on glibc $GLIBC_MAJOR.$GLIBC_MINOR" +( + cd "$REPO_ROOT/src-tauri" + cargo tauri build --ci \ + --features managed-runtime \ + --config "$LINUX_CONFIG" \ + "${VERSION_ARGS[@]}" +) + +shopt -s nullglob +APPIMAGES=("$REPO_ROOT"/src-tauri/target/release/bundle/appimage/*.AppImage) +[[ "${#APPIMAGES[@]}" -eq 1 ]] || fail \ + "expected exactly one AppImage, found ${#APPIMAGES[@]}" + +python3 "$REPO_ROOT/scripts/verify_linux_appimage.py" \ + "${APPIMAGES[0]}" \ + --output "$AUDIT_PATH" + +echo "Linux release: verified ${APPIMAGES[0]}" +echo "Linux release: native dependency audit $AUDIT_PATH" diff --git a/scripts/build-macos-release.sh b/scripts/build-macos-release.sh index 668889b..d528195 100755 --- a/scripts/build-macos-release.sh +++ b/scripts/build-macos-release.sh @@ -11,6 +11,7 @@ DMG_DIR="$REPO_ROOT/src-tauri/target/release/bundle/dmg" BACKEND_DIR="$REPO_ROOT/src-tauri/sidecar-dist/lsdj_backend" BACKEND_BIN="$BACKEND_DIR/lsdj_backend" RELEASE_CONFIG="$REPO_ROOT/src-tauri/tauri.release.conf.json" +MACOS_CONFIG="$REPO_ROOT/src-tauri/tauri.macos.conf.json" ENTITLEMENTS="$REPO_ROOT/src-tauri/entitlements.plist" RELEASE_VERSION="${LSDJ_RELEASE_VERSION:-}" EXPECTED_BUNDLE_ID="works.protocol.lsdj" @@ -136,11 +137,13 @@ echo "macOS release: building, signing, notarizing, and stapling" if [ -n "$RELEASE_VERSION" ]; then cargo tauri build --ci \ --features bundled-backend \ + --config "$MACOS_CONFIG" \ --config "$RELEASE_CONFIG" \ --config "{\"version\":\"$RELEASE_VERSION\"}" else cargo tauri build --ci \ --features bundled-backend \ + --config "$MACOS_CONFIG" \ --config "$RELEASE_CONFIG" fi ) diff --git a/scripts/release_artifact.py b/scripts/release_artifact.py index 30db2d4..3a1100a 100644 --- a/scripts/release_artifact.py +++ b/scripts/release_artifact.py @@ -51,9 +51,9 @@ class ProducerPolicy: asset_count: int -# macOS is the sole required release producer initially. Adding a platform is -# an explicit policy change: add its producer here and to the publisher's -# --required-producer list in the workflow in the same reviewed change. +# Every policy entry is required. Adding a platform is an explicit fail-closed +# change: add its producer here and to the publisher's --required-producer list +# in the workflow in the same reviewed change. PRODUCER_POLICIES = { "macos-arm64": ProducerPolicy( platform="macos", @@ -61,6 +61,12 @@ class ProducerPolicy: asset_suffix=".dmg", asset_count=1, ), + "linux-x64": ProducerPolicy( + platform="linux", + architecture="x86_64", + asset_suffix=".appimage", + asset_count=1, + ), } diff --git a/scripts/tests/test_release_artifact.py b/scripts/tests/test_release_artifact.py index 98d438e..315dffe 100644 --- a/scripts/tests/test_release_artifact.py +++ b/scripts/tests/test_release_artifact.py @@ -24,23 +24,34 @@ class ReleaseArtifactTest(unittest.TestCase): def setUp(self): self.temporary = tempfile.TemporaryDirectory() self.root = Path(self.temporary.name) - self.asset = self.root / "LSDJ_2026.08.7_aarch64.dmg" - self.asset.write_bytes(b"verified dmg bytes") + self.macos_asset = self.root / "LSDJ_2026.08.7_aarch64.dmg" + self.macos_asset.write_bytes(b"verified dmg bytes") + self.linux_asset = self.root / "LSDJ_2026.08.7_amd64.AppImage" + self.linux_asset.write_bytes(b"verified appimage bytes") def tearDown(self): self.temporary.cleanup() - def create_bundle(self): - bundle = self.root / "incoming" / "macos-arm64" + def create_bundle(self, producer="macos-arm64"): + asset = { + "macos-arm64": self.macos_asset, + "linux-x64": self.linux_asset, + }[producer] + bundle = self.root / "incoming" / producer release_artifact.create_bundle( - producer="macos-arm64", + producer=producer, release_tag=TAG, revision=REVISION, - assets=[self.asset], + assets=[asset], output_dir=bundle, ) return bundle + def create_all_bundles(self): + self.create_bundle("macos-arm64") + self.create_bundle("linux-x64") + return self.root / "incoming" + def draft_release(self, assets, **updates): data = { "id": 12345, @@ -54,12 +65,12 @@ def draft_release(self, assets, **updates): return data def test_create_and_verify_bundle(self): - bundle = self.create_bundle() + incoming = self.create_all_bundles() output = self.root / "verified" release_artifact.verify_bundles( - input_root=bundle.parent, - required_producers=["macos-arm64"], + input_root=incoming, + required_producers=["macos-arm64", "linux-x64"], release_tag=TAG, revision=REVISION, output_dir=output, @@ -68,32 +79,35 @@ def test_create_and_verify_bundle(self): self.assertEqual( {path.name for path in output.iterdir()}, { - self.asset.name, + self.macos_asset.name, + self.linux_asset.name, "macos-arm64-release-metadata.json", "macos-arm64-SHA256SUMS.txt", + "linux-x64-release-metadata.json", + "linux-x64-SHA256SUMS.txt", "release-index.json", }, ) index = json.loads((output / "release-index.json").read_text()) self.assertEqual(index["release_tag"], TAG) self.assertEqual(index["revision"], REVISION) - self.assertEqual(index["producers"], ["macos-arm64"]) + self.assertEqual(index["producers"], ["linux-x64", "macos-arm64"]) def test_tampered_asset_fails_closed(self): - bundle = self.create_bundle() - (bundle / self.asset.name).write_bytes(b"tampered") + incoming = self.create_all_bundles() + (incoming / "macos-arm64" / self.macos_asset.name).write_bytes(b"tampered") with self.assertRaisesRegex(release_artifact.ArtifactError, "size|checksum"): release_artifact.verify_bundles( - input_root=bundle.parent, - required_producers=["macos-arm64"], + input_root=incoming, + required_producers=["macos-arm64", "linux-x64"], release_tag=TAG, revision=REVISION, output_dir=self.root / "verified", ) def test_empty_installer_fails_closed(self): - self.asset.write_bytes(b"") + self.macos_asset.write_bytes(b"") with self.assertRaisesRegex( release_artifact.ArtifactError, "must not be empty" @@ -115,39 +129,39 @@ def test_missing_required_producer_fails_closed(self): with self.assertRaisesRegex(release_artifact.ArtifactError, "producer set"): release_artifact.verify_bundles( input_root=incoming, - required_producers=["macos-arm64"], + required_producers=["macos-arm64", "linux-x64"], release_tag=TAG, revision=REVISION, output_dir=self.root / "verified", ) def test_unexpected_bundle_file_fails_closed(self): - bundle = self.create_bundle() - (bundle / "surprise.txt").write_text("not declared") + incoming = self.create_all_bundles() + (incoming / "macos-arm64" / "surprise.txt").write_text("not declared") with self.assertRaisesRegex(release_artifact.ArtifactError, "unexpected"): release_artifact.verify_bundles( - input_root=bundle.parent, - required_producers=["macos-arm64"], + input_root=incoming, + required_producers=["macos-arm64", "linux-x64"], release_tag=TAG, revision=REVISION, output_dir=self.root / "verified", ) def test_wrong_release_identity_fails_closed(self): - bundle = self.create_bundle() + incoming = self.create_all_bundles() with self.assertRaisesRegex(release_artifact.ArtifactError, "release_tag"): release_artifact.verify_bundles( - input_root=bundle.parent, - required_producers=["macos-arm64"], + input_root=incoming, + required_producers=["macos-arm64", "linux-x64"], release_tag="v2026.08.8", revision=REVISION, output_dir=self.root / "verified", ) def test_required_producer_arguments_must_exactly_match_policy(self): - bundle = self.create_bundle() + incoming = self.create_all_bundles() windows_policy = release_artifact.ProducerPolicy( platform="windows", architecture="x86_64", @@ -163,8 +177,8 @@ def test_required_producer_arguments_must_exactly_match_policy(self): release_artifact.ArtifactError, "release policy" ): release_artifact.verify_bundles( - input_root=bundle.parent, - required_producers=["macos-arm64"], + input_root=incoming, + required_producers=["macos-arm64", "linux-x64"], release_tag=TAG, revision=REVISION, output_dir=self.root / "verified", @@ -329,7 +343,10 @@ def test_release_workflow_keeps_one_least_privilege_publisher(self): self.assertEqual(workflow.count("contents: write"), 1) self.assertEqual(len(re.findall(r"^ publish:$", workflow, re.MULTILINE)), 1) self.assertIn("needs.produce_macos.result == 'success'", workflow) + self.assertIn("needs.produce_linux.result == 'success'", workflow) self.assertIn("--required-producer macos-arm64", workflow) + self.assertIn("--required-producer linux-x64", workflow) + self.assertIn("runs-on: ubuntu-22.04", workflow) self.assertRegex(workflow, r"(?m)^on:\n push:\n tags:$") self.assertNotIn("pull_request:", workflow) self.assertNotIn("workflow_dispatch:", workflow) @@ -371,8 +388,9 @@ def test_official_actions_are_immutably_pinned(self): def test_windows_ci_has_no_forced_bash_steps(self): workflow = (REPO_ROOT / ".github/workflows/ci.yml").read_text() - self.assertNotIn("shell: bash", workflow) - self.assertIn("if: runner.os == 'Linux'", workflow) + shared = workflow[: workflow.index(" linux_appimage:")] + self.assertNotIn("shell: bash", shared) + self.assertIn("if: runner.os == 'Linux'", shared) if __name__ == "__main__": diff --git a/scripts/tests/test_verify_linux_appimage.py b/scripts/tests/test_verify_linux_appimage.py new file mode 100644 index 0000000..aa34e26 --- /dev/null +++ b/scripts/tests/test_verify_linux_appimage.py @@ -0,0 +1,72 @@ +import importlib.util +import stat +import sys +import tempfile +import unittest +from pathlib import Path + + +MODULE_PATH = Path(__file__).parents[1] / "verify_linux_appimage.py" +SPEC = importlib.util.spec_from_file_location("verify_linux_appimage", MODULE_PATH) +assert SPEC is not None and SPEC.loader is not None +verify_linux_appimage = importlib.util.module_from_spec(SPEC) +sys.modules[SPEC.name] = verify_linux_appimage +SPEC.loader.exec_module(verify_linux_appimage) + + +class AppImageLayoutTest(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory() + self.root = Path(self.temporary.name) + (self.root / "AppRun").write_text("entry") + (self.root / "lsdj-app.png").write_bytes(b"png") + (self.root / "lsdj-app.desktop").write_text( + "[Desktop Entry]\n" + "Type=Application\n" + "Name=LSDJ\n" + "Exec=lsdj-app\n" + "Icon=lsdj-app\n" + "Categories=AudioVideo;Audio;\n" + ) + binary = self.root / "usr/bin/lsdj-app" + binary.parent.mkdir(parents=True) + binary.write_bytes(b"elf") + binary.chmod(binary.stat().st_mode | stat.S_IXUSR) + + def tearDown(self): + self.temporary.cleanup() + + def test_desktop_and_binary_layout_produce_deterministic_audit(self): + audit = verify_linux_appimage.verify_extracted( + self.root, ["libasound.so.2", "libc.so.6"] + ) + + self.assertEqual(audit["architecture"], "x86_64") + self.assertEqual(audit["desktop"]["name"], "LSDJ") + self.assertEqual(audit["elfNeeded"], ["libasound.so.2", "libc.so.6"]) + + def test_missing_audio_category_fails(self): + (self.root / "lsdj-app.desktop").write_text( + "[Desktop Entry]\nType=Application\nName=LSDJ\nExec=lsdj-app\n" + ) + + with self.assertRaisesRegex( + verify_linux_appimage.AppImageError, "audio/music category" + ): + verify_linux_appimage.verify_extracted(self.root, ["libc.so.6"]) + + def test_duplicate_desktop_keys_fail_closed(self): + (self.root / "lsdj-app.desktop").write_text( + "[Desktop Entry]\n" + "Type=Application\nName=LSDJ\nName=Other\n" + "Exec=lsdj-app\nCategories=Audio;\n" + ) + + with self.assertRaisesRegex( + verify_linux_appimage.AppImageError, "duplicate desktop key" + ): + verify_linux_appimage.verify_extracted(self.root, ["libc.so.6"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/verify_linux_appimage.py b/scripts/verify_linux_appimage.py new file mode 100755 index 0000000..b7c8ede --- /dev/null +++ b/scripts/verify_linux_appimage.py @@ -0,0 +1,166 @@ +#!/usr/bin/env python3 +"""Verify an LSDJ x86_64 AppImage and emit deterministic package audit data.""" + +from __future__ import annotations + +import argparse +import json +import re +import shutil +import stat +import subprocess +import tempfile +from pathlib import Path + + +class AppImageError(RuntimeError): + """The produced AppImage violated the Linux package contract.""" + + +def require(condition: bool, message: str) -> None: + if not condition: + raise AppImageError(message) + + +def desktop_entries(path: Path) -> dict[str, str]: + require(path.is_file() and not path.is_symlink(), f"unsafe desktop entry: {path}") + entries: dict[str, str] = {} + section = "" + for line in path.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if not line or line.startswith("#"): + continue + if line.startswith("[") and line.endswith("]"): + section = line[1:-1] + continue + if section != "Desktop Entry" or "=" not in line: + continue + key, value = line.split("=", 1) + if key in entries: + raise AppImageError(f"duplicate desktop key: {key}") + entries[key] = value + return entries + + +def needed_libraries(binary: Path) -> list[str]: + readelf = shutil.which("readelf") + require(readelf is not None, "readelf is required for the build-time ELF audit") + result = subprocess.run( + [readelf, "--dynamic", str(binary)], + check=True, + capture_output=True, + text=True, + ) + needed = sorted(set(re.findall(r"\(NEEDED\).*?\[(.+?)\]", result.stdout))) + require(needed, "packaged executable has no ELF NEEDED entries") + lowered = {library.casefold() for library in needed} + forbidden = { + library + for library in lowered + if "python" in library + or library.startswith("libcuda.") + or library.startswith("libcudart.") + } + require( + not forbidden, + "AppImage shell must not link system Python/CUDA libraries: " + + ", ".join(sorted(forbidden)), + ) + return needed + + +def verify_extracted(root: Path, libraries: list[str]) -> dict: + require(root.is_dir() and not root.is_symlink(), "missing extracted AppImage root") + app_run = root / "AppRun" + require(app_run.exists(), "AppImage has no AppRun entry point") + + desktop_files = sorted(root.glob("*.desktop")) + require(len(desktop_files) == 1, "AppImage must contain exactly one desktop entry") + desktop = desktop_entries(desktop_files[0]) + require(desktop.get("Type") == "Application", "desktop Type must be Application") + require(desktop.get("Name") == "LSDJ", "desktop Name must be LSDJ") + require("lsdj-app" in desktop.get("Exec", ""), "desktop Exec must launch lsdj-app") + categories = {item for item in desktop.get("Categories", "").split(";") if item} + require( + bool(categories & {"Audio", "AudioVideo", "Music"}), + "desktop entry must advertise an audio/music category", + ) + + binary = root / "usr/bin/lsdj-app" + require( + binary.is_file() and not binary.is_symlink(), + "AppImage has no safe lsdj-app binary", + ) + require( + binary.stat().st_mode & stat.S_IXUSR != 0, + "packaged lsdj-app binary is not executable", + ) + require(any(root.glob("*.png")), "AppImage root has no desktop icon") + + return { + "architecture": "x86_64", + "desktop": { + "categories": sorted(categories), + "exec": desktop["Exec"], + "file": desktop_files[0].name, + "icon": desktop.get("Icon"), + "name": desktop["Name"], + }, + "elfNeeded": sorted(libraries), + "platform": "linux", + "schemaVersion": 1, + } + + +def verify_appimage(appimage: Path) -> dict: + require( + appimage.is_file() and not appimage.is_symlink(), + f"AppImage must be a regular non-symlink file: {appimage}", + ) + require(appimage.suffix == ".AppImage", "artifact must end in .AppImage") + require(appimage.stat().st_size > 0, "AppImage must not be empty") + require( + appimage.stat().st_mode & stat.S_IXUSR != 0, + "AppImage must have its executable bit set", + ) + + with tempfile.TemporaryDirectory(prefix="lsdj-appimage-") as temporary: + extraction_dir = Path(temporary) + result = subprocess.run( + [str(appimage.resolve()), "--appimage-extract"], + cwd=extraction_dir, + check=False, + capture_output=True, + text=True, + ) + require( + result.returncode == 0, + f"AppImage extraction failed ({result.returncode}): {result.stderr[-2000:]}", + ) + root = extraction_dir / "squashfs-root" + binary = root / "usr/bin/lsdj-app" + libraries = needed_libraries(binary) + audit = verify_extracted(root, libraries) + audit["artifact"] = appimage.name + return audit + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("appimage", type=Path) + parser.add_argument("--output", type=Path, required=True) + args = parser.parse_args() + audit = verify_appimage(args.appimage) + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text( + json.dumps(audit, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + newline="\n", + ) + + +if __name__ == "__main__": + try: + main() + except (AppImageError, OSError, subprocess.SubprocessError) as error: + raise SystemExit(f"Linux package verification failed: {error}") from error diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index c6d51d0..9a8d79c 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -20,6 +20,11 @@ path = "src/main.rs" # runtime and startup must resolve it from Contents/Resources. Developer builds # omit the feature and retain the uv-based source-tree commands. bundled-backend = [] +# Portable release contract: Linux/Windows model runtimes are installed and +# selected by the app-owned adapters from issues #110/#111. Until an adapter +# supplies an explicit executable, packaged builds fail closed instead of +# falling through to a developer machine's `uv`, Python, Git, or shell tools. +managed-runtime = [] # The cargo WORKSPACE. The app is the root package; the audio engine is a member # library crate so it stays headless-testable (`cargo test -p lsdj-engine`) diff --git a/src-tauri/src/generation.rs b/src-tauri/src/generation.rs index b568428..94f3383 100644 --- a/src-tauri/src/generation.rs +++ b/src-tauri/src/generation.rs @@ -121,6 +121,13 @@ pub fn generation_command(port: u16) -> io::Result { return Ok(cmd); } + // A portable package must wait for #110/#111's verified app-managed + // adapter. Never turn a missing runtime into an implicit dependency on a + // user's system Python, `uv`, Git, or shell. + if !crate::runtime_launch::developer_fallback_allowed() { + return Err(crate::runtime_launch::unavailable("stableAudio")); + } + let overridden = std::env::var("LSDJ_GENERATION_CMD"); let spec = overridden .clone() @@ -140,7 +147,7 @@ pub fn generation_command(port: u16) -> io::Result { Ok(cmd) } -#[cfg(test)] +#[cfg(all(test, not(feature = "managed-runtime")))] mod tests { use super::*; @@ -163,4 +170,5 @@ mod tests { std::env::remove_var("LSDJ_GENERATION_CMD"); } + } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 8766867..95d9ea7 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -48,8 +48,10 @@ mod loras; mod mcp; mod midi; mod models; +mod platform_diagnostics; mod platform_paths; mod runtime_installer; +mod runtime_launch; mod samples; mod settings; mod sidecar; @@ -773,6 +775,7 @@ pub fn run() { }) .invoke_handler(tauri::generate_handler![ app_info, + platform_diagnostics::platform_diagnostics, rotate_mcp_token, set_mcp_port, list_output_devices, diff --git a/src-tauri/src/midi/drivers.rs b/src-tauri/src/midi/drivers.rs index fafa9de..4581f80 100644 --- a/src-tauri/src/midi/drivers.rs +++ b/src-tauri/src/midi/drivers.rs @@ -45,11 +45,25 @@ pub const DRIVERS: [Driver; 2] = [ }, ]; -/// The first registry driver whose fragment the port name contains, or `None` -/// for a non-controller port (which the service attaches as a keyboard-note -/// source instead). +/// Normalize the display punctuation ALSA/CoreMIDI/WinMM may add around a USB +/// product name while retaining only identity-bearing alphanumerics. The raw +/// name is still used to open and persist the exact port. +fn identity(name: &str) -> String { + name.chars() + .filter(|character| character.is_alphanumeric()) + .flat_map(char::to_uppercase) + .collect() +} + +/// The first registry driver whose normalized fragment occurs in the normalized +/// port name, or `None` for a non-controller port. ALSA commonly reports names +/// such as `DDJ-FLX4:DDJ-FLX4 MIDI 1 24:0`; punctuation/case must not make that +/// class-compliant controller disappear. pub fn driver_for_name(name: &str) -> Option<&'static Driver> { - DRIVERS.iter().find(|d| name.contains(d.name_fragment)) + let name = identity(name); + DRIVERS + .iter() + .find(|driver| name.contains(&identity(driver.name_fragment))) } #[cfg(test)] @@ -60,6 +74,14 @@ mod tests { fn matches_ports_by_fragment_in_registry_order() { assert_eq!(driver_for_name("DDJ-FLX4").map(|d| d.id), Some("flx4")); assert_eq!(driver_for_name("Pioneer DDJ-FLX4 MIDI 1").map(|d| d.id), Some("flx4")); + assert_eq!( + driver_for_name("ddj-flx4:ddj-flx4 midi 1 24:0").map(|d| d.id), + Some("flx4") + ); + assert_eq!( + driver_for_name("AlphaTheta DDJ FLX4 MIDI 1").map(|d| d.id), + Some("flx4") + ); assert_eq!(driver_for_name("DDJ-400").map(|d| d.id), Some("ddj400")); assert_eq!(driver_for_name("IAC Driver Bus 1").map(|d| d.id), None); assert_eq!(driver_for_name("KeyLab 61").map(|d| d.id), None); diff --git a/src-tauri/src/midi/mod.rs b/src-tauri/src/midi/mod.rs index 89b7eb8..f05e46e 100644 --- a/src-tauri/src/midi/mod.rs +++ b/src-tauri/src/midi/mod.rs @@ -19,7 +19,7 @@ //! proceeds, intent kinds migrate from the forward list to native //! application without touching the transport or the translator. //! -//! Input callbacks run on CoreMIDI threads: they translate, route, and +//! Input callbacks run on the platform MIDI backend's threads: they translate, route, and //! return — the heavy lifting (LED frames, beat math) lives on the painter //! and scheduler threads. Nothing here goes near the cpal callback. @@ -234,6 +234,7 @@ impl MidiService { // Dropping the wrapper is fine: coremidi 0.9 never disposes clients // (its `Drop` is deliberately disabled upstream), so the underlying // client — and the delivery it anchors — lives as long as the app. + #[cfg(target_os = "macos")] if let Err(e) = MidiInput::new("LSDJ hot-plug anchor") { eprintln!("lsdj-app: midi hot-plug anchor failed: {e}"); } @@ -475,10 +476,13 @@ fn bind_output(shared: &Arc, driver: &Driver, name: &str) { return; } }; - let port = output - .ports() - .into_iter() - .find(|p| output.port_name(p).is_ok_and(|n| n.contains(driver.name_fragment))); + let port = output.ports().into_iter().find(|port| { + output + .port_name(port) + .ok() + .and_then(|name| driver_for_name(&name)) + .is_some_and(|candidate| candidate.id == driver.id) + }); let Some(port) = port else { eprintln!("lsdj-app: no midi output port for '{name}'"); return; diff --git a/src-tauri/src/platform_diagnostics.rs b/src-tauri/src/platform_diagnostics.rs new file mode 100644 index 0000000..60125cb --- /dev/null +++ b/src-tauri/src/platform_diagnostics.rs @@ -0,0 +1,349 @@ +//! Structured desktop/runtime diagnostics for platform support. +//! +//! The command returns facts and stable advisory codes, not prose. The webview +//! can localise those codes, support bundles can retain the evidence, and a +//! headless test can validate classification without pretending that CI has a +//! real PipeWire/ALSA/MIDI desktop. + +use std::path::Path; + +#[cfg(any(target_os = "linux", test))] +use std::collections::HashMap; + +use serde::Serialize; + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PlatformDiagnostics { + platform: &'static str, + architecture: &'static str, + runtime_mode: &'static str, + developer_fallback_allowed: bool, + roots: RootDiagnostics, + linux: Option, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct RootDiagnostics { + config: String, + data: String, + cache: String, + assets: String, + staging: String, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct LinuxDiagnostics { + distribution_id: Option, + distribution_version: Option, + distribution_support: &'static str, + session_type: &'static str, + audio_backend: &'static str, + pipewire_socket_detected: bool, + pulse_socket_detected: bool, + alsa_devices_detected: bool, + midi_sequencer_access: &'static str, + advisories: Vec<&'static str>, +} + +#[cfg(any(target_os = "linux", test))] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct LinuxEvidence { + pipewire_socket: bool, + pulse_socket: bool, + alsa_devices: bool, + midi_sequencer: MidiSequencerAccess, +} + +#[cfg(any(target_os = "linux", test))] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum MidiSequencerAccess { + Available, + PermissionDenied, + Missing, +} + +#[cfg(any(target_os = "linux", test))] +impl MidiSequencerAccess { + const fn as_str(self) -> &'static str { + match self { + Self::Available => "available", + Self::PermissionDenied => "permissionDenied", + Self::Missing => "missing", + } + } +} + +/// Return the host facts that explain Linux path, desktop-session, audio, MIDI, +/// and runtime-launch behavior. No probe opens an audio or MIDI stream. +#[tauri::command] +pub fn platform_diagnostics() -> PlatformDiagnostics { + let paths = crate::platform_paths::get(); + let roots = RootDiagnostics { + config: display(paths.config()), + data: display(paths.data()), + cache: display(paths.cache()), + assets: display(paths.assets()), + staging: display(paths.staging()), + }; + PlatformDiagnostics { + platform: std::env::consts::OS, + architecture: std::env::consts::ARCH, + runtime_mode: crate::runtime_launch::mode(), + developer_fallback_allowed: crate::runtime_launch::developer_fallback_allowed(), + roots, + linux: collect_linux(), + } +} + +fn display(path: &Path) -> String { + path.to_string_lossy().into_owned() +} + +#[cfg(target_os = "linux")] +fn collect_linux() -> Option { + let os_release = std::fs::read_to_string("/etc/os-release").unwrap_or_default(); + let distribution = parse_os_release(&os_release); + let distribution_id = distribution.get("ID").cloned(); + let distribution_version = distribution.get("VERSION_ID").cloned(); + let distribution_support = distribution_support( + distribution_id.as_deref(), + distribution_version.as_deref(), + ); + let session_type = session_type(|name| std::env::var(name).ok()); + + let runtime_dir = std::env::var_os("XDG_RUNTIME_DIR") + .map(std::path::PathBuf::from) + .filter(|path| path.is_absolute()); + let evidence = LinuxEvidence { + pipewire_socket: runtime_dir + .as_ref() + .is_some_and(|dir| dir.join("pipewire-0").exists()), + pulse_socket: runtime_dir + .as_ref() + .is_some_and(|dir| dir.join("pulse/native").exists()), + alsa_devices: Path::new("/dev/snd").is_dir(), + midi_sequencer: midi_sequencer_access(Path::new("/dev/snd/seq")), + }; + Some(linux_diagnostics( + distribution_id, + distribution_version, + distribution_support, + session_type, + evidence, + )) +} + +#[cfg(not(target_os = "linux"))] +fn collect_linux() -> Option { + None +} + +#[cfg(any(target_os = "linux", test))] +fn linux_diagnostics( + distribution_id: Option, + distribution_version: Option, + distribution_support: &'static str, + session_type: &'static str, + evidence: LinuxEvidence, +) -> LinuxDiagnostics { + let audio_backend = if evidence.pipewire_socket { + "pipewireAlsa" + } else if evidence.pulse_socket { + "pulseAlsa" + } else { + "alsa" + }; + LinuxDiagnostics { + distribution_id, + distribution_version, + distribution_support, + session_type, + audio_backend, + pipewire_socket_detected: evidence.pipewire_socket, + pulse_socket_detected: evidence.pulse_socket, + alsa_devices_detected: evidence.alsa_devices, + midi_sequencer_access: evidence.midi_sequencer.as_str(), + advisories: advisory_codes(distribution_support, session_type, evidence), + } +} + +#[cfg(any(target_os = "linux", test))] +fn parse_os_release(content: &str) -> HashMap { + content + .lines() + .filter_map(|line| { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + return None; + } + let (key, value) = line.split_once('=')?; + if key.is_empty() + || !key.bytes().all(|byte| byte.is_ascii_uppercase() || byte == b'_') + { + return None; + } + let value = value.trim(); + let value = if value.len() >= 2 + && ((value.starts_with('"') && value.ends_with('"')) + || (value.starts_with('\'') && value.ends_with('\''))) + { + &value[1..value.len() - 1] + } else { + value + }; + Some((key.to_string(), value.chars().take(128).collect())) + }) + .collect() +} + +#[cfg(any(target_os = "linux", test))] +fn distribution_support(id: Option<&str>, version: Option<&str>) -> &'static str { + if !id.is_some_and(|id| id.eq_ignore_ascii_case("ubuntu")) { + return if id.is_some() { "community" } else { "unknown" }; + } + match version.and_then(version_pair) { + Some((major, minor)) if (major, minor) >= (22, 4) => "supported", + Some(_) => "unsupportedVersion", + None => "unknown", + } +} + +#[cfg(any(target_os = "linux", test))] +fn version_pair(version: &str) -> Option<(u32, u32)> { + let mut pieces = version.split('.'); + let major = pieces.next()?.parse().ok()?; + let minor = pieces.next().unwrap_or("0").parse().ok()?; + Some((major, minor)) +} + +#[cfg(any(target_os = "linux", test))] +fn session_type(get: impl Fn(&str) -> Option) -> &'static str { + match get("XDG_SESSION_TYPE") + .as_deref() + .map(str::to_ascii_lowercase) + .as_deref() + { + Some("wayland") => "wayland", + Some("x11") => "x11", + _ if get("WAYLAND_DISPLAY").is_some() => "wayland", + _ if get("DISPLAY").is_some() => "x11", + _ => "unknown", + } +} + +#[cfg(any(target_os = "linux", test))] +fn advisory_codes( + distribution_support: &str, + session_type: &str, + evidence: LinuxEvidence, +) -> Vec<&'static str> { + let mut codes = Vec::new(); + if distribution_support != "supported" { + codes.push("linux.distribution.notSupported"); + } + if session_type == "unknown" { + codes.push("linux.session.notDetected"); + } + if !evidence.alsa_devices { + codes.push("linux.audio.alsaDevicesMissing"); + } + match evidence.midi_sequencer { + MidiSequencerAccess::Available => {} + MidiSequencerAccess::PermissionDenied => { + codes.push("linux.midi.sequencerPermissionDenied"); + } + MidiSequencerAccess::Missing => codes.push("linux.midi.sequencerMissing"), + } + codes +} + +#[cfg(target_os = "linux")] +fn midi_sequencer_access(path: &Path) -> MidiSequencerAccess { + use std::ffi::CString; + use std::os::unix::ffi::OsStrExt; + + if !path.exists() { + return MidiSequencerAccess::Missing; + } + let Ok(path) = CString::new(path.as_os_str().as_bytes()) else { + return MidiSequencerAccess::PermissionDenied; + }; + // `access` checks the real user's read/write permission without opening an + // ALSA sequencer client or changing device state. + if unsafe { libc::access(path.as_ptr(), libc::R_OK | libc::W_OK) } == 0 { + MidiSequencerAccess::Available + } else { + MidiSequencerAccess::PermissionDenied + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ubuntu_2204_and_newer_are_the_only_supported_distribution_contract() { + assert_eq!(distribution_support(Some("ubuntu"), Some("22.04")), "supported"); + assert_eq!(distribution_support(Some("Ubuntu"), Some("24.04")), "supported"); + assert_eq!( + distribution_support(Some("ubuntu"), Some("20.04")), + "unsupportedVersion" + ); + assert_eq!(distribution_support(Some("fedora"), Some("42")), "community"); + assert_eq!(distribution_support(None, None), "unknown"); + } + + #[test] + fn os_release_parser_does_not_evaluate_shell_syntax() { + let parsed = parse_os_release( + "ID=ubuntu\nVERSION_ID=\"22.04\"\nNAME='Ubuntu Linux'\nBAD-KEY=value\n", + ); + assert_eq!(parsed.get("ID").map(String::as_str), Some("ubuntu")); + assert_eq!(parsed.get("VERSION_ID").map(String::as_str), Some("22.04")); + assert_eq!(parsed.get("NAME").map(String::as_str), Some("Ubuntu Linux")); + assert!(!parsed.contains_key("BAD-KEY")); + } + + #[test] + fn desktop_session_uses_xdg_then_safe_display_fallbacks() { + assert_eq!( + session_type(|name| (name == "XDG_SESSION_TYPE").then(|| "wayland".into())), + "wayland" + ); + assert_eq!( + session_type(|name| (name == "DISPLAY").then(|| ":99".into())), + "x11" + ); + assert_eq!(session_type(|_| None), "unknown"); + } + + #[test] + fn diagnostics_report_transport_evidence_without_claiming_hardware() { + assert_eq!(MidiSequencerAccess::Available.as_str(), "available"); + assert_eq!(MidiSequencerAccess::Missing.as_str(), "missing"); + let evidence = LinuxEvidence { + pipewire_socket: true, + pulse_socket: true, + alsa_devices: false, + midi_sequencer: MidiSequencerAccess::PermissionDenied, + }; + let diagnostics = linux_diagnostics( + Some("ubuntu".into()), + Some("22.04".into()), + "supported", + "wayland", + evidence, + ); + assert_eq!(diagnostics.audio_backend, "pipewireAlsa"); + assert_eq!( + diagnostics.advisories, + [ + "linux.audio.alsaDevicesMissing", + "linux.midi.sequencerPermissionDenied" + ] + ); + } +} diff --git a/src-tauri/src/runtime_launch.rs b/src-tauri/src/runtime_launch.rs new file mode 100644 index 0000000..e438022 --- /dev/null +++ b/src-tauri/src/runtime_launch.rs @@ -0,0 +1,59 @@ +//! Runtime-launch policy shared by every Python-backed service. +//! +//! Developer builds may use the source-tree `uv run ...` commands. Portable +//! release builds must not: their MRT2 and Stable Audio adapters are installed +//! and verified in app-owned storage, then expose an explicit executable path. +//! Keeping this decision in one tiny module makes the #110/#111 adapters +//! pluggable without duplicating a dangerous fallback at each call site. + +use std::io; + +/// Whether commands may fall back to source-tree developer tooling when no +/// explicit backend executable has been configured. +pub const fn developer_fallback_allowed() -> bool { + !cfg!(feature = "managed-runtime") +} + +/// A structured launch failure for a packaged build whose platform adapter is +/// not installed yet. Callers already surface command-spawn failures through +/// their bounded diagnostics/status contracts. +pub fn unavailable(service: &str) -> io::Error { + io::Error::new(io::ErrorKind::NotFound, format!("runtime.unavailable.{service}")) +} + +/// Stable diagnostic identifier for the launch policy in this build. +pub const fn mode() -> &'static str { + if cfg!(feature = "bundled-backend") { + "bundled" + } else if cfg!(feature = "managed-runtime") { + "managed" + } else { + "developer" + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn launch_mode_matches_the_compiled_contract() { + if cfg!(feature = "managed-runtime") { + assert_eq!(mode(), "managed"); + assert!(!developer_fallback_allowed()); + } else if cfg!(feature = "bundled-backend") { + assert_eq!(mode(), "bundled"); + assert!(developer_fallback_allowed()); + } else { + assert_eq!(mode(), "developer"); + assert!(developer_fallback_allowed()); + } + } + + #[test] + fn missing_managed_runtime_is_an_actionable_not_found_error() { + let error = unavailable("mrt2"); + assert_eq!(error.kind(), io::ErrorKind::NotFound); + assert_eq!(error.to_string(), "runtime.unavailable.mrt2"); + } +} diff --git a/src-tauri/src/sidecar.rs b/src-tauri/src/sidecar.rs index b144175..af559fd 100644 --- a/src-tauri/src/sidecar.rs +++ b/src-tauri/src/sidecar.rs @@ -602,6 +602,13 @@ pub fn sidecar_base_command() -> io::Result { return Ok(Command::new(program)); } + // Portable releases get an explicit verified adapter executable. Missing + // one is a first-run/runtime state, never permission to execute a developer + // machine's `uv`, Python, Git, or shell from PATH. + if !crate::runtime_launch::developer_fallback_allowed() { + return Err(crate::runtime_launch::unavailable("mrt2")); + } + let overridden = std::env::var("LSDJ_SIDECAR_CMD"); let spec = overridden .clone() @@ -824,6 +831,9 @@ while True: std::fs::set_permissions(&wrapper, permissions).unwrap(); // SAFETY-ish: no other test reads LSDJ_SIDECAR_CMD or calls // Sidecar::spawn, so this process-global is uncontended; removed at the end. + #[cfg(feature = "managed-runtime")] + std::env::set_var("LSDJ_BACKEND_BIN", wrapper.as_os_str()); + #[cfg(not(feature = "managed-runtime"))] std::env::set_var("LSDJ_SIDECAR_CMD", wrapper.as_os_str()); let mut engine = Engine::new(); @@ -923,6 +933,9 @@ while True: } assert!(gone, "Python sidecar child {pid} survived process-group teardown"); } + #[cfg(feature = "managed-runtime")] + std::env::remove_var("LSDJ_BACKEND_BIN"); + #[cfg(not(feature = "managed-runtime"))] std::env::remove_var("LSDJ_SIDECAR_CMD"); let _ = std::fs::remove_dir_all(&tmp); } diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index bd7edb4..2146b7b 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -14,8 +14,6 @@ "title": "LSDJ", "width": 1280, "height": 800, - "titleBarStyle": "Overlay", - "hiddenTitle": true, "backgroundColor": "#050507" } ], @@ -25,7 +23,6 @@ }, "bundle": { "active": true, - "targets": ["app", "dmg"], "category": "Music", "icon": [ "icons/32x32.png", @@ -33,11 +30,6 @@ "icons/128x128@2x.png", "icons/icon.icns", "icons/icon.ico" - ], - "macOS": { - "minimumSystemVersion": "11.0", - "signingIdentity": "-", - "entitlements": "entitlements.plist" - } + ] } } diff --git a/src-tauri/tauri.linux.conf.json b/src-tauri/tauri.linux.conf.json new file mode 100644 index 0000000..2eca149 --- /dev/null +++ b/src-tauri/tauri.linux.conf.json @@ -0,0 +1,28 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "app": { + "windows": [ + { + "label": "main", + "title": "LSDJ", + "width": 1280, + "height": 800, + "decorations": true, + "backgroundColor": "#050507" + } + ] + }, + "bundle": { + "targets": ["appimage"], + "icon": [ + "icons/32x32.png", + "icons/128x128.png", + "icons/128x128@2x.png" + ], + "linux": { + "appimage": { + "bundleMediaFramework": false + } + } + } +} diff --git a/src-tauri/tauri.macos.conf.json b/src-tauri/tauri.macos.conf.json new file mode 100644 index 0000000..159b5cb --- /dev/null +++ b/src-tauri/tauri.macos.conf.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "app": { + "windows": [ + { + "label": "main", + "title": "LSDJ", + "width": 1280, + "height": 800, + "titleBarStyle": "Overlay", + "hiddenTitle": true, + "backgroundColor": "#050507" + } + ] + }, + "bundle": { + "targets": ["app", "dmg"], + "macOS": { + "minimumSystemVersion": "11.0", + "signingIdentity": "-", + "entitlements": "entitlements.plist" + } + } +} From a008deef1e672cec37c77db410bffa87a7e2adeb Mon Sep 17 00:00:00 2001 From: brxs Date: Sat, 8 Aug 2026 15:17:30 -0700 Subject: [PATCH 2/4] test: keep AppImage layout checks portable --- scripts/verify_linux_appimage.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/scripts/verify_linux_appimage.py b/scripts/verify_linux_appimage.py index b7c8ede..e6b0e3f 100755 --- a/scripts/verify_linux_appimage.py +++ b/scripts/verify_linux_appimage.py @@ -5,6 +5,7 @@ import argparse import json +import os import re import shutil import stat @@ -91,10 +92,13 @@ def verify_extracted(root: Path, libraries: list[str]) -> dict: binary.is_file() and not binary.is_symlink(), "AppImage has no safe lsdj-app binary", ) - require( - binary.stat().st_mode & stat.S_IXUSR != 0, - "packaged lsdj-app binary is not executable", - ) + # Windows does not preserve POSIX mode bits in the shared pure-layout unit + # test. Production verification runs on Linux, where this remains required. + if os.name != "nt": + require( + binary.stat().st_mode & stat.S_IXUSR != 0, + "packaged lsdj-app binary is not executable", + ) require(any(root.glob("*.png")), "AppImage root has no desktop icon") return { From 90d0f64de4434ad294631ad50e60456f6a9e91c4 Mon Sep 17 00:00:00 2001 From: brxs Date: Sat, 8 Aug 2026 15:34:18 -0700 Subject: [PATCH 3/4] fix: verify safe AppImage desktop symlinks --- scripts/tests/test_verify_linux_appimage.py | 44 +++++++++++++++++++++ scripts/verify_linux_appimage.py | 21 ++++++++-- 2 files changed, 62 insertions(+), 3 deletions(-) diff --git a/scripts/tests/test_verify_linux_appimage.py b/scripts/tests/test_verify_linux_appimage.py index aa34e26..432559b 100644 --- a/scripts/tests/test_verify_linux_appimage.py +++ b/scripts/tests/test_verify_linux_appimage.py @@ -67,6 +67,50 @@ def test_duplicate_desktop_keys_fail_closed(self): ): verify_linux_appimage.verify_extracted(self.root, ["libc.so.6"]) + def test_internal_desktop_symlink_is_accepted(self): + desktop = self.root / "lsdj-app.desktop" + packaged = self.root / "usr/share/applications/lsdj-app.desktop" + packaged.parent.mkdir(parents=True) + desktop.replace(packaged) + try: + desktop.symlink_to(Path("usr/share/applications/lsdj-app.desktop")) + except OSError as error: + self.skipTest(f"symlinks are unavailable: {error}") + + audit = verify_linux_appimage.verify_extracted(self.root, ["libc.so.6"]) + + self.assertEqual(audit["desktop"]["file"], "lsdj-app.desktop") + + def test_desktop_symlink_cannot_escape_package_root(self): + desktop = self.root / "lsdj-app.desktop" + outside = self.root.parent / f"{self.root.name}-outside.desktop" + desktop.replace(outside) + self.addCleanup(outside.unlink, missing_ok=True) + try: + desktop.symlink_to(outside) + except OSError as error: + self.skipTest(f"symlinks are unavailable: {error}") + + with self.assertRaisesRegex( + verify_linux_appimage.AppImageError, "unsafe desktop entry" + ): + verify_linux_appimage.verify_extracted(self.root, ["libc.so.6"]) + + def test_absolute_desktop_symlink_is_rejected(self): + desktop = self.root / "lsdj-app.desktop" + packaged = self.root / "usr/share/applications/lsdj-app.desktop" + packaged.parent.mkdir(parents=True) + desktop.replace(packaged) + try: + desktop.symlink_to(packaged.resolve()) + except OSError as error: + self.skipTest(f"symlinks are unavailable: {error}") + + with self.assertRaisesRegex( + verify_linux_appimage.AppImageError, "absolute symlink" + ): + verify_linux_appimage.verify_extracted(self.root, ["libc.so.6"]) + if __name__ == "__main__": unittest.main() diff --git a/scripts/verify_linux_appimage.py b/scripts/verify_linux_appimage.py index e6b0e3f..c5f0ebd 100755 --- a/scripts/verify_linux_appimage.py +++ b/scripts/verify_linux_appimage.py @@ -23,8 +23,23 @@ def require(condition: bool, message: str) -> None: raise AppImageError(message) -def desktop_entries(path: Path) -> dict[str, str]: - require(path.is_file() and not path.is_symlink(), f"unsafe desktop entry: {path}") +def safe_packaged_file(root: Path, path: Path, kind: str) -> Path: + """Resolve a regular file without allowing a package-root escape.""" + root = root.resolve(strict=True) + if path.is_symlink(): + target = path.readlink() + require(not target.is_absolute(), f"unsafe {kind}: absolute symlink") + try: + resolved = path.resolve(strict=True) + resolved.relative_to(root) + except (OSError, ValueError): + raise AppImageError(f"unsafe {kind}: {path}") from None + require(resolved.is_file(), f"unsafe {kind}: {path}") + return resolved + + +def desktop_entries(root: Path, path: Path) -> dict[str, str]: + path = safe_packaged_file(root, path, "desktop entry") entries: dict[str, str] = {} section = "" for line in path.read_text(encoding="utf-8").splitlines(): @@ -77,7 +92,7 @@ def verify_extracted(root: Path, libraries: list[str]) -> dict: desktop_files = sorted(root.glob("*.desktop")) require(len(desktop_files) == 1, "AppImage must contain exactly one desktop entry") - desktop = desktop_entries(desktop_files[0]) + desktop = desktop_entries(root, desktop_files[0]) require(desktop.get("Type") == "Application", "desktop Type must be Application") require(desktop.get("Name") == "LSDJ", "desktop Name must be LSDJ") require("lsdj-app" in desktop.get("Exec", ""), "desktop Exec must launch lsdj-app") From ba0068d30b51ff14654b48998528e948fa04979a Mon Sep 17 00:00:00 2001 From: brxs Date: Sat, 8 Aug 2026 15:49:23 -0700 Subject: [PATCH 4/4] fix: gate Unix-only process helpers --- src-tauri/src/analysis/live.rs | 2 +- src-tauri/src/child_process.rs | 8 ++++++-- src-tauri/src/models.rs | 1 + 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src-tauri/src/analysis/live.rs b/src-tauri/src/analysis/live.rs index 22f2ad9..1a361da 100644 --- a/src-tauri/src/analysis/live.rs +++ b/src-tauri/src/analysis/live.rs @@ -133,7 +133,7 @@ pub struct AnalysisFeed { impl AnalysisFeed { /// A feed whose receivers are dropped — every send is a silent no-op. For /// tests that need the tee wiring without analysis threads (no `AppHandle`). - #[cfg(test)] + #[cfg(all(test, unix))] pub fn disconnected(deck_count: usize) -> Self { AnalysisFeed { senders: Arc::new((0..deck_count).map(|_| sync_channel(1).0).collect()), diff --git a/src-tauri/src/child_process.rs b/src-tauri/src/child_process.rs index a138fe0..0116f7e 100644 --- a/src-tauri/src/child_process.rs +++ b/src-tauri/src/child_process.rs @@ -23,6 +23,7 @@ use std::time::{Duration, Instant}; const POLL_INTERVAL: Duration = Duration::from_millis(20); const FORCE_WAIT: Duration = Duration::from_secs(2); +#[cfg(unix)] const TREE_REAP_SWEEPS: usize = 100; const DIAGNOSTIC_BYTES: usize = 16 * 1024; const DIAGNOSTIC_LINES: usize = 128; @@ -155,6 +156,7 @@ fn scrub_child_environment(command: &mut Command) { } impl SupervisedChild { + #[cfg(unix)] pub(crate) fn id(&self) -> u32 { self.child.id() } @@ -576,8 +578,10 @@ fn resume_windows_process(process_id: u32) -> io::Result<()> { } // SAFETY: ownership of the snapshot handle transfers here. let snapshot = unsafe { OwnedHandle::from_raw_handle(raw_snapshot as _) }; - let mut entry = THREADENTRY32::default(); - entry.dwSize = std::mem::size_of::() as u32; + let mut entry = THREADENTRY32 { + dwSize: std::mem::size_of::() as u32, + ..Default::default() + }; // SAFETY: snapshot and entry pointers are valid. let mut has_entry = unsafe { Thread32First(snapshot.as_raw_handle() as _, &mut entry) } != 0; while has_entry { diff --git a/src-tauri/src/models.rs b/src-tauri/src/models.rs index 19c71f7..b9d6166 100644 --- a/src-tauri/src/models.rs +++ b/src-tauri/src/models.rs @@ -1593,6 +1593,7 @@ mod tests { // ONLY on the child Command (never process-global), so they can't race the // sidecar tests that share this binary's environment. + #[cfg(unix)] fn shared() -> InstallShared { InstallShared { busy: AtomicBool::new(false),