diff --git a/CLAUDE.md b/CLAUDE.md
index 13b5b6cc..c951e393 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -35,6 +35,20 @@ CI runs on **Gitea Actions** (`.gitea/workflows/`), not GitHub. Use the `gh` CLI
only against the mirror if one exists; the canonical remote is
`gitea.tourolle.paris`.
+> **🔴 CI installs no system tools.** Never add an `apt-get`, `rustup`,
+> `sdkmanager`, mingw/nsis, or any other *toolchain/system-package* install to a
+> CI workflow step. Every build, test, and packaging **tool** must already live
+> in the Docker image the job runs in — the unified builder (`Dockerfile.builder`
+> → `gitea.tourolle.paris/dtourolle/jellytau-builder`) for Android/Linux/Windows,
+> or `Dockerfile.arch` for Arch. If a job needs a tool the image lacks, **add it
+> to the image, rebuild + push it** (`scripts/build-builder-image.sh`), and use
+> it from CI — do not install it at job time. This keeps builds reproducible and
+> fast, and is why the packaging stages are thin `FROM ${BUILDER_IMAGE}` layers.
+>
+> `bun install` (fetching the project's own JS deps per the lockfile) is **not**
+> a violation — that's project dependencies, not a toolchain. The rule is about
+> system tools, not npm/bun/cargo *packages* declared by the project.
+
## Before Committing
- Frontend: `bun run check` and `bun run test` must pass.
diff --git a/Dockerfile b/Dockerfile
index c39b1c36..208a561b 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,4 +1,11 @@
# Multi-stage build for JellyTau - Tauri Jellyfin client
+#
+# The desktop packaging stages (desktop-linux-build, windows-cross) build FROM
+# the unified registry builder image, which carries every packaging tool. Declared
+# here (before the first FROM) so it's in scope for those stages' FROM lines.
+# Override for local iteration: --build-arg BUILDER_IMAGE=jellytau-builder:latest
+ARG BUILDER_IMAGE=gitea.tourolle.paris/dtourolle/jellytau-builder:latest
+
FROM ubuntu:24.04 AS builder
ENV DEBIAN_FRONTEND=noninteractive \
@@ -108,6 +115,30 @@ RUN cd src-tauri && cargo fetch && cd .. && \
bun run tauri android build --apk true && \
echo "APK build complete!"
+# Desktop packaging stages build FROM the unified registry builder image (see the
+# BUILDER_IMAGE ARG at the top), which already carries every packaging tool
+# (rpm/file for Linux, mingw-w64 + nsis + the x86_64-pc-windows-gnu rust target
+# for Windows). ONE source of dependency truth, shared with CI — no per-stage
+# apt/rustup here.
+
+# Linux desktop packaging environment (deb + rpm; Arch is Dockerfile.arch).
+# Thin layer over the builder — the actual build runs at container-run time on
+# the bind-mounted source (see docker-compose.yml / scripts/build-desktop-linux.sh),
+# matching the `dev` service model. Run standalone with:
+# docker run --rm -v "$PWD:/app" -v "$PWD/dist:/app/dist" \
+# bash -c "OUTPUT_DIR=/app/dist scripts/build-desktop-linux.sh"
+FROM ${BUILDER_IMAGE} AS desktop-linux-build
+WORKDIR /app
+CMD ["bash", "-c", "OUTPUT_DIR=/app/dist scripts/build-desktop-linux.sh"]
+
+# Windows cross-compile environment (MSVC target via cargo-xwin). Video works via
+# WebView2 and audio via the webview backend; NSIS installer is produced
+# from Linux by cargo-xwin. Default bundles NSIS; override WIN_BUNDLES=none for
+# exe-only. Build runs at container-run time like above.
+FROM ${BUILDER_IMAGE} AS windows-cross
+WORKDIR /app
+CMD ["bash", "-c", "OUTPUT_DIR=/app/dist WIN_BUNDLES=${WIN_BUNDLES:-nsis} scripts/build-windows-cross.sh"]
+
# Final output stage
FROM ubuntu:24.04 AS final
RUN apt-get update && apt-get install -y --no-install-recommends \
diff --git a/Dockerfile.arch b/Dockerfile.arch
new file mode 100644
index 00000000..b2445219
--- /dev/null
+++ b/Dockerfile.arch
@@ -0,0 +1,37 @@
+# JellyTau Arch Linux package builder.
+#
+# Tauri has no pacman bundle target, so we build a real .pkg.tar.zst with makepkg
+# from packaging/arch/PKGBUILD. makepkg refuses to run as root, so we create a
+# non-root `builder` user with passwordless sudo (for `makepkg -s` pacman calls).
+#
+# docker build -f Dockerfile.arch -t jellytau-arch .
+# docker run --rm -v "$PWD/dist:/out" jellytau-arch
+FROM archlinux:latest
+
+RUN pacman -Syu --noconfirm \
+ base-devel git sudo \
+ rust cargo nodejs \
+ webkit2gtk-4.1 mpv gtk3 libayatana-appindicator \
+ libsoup3 pkgconf openssl \
+ && pacman -Scc --noconfirm
+
+# Bun is not in the official repos; install the upstream binary.
+RUN curl -fsSL https://bun.sh/install | bash && \
+ ln -s /root/.bun/bin/bun /usr/local/bin/bun
+
+# Non-root build user with passwordless sudo for makepkg's dependency step.
+RUN useradd -m builder && \
+ echo 'builder ALL=(ALL) NOPASSWD: ALL' > /etc/sudoers.d/builder && \
+ ln -sf /root/.bun/bin/bun /usr/local/bin/bun
+
+WORKDIR /app
+COPY . .
+RUN chown -R builder:builder /app
+
+USER builder
+ENV OUTPUT_DIR=/out
+RUN mkdir -p /out
+VOLUME ["/out"]
+
+# Default: build the package. Output lands in /out (mount it to collect the pkg).
+CMD ["bash", "-c", "OUTPUT_DIR=/out scripts/build-arch.sh"]
diff --git a/Dockerfile.builder b/Dockerfile.builder
index ee2b285c..a685950b 100644
--- a/Dockerfile.builder
+++ b/Dockerfile.builder
@@ -1,5 +1,9 @@
# JellyTau Builder Image
-# Pre-built image with all dependencies for building and testing
+# Pre-built image with all dependencies for building, testing, and packaging:
+# - Android APK (SDK/NDK), Linux desktop (deb/rpm),
+# - Windows cross via the official Tauri path: MSVC target + cargo-xwin + NSIS
+# Arch packages build in a separate archlinux image (Dockerfile.arch) since
+# makepkg is Arch-specific.
# Push to your registry: docker build -f Dockerfile.builder -t gitea.tourolle.paris/dtourolle/jellytau-builder:latest .
FROM ubuntu:24.04
@@ -83,6 +87,34 @@ RUN $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --sdk_root=$ANDROID_HOME \
# Set NDK environment variable
ENV NDK_HOME=$ANDROID_HOME/ndk/$NDK_VERSION
+# ---------------------------------------------------------------------------
+# Desktop packaging tools — kept in a trailing layer ON PURPOSE so that adding
+# or changing a packaging tool doesn't invalidate the expensive apt/rust/Android
+# layers above (a tool tweak becomes a ~1-2 min rebuild, not ~15). Covers Linux
+# (deb/rpm) and Windows cross (MSVC via cargo-xwin + NSIS).
+RUN apt-get update && apt-get install -y --no-install-recommends \
+ # Linux desktop packaging: rpmbuild for the .rpm bundle (deb needs nothing extra)
+ rpm \
+ file \
+ # Windows cross-compile (official Tauri path: MSVC target via cargo-xwin).
+ # clang provides clang-cl, the MSVC-compatible C compiler cc-rs uses to build
+ # C deps (bundled sqlite, ring, ...); lld = linker; llvm = llvm-lib/ar etc;
+ # nsis = installer generator.
+ clang \
+ lld \
+ llvm \
+ nsis \
+ && rm -rf /var/lib/apt/lists/* \
+ # Ubuntu's clang package ships clang but NOT the clang-cl alias that cc-rs
+ # invokes for MSVC targets. clang-cl is the same binary in MSVC-compat mode,
+ # so provide it as a symlink.
+ && ln -sf /usr/bin/clang /usr/local/bin/clang-cl
+
+# Windows rust target + cargo-xwin (downloads the MSVC CRT/SDK at build time).
+RUN . $HOME/.cargo/env && \
+ rustup target add x86_64-pc-windows-msvc && \
+ cargo install --locked cargo-xwin
+
WORKDIR /app
ENTRYPOINT ["/bin/bash"]
diff --git a/docker-compose.yml b/docker-compose.yml
index 97415227..191e0647 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -33,6 +33,58 @@ services:
ports:
- "5172:5172" # In case you want to run dev server
+ # Linux desktop packages - deb + rpm + pacman into ./dist
+ desktop-linux-build:
+ build:
+ context: .
+ dockerfile: Dockerfile
+ target: desktop-linux-build
+ args:
+ # Defaults to the registry builder (Dockerfile's ARG). Point at a locally
+ # built builder with: BUILDER_IMAGE=jellytau-builder:latest docker compose ...
+ BUILDER_IMAGE: ${BUILDER_IMAGE:-gitea.tourolle.paris/dtourolle/jellytau-builder:latest}
+ container_name: jellytau-desktop-linux-build
+ volumes:
+ - .:/app
+ - cargo-cache:/root/.cargo
+ - bun-cache:/root/.bun
+ environment:
+ - RUST_BACKTRACE=1
+ - OUTPUT_DIR=/app/dist
+ command: bash -c "OUTPUT_DIR=/app/dist scripts/build-desktop-linux.sh"
+
+ # Arch Linux package (.pkg.tar.zst via makepkg) into ./dist
+ arch-build:
+ build:
+ context: .
+ dockerfile: Dockerfile.arch
+ container_name: jellytau-arch-build
+ volumes:
+ - ./dist:/out
+ environment:
+ - RUST_BACKTRACE=1
+ - OUTPUT_DIR=/out
+
+ # Windows cross-compile (MSVC via cargo-xwin). Emits NSIS installer + .exe to
+ # ./dist. Override WIN_BUNDLES=none for exe-only.
+ windows-cross:
+ build:
+ context: .
+ dockerfile: Dockerfile
+ target: windows-cross
+ args:
+ BUILDER_IMAGE: ${BUILDER_IMAGE:-gitea.tourolle.paris/dtourolle/jellytau-builder:latest}
+ container_name: jellytau-windows-cross
+ volumes:
+ - .:/app
+ - cargo-cache:/root/.cargo
+ - bun-cache:/root/.bun
+ environment:
+ - RUST_BACKTRACE=1
+ - OUTPUT_DIR=/app/dist
+ - WIN_BUNDLES=${WIN_BUNDLES:-nsis}
+ command: bash -c "OUTPUT_DIR=/app/dist WIN_BUNDLES=${WIN_BUNDLES:-nsis} scripts/build-windows-cross.sh"
+
# Development container - for interactive development
dev:
build:
diff --git a/docs/build-desktop-packages.md b/docs/build-desktop-packages.md
new file mode 100644
index 00000000..b46dbd6d
--- /dev/null
+++ b/docs/build-desktop-packages.md
@@ -0,0 +1,91 @@
+# Desktop packaging (Linux, Arch, Windows)
+
+How to produce distributable desktop packages for JellyTau. All three flows can
+run in Docker so no host toolchain setup is required. Outputs land in `./dist`.
+
+## One builder image (shared with CI)
+
+The deb/rpm and Windows-cross flows build on the **unified registry builder**
+([../Dockerfile.builder](../Dockerfile.builder) →
+`gitea.tourolle.paris/dtourolle/jellytau-builder`), the same image CI uses. It
+carries every packaging tool: Android SDK/NDK, `rpm`/`file` (Linux bundler),
+`cargo-xwin` + `lld` + `llvm` + `nsis` + the `x86_64-pc-windows-msvc` rust target
+(Windows). There is **one** dependency source of truth — no per-stage tool
+installs.
+
+The desktop stages in [../Dockerfile](../Dockerfile) are thin `FROM
+${BUILDER_IMAGE}` environments; the actual build runs at container-run time on
+your bind-mounted source (like the `dev` service), so source edits need no image
+rebuild.
+
+**If you changed `Dockerfile.builder`** (e.g. added a tool), rebuild and push it
+first, or the packaging flows use the stale registry image:
+
+```bash
+scripts/build-builder-image.sh # build + push :latest to the registry
+# ...or iterate locally without pushing:
+docker build -f Dockerfile.builder -t jellytau-builder:latest .
+BUILDER_IMAGE=jellytau-builder:latest bun run docker:build:windows
+```
+
+Arch uses a separate `archlinux` image ([../Dockerfile.arch](../Dockerfile.arch))
+because `makepkg` is Arch-specific — it is not part of the unified builder.
+
+| Target | Format | Docker command | Functional? |
+|--------|--------|----------------|-------------|
+| Debian/Ubuntu, Fedora | `.deb`, `.rpm` | `bun run docker:build:linux` | ✅ yes |
+| Arch Linux | `.pkg.tar.zst` | `bun run docker:build:arch` | ✅ yes |
+| Windows | NSIS installer + `.exe` | `bun run docker:build:windows` | ✅ yes (unsigned) |
+
+## Linux: deb + rpm
+
+Tauri's bundler produces these natively. The build runs on the existing Ubuntu
+builder image ([../Dockerfile](../Dockerfile), `desktop-linux-build` stage):
+
+```bash
+bun run docker:build:linux # deb + rpm -> ./dist
+# or, on a host with the Tauri Linux deps installed:
+BUNDLES="deb,rpm" scripts/build-desktop-linux.sh
+```
+
+Runtime dependency: the app links libmpv (audio) and WebKitGTK (webview + HTML5
+transcoded video). The deb/rpm declare these.
+
+> Note: `appimage` is also a valid Tauri target if you want a portable bundle —
+> add it to `BUNDLES`.
+
+## Arch Linux: pacman package
+
+**Tauri has no `pacman` bundle target** (as of tauri-cli 2.9.x — valid targets
+are deb/rpm/appimage/msi/nsis/app/dmg). So we ship a hand-written PKGBUILD in
+[../packaging/arch/PKGBUILD](../packaging/arch/PKGBUILD) and build it with
+`makepkg` on an Arch base image ([../Dockerfile.arch](../Dockerfile.arch)):
+
+```bash
+bun run docker:build:arch # .pkg.tar.zst -> ./dist
+```
+
+The PKGBUILD is AUR-ready: swap its `source=()` for a release tarball/VCS URL to
+publish. Runtime deps: `webkit2gtk-4.1`, `mpv`, `gtk3`, `libayatana-appindicator`.
+
+`makepkg` refuses to run as root, so the Docker stage builds as a non-root
+`builder` user. Because the image `COPY`s the source at build time, the
+`arch-build` compose service does **not** bind-mount the repo — rebuild the image
+to pick up source changes.
+
+## Windows: NSIS installer cross-compiled from Linux
+
+Produces a working (unsigned) NSIS installer + `.exe` via the official Tauri
+cross-compile path — the `x86_64-pc-windows-msvc` target driven by `cargo-xwin`.
+Video plays via WebView2 and audio via the webview `` backend. See
+[build-windows.md](build-windows.md) for the full explanation.
+
+```bash
+bun run docker:build:windows # NSIS installer + .exe -> ./dist
+WIN_BUNDLES=none bun run docker:build:windows # exe only, skip bundling
+```
+
+The Docker `windows-cross` stage is a thin layer over the builder, which carries
+`cargo-xwin` + `lld` + `llvm` + `nsis` + the `x86_64-pc-windows-msvc` target.
+Cross-compilation is Tauri's "last resort" path (less tested than building on
+Windows); a `windows-latest` CI job is the fallback if it misbehaves.
diff --git a/docs/build-windows.md b/docs/build-windows.md
new file mode 100644
index 00000000..44d75542
--- /dev/null
+++ b/docs/build-windows.md
@@ -0,0 +1,78 @@
+# Windows build
+
+JellyTau targets Linux and Android primarily, but a working Windows build —
+including an **NSIS installer cross-compiled from Linux** — is produced by the
+Docker tooling. It is not yet a first-class release target (no code signing / CI
+job / SMTC lockscreen), but it runs and plays media.
+
+## How playback works on Windows
+
+- **Video** — renders through the webview HTML5 `` element (hls.js) on
+ *every* platform; on Windows that is WebView2 (Chromium/Edge), which plays HLS +
+ h264 fine. No Windows-specific code.
+- **Audio-only (music)** — the native audio backends are libmpv (Linux) and
+ ExoPlayer (Android); neither exists on Windows. Instead
+ `create_player_backend()` in [../src-tauri/src/lib.rs](../src-tauri/src/lib.rs)
+ uses `WebviewAudioBackend` on non-Linux/non-Android targets: it hands the stream
+ URL to a webview `` element (see
+ [../src/lib/services/webviewAudio.ts](../src/lib/services/webviewAudio.ts)),
+ which reports state back through the same `player_report_*` round-trip the video
+ path uses. Pure Rust + Tauri events.
+
+## Cross-compiling from Linux (MSVC + cargo-xwin)
+
+We use the [official Tauri cross-compile path](https://v2.tauri.app/distribute/windows-installer/):
+the **MSVC** target (`x86_64-pc-windows-msvc`) driven by
+[`cargo-xwin`](https://github.com/rust-cross/cargo-xwin), which downloads the MSVC
+CRT / Windows SDK headers and links with `lld`. MSVC is the target Tauri
+officially supports for Windows (mingw/GNU is not), and — unlike GNU — it lets the
+Tauri CLI bundle the **NSIS installer from a Linux host**.
+
+> Why not mingw/GNU? The GNU target *does* link a valid `.exe`, but the Tauri CLI
+> gates `--bundles` by the host OS unless it recognizes a real Windows build.
+> `--runner cargo-xwin --target x86_64-pc-windows-msvc` is what flips it into
+> Windows mode and enables the `nsis`/`msi` bundlers on Linux.
+
+The builder image ([../Dockerfile.builder](../Dockerfile.builder)) bakes in the
+whole toolchain: the `x86_64-pc-windows-msvc` rust target, `cargo-xwin`, `lld`,
+`llvm`, and `nsis`.
+
+```bash
+bun run docker:build:windows # NSIS installer + .exe -> ./dist
+WIN_BUNDLES=none bun run docker:build:windows # exe only, skip bundling
+```
+
+Or directly on a host that has the toolchain:
+
+```bash
+scripts/build-windows-cross.sh # nsis installer + exe
+WIN_BUNDLES=none scripts/build-windows-cross.sh # exe only
+```
+
+Under the hood the build runs:
+
+```bash
+tauri build --runner cargo-xwin --target x86_64-pc-windows-msvc --bundles nsis
+```
+
+Outputs:
+- `.exe` — `src-tauri/target/x86_64-pc-windows-msvc/release/jellytau.exe`
+- NSIS installer — `.../release/bundle/nsis/*-setup.exe`
+
+(both copied to `./dist` when `OUTPUT_DIR` is set).
+
+## Caveats
+
+- **Cross-compilation is a last resort** per Tauri's own docs — it's less tested
+ than building on Windows. If it misbehaves, a `windows-latest` CI job or a
+ Windows VM building natively (`tauri build --bundles nsis`) is the fallback.
+- **Code signing is not wired up** — the installer is unsigned, so Windows
+ SmartScreen will warn on first run.
+
+## Outstanding for a first-class Windows release
+
+1. Gapless/crossfade + SMTC (lockscreen) — currently no-ops in the webview audio
+ path.
+2. Downloaded (`Local` source) file playback needs `convertFileSrc` on the
+ frontend; streaming works today.
+3. Code signing + a Windows packaging CI job.
diff --git a/package.json b/package.json
index 7c144e36..947ededf 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "jellytau",
- "version": "0.0.18",
+ "version": "0.1.0",
"description": "",
"type": "module",
"packageManager": "bun@1.3.5",
@@ -25,6 +25,12 @@
"android:dev": "./scripts/build-and-deploy.sh",
"android:check": "./scripts/check-android.sh",
"android:logs": "./scripts/logcat.sh",
+ "desktop:build:linux": "./scripts/build-desktop-linux.sh",
+ "desktop:build:arch": "./scripts/build-arch.sh",
+ "desktop:build:windows": "./scripts/build-windows-cross.sh",
+ "docker:build:linux": "docker compose run --rm desktop-linux-build",
+ "docker:build:arch": "docker compose run --rm arch-build",
+ "docker:build:windows": "docker compose run --rm windows-cross",
"clean": "./scripts/clean.sh",
"tauri": "tauri",
"traces": "bun run scripts/extract-traces.ts",
diff --git a/packaging/arch/PKGBUILD b/packaging/arch/PKGBUILD
new file mode 100644
index 00000000..fd3d9d35
--- /dev/null
+++ b/packaging/arch/PKGBUILD
@@ -0,0 +1,52 @@
+# Maintainer: Duncan Tourolle
+#
+# JellyTau — a cross-platform Jellyfin client (Tauri + SvelteKit).
+#
+# This PKGBUILD builds from the local source tree by default (see the `dev`
+# convenience below), which is what scripts/build-arch.sh uses inside the Arch
+# Docker stage. For AUR distribution, replace the `source=()` line with a release
+# tarball/VCS URL and drop the local-copy prepare() step.
+
+pkgname=jellytau
+pkgver=0.0.18
+pkgrel=1
+pkgdesc="A cross-platform Jellyfin client"
+arch=('x86_64')
+url="https://gitea.tourolle.paris/dtourolle/jellytau"
+license=('MIT')
+# Runtime: libmpv for audio, webkit2gtk for the webview + HTML5 transcoded video.
+depends=('webkit2gtk-4.1' 'mpv' 'gtk3' 'libayatana-appindicator')
+makedepends=('rust' 'cargo' 'bun' 'nodejs' 'pkgconf' 'libsoup3')
+options=('!strip' '!lto')
+
+# Populated from the working tree by scripts/build-arch.sh (SRC env var).
+_srcdir="${JELLYTAU_SRC:-$startdir/../..}"
+
+build() {
+ cd "$_srcdir"
+ export CARGO_HOME="${CARGO_HOME:-$srcdir/cargo-home}"
+ bun install --frozen-lockfile || bun install
+ bun run build
+ # Only the raw binary is needed; packaging is done in package() below so we
+ # control the Arch filesystem layout ourselves rather than via tauri-bundler.
+ (cd src-tauri && cargo build --release --locked)
+}
+
+package() {
+ cd "$_srcdir"
+
+ install -Dm755 "src-tauri/target/release/jellytau" \
+ "$pkgdir/usr/bin/jellytau"
+
+ # Desktop entry
+ install -Dm644 "packaging/arch/jellytau.desktop" \
+ "$pkgdir/usr/share/applications/jellytau.desktop"
+
+ # Icons (hicolor)
+ install -Dm644 "src-tauri/icons/32x32.png" \
+ "$pkgdir/usr/share/icons/hicolor/32x32/apps/jellytau.png"
+ install -Dm644 "src-tauri/icons/128x128.png" \
+ "$pkgdir/usr/share/icons/hicolor/128x128/apps/jellytau.png"
+ install -Dm644 "src-tauri/icons/128x128@2x.png" \
+ "$pkgdir/usr/share/icons/hicolor/256x256/apps/jellytau.png"
+}
diff --git a/packaging/arch/jellytau.desktop b/packaging/arch/jellytau.desktop
new file mode 100644
index 00000000..52812f30
--- /dev/null
+++ b/packaging/arch/jellytau.desktop
@@ -0,0 +1,9 @@
+[Desktop Entry]
+Type=Application
+Name=JellyTau
+Comment=A cross-platform Jellyfin client
+Exec=jellytau
+Icon=jellytau
+Terminal=false
+Categories=AudioVideo;Player;Audio;Video;
+StartupWMClass=jellytau
diff --git a/scripts/build-arch.sh b/scripts/build-arch.sh
new file mode 100755
index 00000000..ecf0bc8d
--- /dev/null
+++ b/scripts/build-arch.sh
@@ -0,0 +1,36 @@
+#!/bin/bash
+# Build an Arch Linux package (.pkg.tar.zst) for JellyTau via makepkg.
+#
+# Tauri's bundler has no pacman target (as of tauri-cli 2.9.x), so we ship a
+# hand-written PKGBUILD in packaging/arch/ and build it with makepkg. This must
+# run on an Arch host / the `arch-build` Docker stage — makepkg is Arch-specific
+# and refuses to run as root, so run it as a non-root user with sudo for deps.
+#
+# Usage (typically inside the arch-build Docker stage as a non-root user):
+# scripts/build-arch.sh
+# OUTPUT_DIR=/app/dist scripts/build-arch.sh
+set -euo pipefail
+
+REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
+cd "$REPO_ROOT/packaging/arch"
+
+echo "🏛️ Building JellyTau Arch package"
+echo "=================================="
+
+# Point the PKGBUILD at the working tree and give cargo/bun a writable home.
+export JELLYTAU_SRC="$REPO_ROOT"
+export CARGO_HOME="${CARGO_HOME:-$REPO_ROOT/.cargo-arch}"
+
+# -s installs missing deps (needs sudo/root privileges for pacman), -f overwrites.
+makepkg -sf --noconfirm
+
+echo ""
+echo "✅ Built Arch package(s):"
+ls -1 ./*.pkg.tar.zst
+
+if [[ -n "${OUTPUT_DIR:-}" ]]; then
+ mkdir -p "$OUTPUT_DIR"
+ cp -v ./*.pkg.tar.zst "$OUTPUT_DIR/"
+ echo ""
+ echo "📦 Copied Arch package(s) to $OUTPUT_DIR"
+fi
diff --git a/scripts/build-desktop-linux.sh b/scripts/build-desktop-linux.sh
new file mode 100755
index 00000000..6b202a6d
--- /dev/null
+++ b/scripts/build-desktop-linux.sh
@@ -0,0 +1,44 @@
+#!/bin/bash
+# Build Linux desktop packages (deb + rpm) for JellyTau.
+#
+# Produces bundles under src-tauri/target/release/bundle/{deb,rpm}.
+# Runs on the existing Ubuntu builder image. NOTE: Tauri has no pacman bundle
+# target — the Arch package is built separately with makepkg (scripts/build-arch.sh
+# / Dockerfile.arch). `appimage` is also available if you want a portable bundle.
+#
+# Usage:
+# scripts/build-desktop-linux.sh # deb + rpm
+# BUNDLES="deb,appimage" scripts/build-desktop-linux.sh # subset / add appimage
+# OUTPUT_DIR=/app/dist scripts/build-desktop-linux.sh # copy bundles out
+set -euo pipefail
+
+cd "$(dirname "$0")/.."
+
+BUNDLES="${BUNDLES:-deb,rpm}"
+
+echo "🐧 Building JellyTau Linux desktop packages"
+echo "==========================================="
+echo "Bundles: $BUNDLES"
+echo ""
+
+bun install --frozen-lockfile 2>/dev/null || bun install
+bun run build
+
+# --bundles overrides tauri.conf.json bundle.targets so this script controls
+# exactly which Linux formats are produced (never NSIS here).
+bun run tauri build --bundles "$BUNDLES"
+
+BUNDLE_ROOT="src-tauri/target/release/bundle"
+echo ""
+echo "✅ Built packages:"
+find "$BUNDLE_ROOT" -maxdepth 2 -type f \
+ \( -name '*.deb' -o -name '*.rpm' -o -name '*.AppImage' \) -print
+
+if [[ -n "${OUTPUT_DIR:-}" ]]; then
+ mkdir -p "$OUTPUT_DIR"
+ find "$BUNDLE_ROOT" -maxdepth 2 -type f \
+ \( -name '*.deb' -o -name '*.rpm' -o -name '*.AppImage' \) \
+ -exec cp -v {} "$OUTPUT_DIR/" \;
+ echo ""
+ echo "📦 Copied bundles to $OUTPUT_DIR"
+fi
diff --git a/scripts/build-windows-cross.sh b/scripts/build-windows-cross.sh
new file mode 100755
index 00000000..568613c3
--- /dev/null
+++ b/scripts/build-windows-cross.sh
@@ -0,0 +1,69 @@
+#!/bin/bash
+# Cross-compile JellyTau for Windows from Linux, producing an NSIS installer.
+#
+# Uses the OFFICIAL Tauri cross-compile path (https://v2.tauri.app/distribute/
+# windows-installer/): the MSVC target driven by cargo-xwin, which downloads the
+# MSVC CRT/Windows SDK headers and links with lld. This is the target Tauri
+# officially supports for Windows (the mingw/GNU target is not), and unlike GNU
+# it can bundle the NSIS installer from a Linux host.
+#
+# Playback on Windows: video renders via WebView2 and audio via the webview
+# backend (WebviewAudioBackend) — see docs/build-windows.md.
+#
+# Requirements (present in the Docker windows-cross target / unified builder):
+# - rustup target x86_64-pc-windows-msvc
+# - cargo-xwin (cargo install --locked cargo-xwin)
+# - lld, llvm (linker + llvm-lib used by cargo-xwin)
+# - nsis (makensis) (installer generator)
+#
+# Usage:
+# scripts/build-windows-cross.sh # exe + NSIS installer
+# WIN_BUNDLES=none scripts/build-windows-cross.sh # exe only, skip bundling
+# OUTPUT_DIR=/app/dist scripts/build-windows-cross.sh
+set -euo pipefail
+
+cd "$(dirname "$0")/.."
+
+TARGET="x86_64-pc-windows-msvc"
+WIN_BUNDLES="${WIN_BUNDLES:-nsis}"
+
+echo "🪟 Cross-compiling JellyTau for Windows ($TARGET, via cargo-xwin)"
+echo "================================================================"
+echo "Video plays via WebView2; audio via the webview backend."
+echo "Bundles: $WIN_BUNDLES"
+echo ""
+
+bun install --frozen-lockfile 2>/dev/null || bun install
+bun run build
+
+# --runner cargo-xwin + the MSVC target is what makes the Tauri CLI treat this as
+# a real Windows build and enable the nsis/msi bundlers on a Linux host.
+#
+# IMPORTANT: do NOT pass `--bundles nsis` here. tauri-cli 2.9.x validates the
+# `--bundles` flag against a static clap enum gated by the HOST OS (Linux allows
+# only deb/rpm/appimage) *before* it considers --target/--runner, so `--bundles
+# nsis` is rejected at arg-parse time. Instead the Windows bundle targets come
+# from tauri.conf.json (bundle.targets includes "nsis"), which is not subject to
+# that CLI validation — the bundler then picks nsis once it knows the target is
+# Windows.
+if [[ "$WIN_BUNDLES" == "none" ]]; then
+ bun run tauri build --runner cargo-xwin --target "$TARGET" --no-bundle
+else
+ bun run tauri build --runner cargo-xwin --target "$TARGET"
+fi
+
+BIN_DIR="src-tauri/target/$TARGET/release"
+echo ""
+echo "✅ Built Windows artifacts:"
+find "$BIN_DIR" -maxdepth 1 -name '*.exe' -print
+find "$BIN_DIR/bundle" -type f \( -name '*.exe' -o -name '*.msi' \) -print 2>/dev/null || true
+
+if [[ -n "${OUTPUT_DIR:-}" ]]; then
+ mkdir -p "$OUTPUT_DIR"
+ find "$BIN_DIR" -maxdepth 1 -name 'jellytau.exe' -exec cp -v {} "$OUTPUT_DIR/" \;
+ # NSIS setup installers land in bundle/nsis/*-setup.exe; MSI in bundle/msi/*.msi.
+ find "$BIN_DIR/bundle" -type f \( -name '*-setup.exe' -o -name '*.msi' \) \
+ -exec cp -v {} "$OUTPUT_DIR/" \; 2>/dev/null || true
+ echo ""
+ echo "📦 Copied Windows artifacts to $OUTPUT_DIR"
+fi
diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json
index 08ea3ebb..a40f16e3 100644
--- a/src-tauri/tauri.conf.json
+++ b/src-tauri/tauri.conf.json
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "jellytau",
- "version": "0.0.18",
+ "version": "0.1.0",
"identifier": "com.dtourolle.jellytau",
"build": {
"beforeDevCommand": "bun run dev",
@@ -23,7 +23,7 @@
},
"bundle": {
"active": true,
- "targets": ["deb", "rpm"],
+ "targets": ["deb", "rpm", "nsis"],
"icon": [
"icons/32x32.png",
"icons/128x128.png",