Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bbdcdca47e | ||
|
|
6e16f188dc |
@@ -28,7 +28,7 @@ jobs:
|
||||
if: "!startsWith(github.event.head_commit.message, 'chore(release)')"
|
||||
runs-on: linux/amd64
|
||||
container:
|
||||
image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.08.1
|
||||
image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.08
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
@@ -89,15 +89,6 @@ jobs:
|
||||
# sit on master until somebody cut a tag. These three steps are what make
|
||||
# those configs load-bearing. All are project deps installed by
|
||||
# `bun install`; nothing is fetched at job time.
|
||||
# Cheap tripwire for a class of defect this repo kept hitting: tooling on
|
||||
# a rarely-taken path. scripts/build-android.sh ran `npm install` on its
|
||||
# clean-build branch -- in a bun project, ignoring bun.lock and
|
||||
# re-resolving the tree, which is how the Tauri plugin crate/package
|
||||
# versions drifted apart and broke a release build. It survived because
|
||||
# clean builds are rare.
|
||||
- name: Check build tooling
|
||||
run: bash scripts/check-tooling.sh
|
||||
|
||||
- name: Check formatting
|
||||
run: bun run format:check
|
||||
|
||||
@@ -116,28 +107,6 @@ jobs:
|
||||
bunx svelte-kit sync
|
||||
bun run check
|
||||
|
||||
# Tauri refuses to build when a plugin's Rust crate and npm package are on
|
||||
# different minor versions. Nothing here runs `tauri build` -- that only
|
||||
# happens on a tag -- so a mismatch introduced on master stayed invisible
|
||||
# until the release build, which is where it was found: v0.10.0 prep hit
|
||||
# `tauri-plugin-log (v2.8.0) : @tauri-apps/plugin-log (v2.9.0)`. `cargo
|
||||
# check`, clippy, the tests and svelte-check had all passed.
|
||||
#
|
||||
# `tauri info` performs the same comparison the bundler does, without a
|
||||
# build. Grepping its output is crude, but the alternative is discovering
|
||||
# this at tag time again.
|
||||
- name: Check Tauri plugin versions match
|
||||
run: |
|
||||
set -e
|
||||
if bunx tauri info 2>&1 | tee /tmp/tauri-info.txt | grep -q "version mismatched"; then
|
||||
echo "::error::A Tauri plugin's Rust crate and npm package versions disagree."
|
||||
echo "::error::The release build will refuse to start. Align them in"
|
||||
echo "::error::src-tauri/Cargo.toml and package.json (both are pinned exactly)."
|
||||
grep -A6 "version mismatched" /tmp/tauri-info.txt || true
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ Tauri plugin crate/package versions agree."
|
||||
|
||||
# Coverage rather than a bare `bun run test`: same suite, plus the
|
||||
# thresholds in vitest.config.ts, so a large untested module or a deleted
|
||||
# test fails here instead of being noticed months later.
|
||||
@@ -187,7 +156,7 @@ jobs:
|
||||
runs-on: linux/amd64
|
||||
needs: test
|
||||
container:
|
||||
image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.08.1
|
||||
image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.08
|
||||
env:
|
||||
ANDROID_HOME: /opt/android-sdk
|
||||
ANDROID_SDK_ROOT: /opt/android-sdk
|
||||
@@ -256,7 +225,7 @@ jobs:
|
||||
name: Supply Chain
|
||||
runs-on: linux/amd64
|
||||
container:
|
||||
image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.08.1
|
||||
image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.08
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
|
||||
@@ -21,7 +21,7 @@ jobs:
|
||||
name: Run Tests
|
||||
runs-on: linux/amd64
|
||||
container:
|
||||
image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.08.1
|
||||
image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.08
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
@@ -94,7 +94,7 @@ jobs:
|
||||
runs-on: linux/amd64
|
||||
needs: test
|
||||
container:
|
||||
image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.08.1
|
||||
image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.08
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
@@ -146,29 +146,9 @@ jobs:
|
||||
# If TAURI_SIGNING_PRIVATE_KEY is ever absent the build fails loudly rather
|
||||
# than quietly shipping an unsigned release that no client will accept --
|
||||
# which is the behaviour we want.
|
||||
# Same hazard as the Windows job: the bundle directory is never cleaned by
|
||||
# cargo and the runner reuses src-tauri/target, while the copy step below
|
||||
# globs bundle/deb/*.deb and friends. Windows is where this actually bit
|
||||
# (v0.8.2 shipped thirteen stale installers), but only because Linux
|
||||
# packaging is newer -- the glob is identical. Remove the directory so a
|
||||
# stale artifact cannot exist to be copied.
|
||||
- name: Clear previous bundle output
|
||||
run: rm -rf src-tauri/target/release/bundle
|
||||
|
||||
- name: Build for Linux
|
||||
run: bun run tauri build
|
||||
env:
|
||||
# linuxdeploy's bundled `strip` cannot parse the `.relr.dyn` section
|
||||
# modern toolchains emit, and fails on every bundled library:
|
||||
# strip: libzstd.so.1: unknown type [0x13] section `.relr.dyn'
|
||||
# failed to bundle project `failed to run linuxdeploy`
|
||||
# Ubuntu 23.10+ links with -z pack-relative-relocs by default, so this
|
||||
# image hits it. Skipping strip is linuxdeploy's documented escape
|
||||
# hatch; the cost is a larger AppImage. Found by building the target
|
||||
# locally before tagging -- nothing in CI builds the app, so a release
|
||||
# would have been the first time anyone discovered the AppImage target
|
||||
# does not work.
|
||||
NO_STRIP: "true"
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
|
||||
@@ -190,15 +170,12 @@ jobs:
|
||||
# Without nullglob an unmatched pattern stays literal, so test each
|
||||
# candidate instead. Same POSIX-only rule as traceability-check.yml.
|
||||
#
|
||||
# Tauri v2 signs the .AppImage ITSELF and writes <name>.AppImage.sig
|
||||
# beside it -- there is no .AppImage.tar.gz unless
|
||||
# bundle.createUpdaterArtifacts is set to "v1Compatible". The updater
|
||||
# downloads the same AppImage a human does and verifies that .sig, so
|
||||
# both files must ship or the manifest points at a signature nobody
|
||||
# can fetch.
|
||||
# The .AppImage.tar.gz + .sig pair is what the updater downloads and
|
||||
# verifies; the plain .AppImage is what a human downloads. Both ship.
|
||||
for bundle in \
|
||||
src-tauri/target/release/bundle/appimage/*.AppImage \
|
||||
src-tauri/target/release/bundle/appimage/*.AppImage.sig \
|
||||
src-tauri/target/release/bundle/appimage/*.AppImage.tar.gz \
|
||||
src-tauri/target/release/bundle/appimage/*.AppImage.tar.gz.sig \
|
||||
src-tauri/target/release/bundle/deb/*.deb \
|
||||
src-tauri/target/release/bundle/rpm/*.rpm; do
|
||||
[ -e "$bundle" ] || continue
|
||||
@@ -235,7 +212,7 @@ jobs:
|
||||
# baked into the builder image. No toolchain installs here — the image has
|
||||
# cargo-xwin, clang/clang-cl, lld, llvm, nsis and the msvc target.
|
||||
container:
|
||||
image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.08.1
|
||||
image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.08
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
@@ -308,7 +285,7 @@ jobs:
|
||||
runs-on: linux/amd64
|
||||
needs: test
|
||||
container:
|
||||
image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.08.1
|
||||
image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.08
|
||||
env:
|
||||
ANDROID_HOME: /opt/android-sdk
|
||||
ANDROID_SDK_ROOT: /opt/android-sdk
|
||||
@@ -380,12 +357,8 @@ jobs:
|
||||
keyPassword=${{ secrets.ANDROID_KEY_PASSWORD }}
|
||||
EOF
|
||||
|
||||
# `--apk` is a boolean flag, not `--apk true`. tauri-cli took a value here
|
||||
# until 2.10; from 2.11 the stray `true` is parsed as a positional and the
|
||||
# command fails with "unexpected argument 'true' found" before building.
|
||||
# This line and scripts/build-android.sh must agree.
|
||||
- name: Build signed Android APK
|
||||
run: bun run tauri android build --apk --target aarch64
|
||||
run: bun run tauri android build --apk true --target aarch64
|
||||
|
||||
- name: Collect & verify signed APK
|
||||
run: |
|
||||
@@ -411,7 +384,7 @@ jobs:
|
||||
needs: [build-linux, build-windows, build-android]
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
container:
|
||||
image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.08.1
|
||||
image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.08
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
@@ -440,19 +413,6 @@ jobs:
|
||||
name: jellytau-android
|
||||
path: artifacts/android/
|
||||
|
||||
# Runs before the SBOM, the checksums and the upload -- everything
|
||||
# downstream describes this set of files, so a stale artifact must be
|
||||
# caught before it gets hashed into SHA256SUMS and published as though it
|
||||
# belonged to this release.
|
||||
#
|
||||
# See the script for the eight months of releases that shipped their
|
||||
# predecessors' Windows installers.
|
||||
- name: Verify artifacts belong to this release
|
||||
run: |
|
||||
./scripts/check-release-artifacts.sh \
|
||||
"${{ steps.tag_name.outputs.VERSION }}" \
|
||||
artifacts/linux artifacts/windows artifacts/android
|
||||
|
||||
# Software Bill of Materials, one per half of the app. Without it there is
|
||||
# no answer to "does this release contain <vulnerable crate>?" other than
|
||||
# rebuilding the tag and re-resolving it. cargo-cyclonedx is in the builder
|
||||
@@ -502,13 +462,8 @@ jobs:
|
||||
APPIMAGE_URL=""
|
||||
NSIS_URL=""
|
||||
|
||||
# Tauri v2 signs the AppImage itself; <name>.AppImage.sig sits beside
|
||||
# it. Verified against a real signed build before tagging -- the
|
||||
# v1-style .AppImage.tar.gz is never produced with
|
||||
# createUpdaterArtifacts: true.
|
||||
for f in artifacts/linux/*.AppImage; do
|
||||
for f in artifacts/linux/*.AppImage.tar.gz; do
|
||||
[ -e "$f" ] || continue
|
||||
case "$f" in *.sig) continue;; esac
|
||||
APPIMAGE_URL="${BASE}/$(basename "$f")"
|
||||
[ -e "$f.sig" ] && APPIMAGE_SIG="$(cat "$f.sig")"
|
||||
done
|
||||
@@ -527,11 +482,9 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# What the in-app update prompt shows. Same reviewed source as the
|
||||
# release body -- the CHANGELOG section for this version, not the
|
||||
# traceability draft.
|
||||
NOTES="$(awk -v ver="## $VERSION" '$0==ver{f=1;next} /^## /{if(f)exit} f' CHANGELOG.md | head -c 4000)"
|
||||
[ -n "$NOTES" ] || NOTES="See the release page for details."
|
||||
# Release notes for the update prompt come from the traceability graph,
|
||||
# same source as the release body.
|
||||
NOTES="$(bun run release:notes 2>/dev/null | head -c 4000 || echo "See the release page for details.")"
|
||||
|
||||
jq -n \
|
||||
--arg version "$PLAIN" \
|
||||
@@ -596,37 +549,24 @@ jobs:
|
||||
# release rather than shipping and failing for users.
|
||||
sha256sum -c SHA256SUMS
|
||||
|
||||
# The published body is the hand-written CHANGELOG.md section for this
|
||||
# version. `bun run release:notes` is printed into the job log as a
|
||||
# drafting aid, but is NOT published: CLAUDE.md is explicit that its
|
||||
# output is "a reviewed draft, not a final changelog", and publishing it
|
||||
# unreviewed proved the point -- a range containing a repo-wide prettier
|
||||
# sweep resolved to nearly the whole requirement matrix and produced notes
|
||||
# claiming one release had added the entire application.
|
||||
#
|
||||
# A missing CHANGELOG section fails the release. A release whose notes say
|
||||
# nothing is worse than one that waits for a maintainer to write two
|
||||
# sentences, and the checklist already requires that entry.
|
||||
# Release notes come from the traceability graph, not from a hardcoded
|
||||
# heredoc. scripts/release-notes.ts resolves the commit range's changed
|
||||
# files to their TRACES ids and then to requirement descriptions, grouping
|
||||
# UR into Features and DR/IR into Improvements -- which is what CLAUDE.md
|
||||
# has asked for all along, while this workflow pasted a fixed block of
|
||||
# install instructions and a line saying "see CHANGELOG.md for detailed
|
||||
# changes". It also linked "GitHub Issues" on a Gitea-hosted project.
|
||||
- name: Prepare release notes
|
||||
id: release_notes
|
||||
run: |
|
||||
set -e
|
||||
VERSION="${{ steps.tag_name.outputs.VERSION }}"
|
||||
|
||||
echo "📋 Traceability draft (for reference; not published):"
|
||||
bun run release:notes 2>/dev/null || echo "(could not derive a draft)"
|
||||
echo ""
|
||||
|
||||
# The section between this version's heading and the next one.
|
||||
CHANGES=$(awk -v ver="## $VERSION" '$0==ver{f=1;next} /^## /{if(f)exit} f' CHANGELOG.md)
|
||||
if [ -z "$(echo "$CHANGES" | tr -d '[:space:]')" ]; then
|
||||
echo "::error::CHANGELOG.md has no '## $VERSION' section."
|
||||
echo "::error::Add the entry for this version and re-tag; see docs/release-checklist.md."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
{
|
||||
echo "$CHANGES"
|
||||
echo "## JellyTau $VERSION"
|
||||
echo ""
|
||||
# A generated summary of what actually changed; falls back to a
|
||||
# pointer rather than failing the release if the range is odd.
|
||||
bun run release:notes 2>/dev/null || echo "See the commit log for changes in this release."
|
||||
echo ""
|
||||
echo "### Downloads"
|
||||
echo ""
|
||||
@@ -638,8 +578,8 @@ jobs:
|
||||
echo "| Windows | \`*-setup.exe\` (NSIS). Unsigned — SmartScreen may warn on first run. |"
|
||||
echo "| Android | \`*.apk\` sideload, or \`*.aab\` for Play Console |"
|
||||
echo ""
|
||||
echo "Desktop builds check for updates from here and can install a new"
|
||||
echo "version in place, verifying its signature first."
|
||||
echo "Desktop builds update themselves from here on: JellyTau checks this"
|
||||
echo "release feed and can install a new version in place."
|
||||
echo ""
|
||||
echo "### Verifying your download"
|
||||
echo ""
|
||||
@@ -659,7 +599,6 @@ jobs:
|
||||
echo "---"
|
||||
echo "Report a problem: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/issues"
|
||||
} > release_notes.md
|
||||
|
||||
echo "📝 Release notes:"
|
||||
cat release_notes.md
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ jobs:
|
||||
name: Build & publish docs to gitea-pages
|
||||
runs-on: linux/amd64
|
||||
container:
|
||||
image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.08.1
|
||||
image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.08
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
|
||||
@@ -17,7 +17,7 @@ jobs:
|
||||
runs-on: linux/amd64
|
||||
name: Check Requirement Traces
|
||||
container:
|
||||
image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.08.1
|
||||
image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.08
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
|
||||
@@ -9,105 +9,6 @@ generated trace matrix lives in [docs/traceability.md](docs/traceability.md).
|
||||
For how long each fixed defect had been shipping before it was found, see
|
||||
[docs/defect-windows.md](docs/defect-windows.md).
|
||||
|
||||
## v0.10.0
|
||||
|
||||
Two things you can see, and a great deal of work on how this project builds and
|
||||
ships itself. The app can now update itself, and it can tell you what it did
|
||||
when something goes wrong — both of which existed as gaps rather than as bugs,
|
||||
which is why they lasted so long.
|
||||
|
||||
### ✨ Changes
|
||||
|
||||
- **JellyTau can update itself.** Anyone who installed an AppImage or ran the
|
||||
Windows installer was frozen on that version permanently: nothing in the app
|
||||
ever mentioned that a newer one existed, and the release page was the only
|
||||
announcement. Settings → Updates now checks, shows what changed, and installs
|
||||
and restarts on request. Each download is verified against JellyTau's signing
|
||||
key before anything is installed, so a substituted file is refused rather than
|
||||
run. Android is deliberately not wired to this — an app may not replace its own
|
||||
APK, that is the system installer's job — and is given a link to the releases
|
||||
page instead of a button that would fail. (UR-077 → DR-217)
|
||||
|
||||
- **You can export a diagnostics bundle.** Until now the app forgot everything it
|
||||
had done the moment it closed. Logs went to standard output, which nobody sees
|
||||
when launching from a desktop icon, and on Android went nowhere at all — so the
|
||||
backend was invisible on the platform where the hardest playback bugs live. A
|
||||
crash left nothing behind. Logs are now kept in a size-capped file that
|
||||
survives a restart, a crash is recorded before the app dies, and Settings →
|
||||
Diagnostics exports the lot as one file to attach to a bug report. Access
|
||||
tokens and passwords are stripped before anything is written to disk, not
|
||||
merely before it is exported. Nothing is transmitted anywhere; you attach the
|
||||
file yourself. (UR-078 → DR-218)
|
||||
|
||||
- **Linux gets an AppImage again.** The release notes have advertised one for
|
||||
months while the build never produced it — the packaging step looked for the
|
||||
file, found nothing, and said nothing. (DR-217)
|
||||
|
||||
### 🐛 Fixes
|
||||
|
||||
- **Releases no longer ship every Windows installer ever built.** Every release
|
||||
from v0.1.0 to v0.8.2 carried its predecessors': sixteen installers on v0.8.2,
|
||||
thirteen of them stale, and a download list on v0.5.0 reaching back to 0.1.0.
|
||||
The build directory is never cleaned and the build machine reuses it, so each
|
||||
release collected whatever was left behind. It went unnoticed for eight months
|
||||
because nothing looked wrong — the files were real and the page merely looked
|
||||
busy. The stale files have been removed from the published releases, the build
|
||||
now clears that directory first, and a check refuses to publish a release
|
||||
containing an artifact from a different version. (DR-220)
|
||||
|
||||
- **Release notes now say what changed.** All 35 previous releases published the
|
||||
same block of generic install instructions, whose "What's New" section was a
|
||||
link to a file that does not resolve from a release page. Every release page
|
||||
now carries its own entry from this changelog, and the past ones have been
|
||||
filled in. (DR-219)
|
||||
|
||||
### 🔒 Security and supply chain
|
||||
|
||||
- **Dependencies are now checked against a vulnerability database on every
|
||||
build.** They never had been. The first run found eight vulnerabilities and one
|
||||
unsoundness in the Rust dependency graph — all of them fixed by an update
|
||||
nobody had a reason to run. Licences are checked against an allow-list too, so
|
||||
nothing gets redistributed inside a release that does not permit it.
|
||||
(DR-216)
|
||||
|
||||
- **Every release publishes checksums and a bill of materials.** `SHA256SUMS`
|
||||
lets you verify a download (`sha256sum -c SHA256SUMS`); the SBOM lists what
|
||||
went into the build, so "does this release contain <vulnerable library>?" has
|
||||
an answer that is not "rebuild it and find out". (DR-216)
|
||||
|
||||
- **Builds are reproducible again.** Every CI job named a container image tag
|
||||
that was rewritten in place, so rebuilding an old release did not necessarily
|
||||
rebuild the same thing. Jobs now pin an immutable tag. The one dependency that
|
||||
comes from a git branch rather than a package registry is pinned to an exact
|
||||
revision, closing a path by which new upstream code could arrive unreviewed in
|
||||
a library linked into the player. (DR-216)
|
||||
|
||||
### 🧹 Under the hood
|
||||
|
||||
- Formatting, linting and type-checking now run in CI. All three were configured
|
||||
and enforced by nothing: 199 files did not match the project's own formatter, a
|
||||
type error could sit on the main branch until somebody cut a release, and the
|
||||
test-coverage command had been broken for months by a dependency mismatch.
|
||||
Coverage now has a floor that only moves up. (DR-215)
|
||||
|
||||
- The traceability matrix counts requirements implemented by configuration.
|
||||
Several carried the necessary annotations and were being counted as uncovered
|
||||
because the extraction tool only read source files. (DR-215)
|
||||
|
||||
- The project now has a security policy, contribution guide, code of conduct,
|
||||
issue and pull-request templates, and an operations document covering the
|
||||
builder image, the release secrets, and what losing the signing key would mean.
|
||||
|
||||
- The app framework moved from Tauri 2.9.5 to 2.11.5. Nothing about this is
|
||||
visible in use, but it is worth recording that it did not go quietly: the
|
||||
windowing layer beneath Tauri quietly stopped publishing the Android JavaVM
|
||||
and application handle that this app's credential storage had been reading for
|
||||
its whole life. Nothing here had changed; a side effect several dependencies
|
||||
down had simply gone away, and the app aborted on launch on every Android
|
||||
device. JellyTau now sets that handle itself rather than relying on someone
|
||||
else to do it. Caught by installing on a real tablet before release — no test
|
||||
suite runs the app. (UR-012 → DR-223)
|
||||
|
||||
## v0.9.1
|
||||
|
||||
A one-line fix to the home screen, released on its own because it is the kind of
|
||||
|
||||
@@ -141,17 +141,6 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
lld \
|
||||
llvm \
|
||||
nsis \
|
||||
# AppImage bundling. linuxdeploy embeds xdg-open into the AppImage and
|
||||
# aborts the whole bundle if it is missing:
|
||||
# failed to bundle project: xdg-open binary not found
|
||||
# It is present on most desktop distros, which is why the AppImage built on
|
||||
# a developer machine and failed here. desktop-file-utils and zsync are the
|
||||
# other two linuxdeploy commonly wants (desktop-file-validate, and zsync for
|
||||
# delta updates), added together so a missing one does not cost another
|
||||
# image rebuild and another failed release build.
|
||||
xdg-utils \
|
||||
desktop-file-utils \
|
||||
zsync \
|
||||
&& 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,
|
||||
|
||||
@@ -5,12 +5,12 @@
|
||||
"": {
|
||||
"name": "jellytau",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.11.1",
|
||||
"@tauri-apps/plugin-log": "2.9.0",
|
||||
"@tauri-apps/plugin-opener": "^2.5.4",
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-log": "^2.9.0",
|
||||
"@tauri-apps/plugin-opener": "^2",
|
||||
"@tauri-apps/plugin-os": "^2.3.2",
|
||||
"@tauri-apps/plugin-process": "^2.3.1",
|
||||
"@tauri-apps/plugin-updater": "2.10.1",
|
||||
"@tauri-apps/plugin-updater": "^2.10.1",
|
||||
"hls.js": "^1.6.15",
|
||||
"svelte-dnd-action": "^0.9.69",
|
||||
},
|
||||
@@ -20,7 +20,7 @@
|
||||
"@sveltejs/kit": "^2.9.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@tauri-apps/cli": "^2.11.4",
|
||||
"@tauri-apps/cli": "^2",
|
||||
"@testing-library/svelte": "^5.3.1",
|
||||
"@vitest/coverage-v8": "^4.0.18",
|
||||
"@vitest/ui": "^4.0.16",
|
||||
@@ -255,35 +255,35 @@
|
||||
|
||||
"@tailwindcss/vite": ["@tailwindcss/vite@4.1.18", "", { "dependencies": { "@tailwindcss/node": "4.1.18", "@tailwindcss/oxide": "4.1.18", "tailwindcss": "4.1.18" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-jVA+/UpKL1vRLg6Hkao5jldawNmRo7mQYrZtNHMIVpLfLhDml5nMRUo/8MwoX2vNXvnaXNNMedrMfMugAVX1nA=="],
|
||||
|
||||
"@tauri-apps/api": ["@tauri-apps/api@2.11.1", "", {}, "sha512-M2FPuYND2m+wh5hfW9ZpSdxMPdEJovPBWwoHJmwUpysTYNHaOkVFN419m/K0LIgjb/7KU2vBgsUepJWugQCvAA=="],
|
||||
"@tauri-apps/api": ["@tauri-apps/api@2.9.1", "", {}, "sha512-IGlhP6EivjXHepbBic618GOmiWe4URJiIeZFlB7x3czM0yDHHYviH1Xvoiv4FefdkQtn6v7TuwWCRfOGdnVUGw=="],
|
||||
|
||||
"@tauri-apps/cli": ["@tauri-apps/cli@2.11.4", "", { "optionalDependencies": { "@tauri-apps/cli-darwin-arm64": "2.11.4", "@tauri-apps/cli-darwin-x64": "2.11.4", "@tauri-apps/cli-linux-arm-gnueabihf": "2.11.4", "@tauri-apps/cli-linux-arm64-gnu": "2.11.4", "@tauri-apps/cli-linux-arm64-musl": "2.11.4", "@tauri-apps/cli-linux-riscv64-gnu": "2.11.4", "@tauri-apps/cli-linux-x64-gnu": "2.11.4", "@tauri-apps/cli-linux-x64-musl": "2.11.4", "@tauri-apps/cli-win32-arm64-msvc": "2.11.4", "@tauri-apps/cli-win32-ia32-msvc": "2.11.4", "@tauri-apps/cli-win32-x64-msvc": "2.11.4" }, "bin": { "tauri": "tauri.js" } }, "sha512-R8xGtMpwyetawSqm9kYOuMmEqkhUbvcUy8n0aNXIxollKBLESUu5f4Fx+64hgASYm1H+jSWq6jCW6zqTnH6hqQ=="],
|
||||
"@tauri-apps/cli": ["@tauri-apps/cli@2.9.6", "", { "optionalDependencies": { "@tauri-apps/cli-darwin-arm64": "2.9.6", "@tauri-apps/cli-darwin-x64": "2.9.6", "@tauri-apps/cli-linux-arm-gnueabihf": "2.9.6", "@tauri-apps/cli-linux-arm64-gnu": "2.9.6", "@tauri-apps/cli-linux-arm64-musl": "2.9.6", "@tauri-apps/cli-linux-riscv64-gnu": "2.9.6", "@tauri-apps/cli-linux-x64-gnu": "2.9.6", "@tauri-apps/cli-linux-x64-musl": "2.9.6", "@tauri-apps/cli-win32-arm64-msvc": "2.9.6", "@tauri-apps/cli-win32-ia32-msvc": "2.9.6", "@tauri-apps/cli-win32-x64-msvc": "2.9.6" }, "bin": { "tauri": "tauri.js" } }, "sha512-3xDdXL5omQ3sPfBfdC8fCtDKcnyV7OqyzQgfyT5P3+zY6lcPqIYKQBvUasNvppi21RSdfhy44ttvJmftb0PCDw=="],
|
||||
|
||||
"@tauri-apps/cli-darwin-arm64": ["@tauri-apps/cli-darwin-arm64@2.11.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-1ryOF3ZhpZ/nemHV5zVwBQBz9jDGKmKPvWPADOhc83ig0P4bMc2iER4NbC6r9sjeIZ6RVQ4g3RZIYvezhcl4TQ=="],
|
||||
"@tauri-apps/cli-darwin-arm64": ["@tauri-apps/cli-darwin-arm64@2.9.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gf5no6N9FCk1qMrti4lfwP77JHP5haASZgVbBgpZG7BUepB3fhiLCXGUK8LvuOjP36HivXewjg72LTnPDScnQQ=="],
|
||||
|
||||
"@tauri-apps/cli-darwin-x64": ["@tauri-apps/cli-darwin-x64@2.11.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-uFsGQAAfuyz1k/yGLmkWfkBlgKAqZfxqlHmLWx81QU27RJWfmbNHCIq8T8w1e+VClleIuZUjpHWfoE4E3DLo3A=="],
|
||||
"@tauri-apps/cli-darwin-x64": ["@tauri-apps/cli-darwin-x64@2.9.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-oWh74WmqbERwwrwcueJyY6HYhgCksUc6NT7WKeXyrlY/FPmNgdyQAgcLuTSkhRFuQ6zh4Np1HZpOqCTpeZBDcw=="],
|
||||
|
||||
"@tauri-apps/cli-linux-arm-gnueabihf": ["@tauri-apps/cli-linux-arm-gnueabihf@2.11.4", "", { "os": "linux", "cpu": "arm" }, "sha512-IaHZn5CdBL21oUmjiVOS1ctw6Ip1O0pjp70FwOWmYz1myWe0SY96ZIj2FYf7pT0m8bI2h/hrs5ZbEXXh44/MkQ=="],
|
||||
"@tauri-apps/cli-linux-arm-gnueabihf": ["@tauri-apps/cli-linux-arm-gnueabihf@2.9.6", "", { "os": "linux", "cpu": "arm" }, "sha512-/zde3bFroFsNXOHN204DC2qUxAcAanUjVXXSdEGmhwMUZeAQalNj5cz2Qli2elsRjKN/hVbZOJj0gQ5zaYUjSg=="],
|
||||
|
||||
"@tauri-apps/cli-linux-arm64-gnu": ["@tauri-apps/cli-linux-arm64-gnu@2.11.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-N41/ukTRVe6XSuUTESuFdGeOW2i7k62tK+6gHK5Kd5/q5RPvvi19GaWAVPPb9u95HSGmTChSolBfzynUsssFaA=="],
|
||||
"@tauri-apps/cli-linux-arm64-gnu": ["@tauri-apps/cli-linux-arm64-gnu@2.9.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-pvbljdhp9VOo4RnID5ywSxgBs7qiylTPlK56cTk7InR3kYSTJKYMqv/4Q/4rGo/mG8cVppesKIeBMH42fw6wjg=="],
|
||||
|
||||
"@tauri-apps/cli-linux-arm64-musl": ["@tauri-apps/cli-linux-arm64-musl@2.11.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-v277UnT/fB64xAfSroL5N3Km3tLmvATWqJJw/wRI+g6o+HkeD0slyE7gOhNs1MbjE41R7bQOTxMVoL3aomUJmw=="],
|
||||
"@tauri-apps/cli-linux-arm64-musl": ["@tauri-apps/cli-linux-arm64-musl@2.9.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-02TKUndpodXBCR0oP//6dZWGYcc22Upf2eP27NvC6z0DIqvkBBFziQUcvi2n6SrwTRL0yGgQjkm9K5NIn8s6jw=="],
|
||||
|
||||
"@tauri-apps/cli-linux-riscv64-gnu": ["@tauri-apps/cli-linux-riscv64-gnu@2.11.4", "", { "os": "linux", "cpu": "none" }, "sha512-qqgNkQ2u1yZHxjhxsZaxUtRDW8dIqIYm33rx/mzwQv0SfY9x1B+iraj8vWeFiXjjSVVhEMepXSOts1TqPzvXNQ=="],
|
||||
"@tauri-apps/cli-linux-riscv64-gnu": ["@tauri-apps/cli-linux-riscv64-gnu@2.9.6", "", { "os": "linux", "cpu": "none" }, "sha512-fmp1hnulbqzl1GkXl4aTX9fV+ubHw2LqlLH1PE3BxZ11EQk+l/TmiEongjnxF0ie4kV8DQfDNJ1KGiIdWe1GvQ=="],
|
||||
|
||||
"@tauri-apps/cli-linux-x64-gnu": ["@tauri-apps/cli-linux-x64-gnu@2.11.4", "", { "os": "linux", "cpu": "x64" }, "sha512-2VRNWl84FOH0m2giiDkO2h0QXlcMJeX+zJDpI5kDIQAx6s+geF3v48F4DXfJez4GS/FdoDGnPnw1C2iYGbQ7bQ=="],
|
||||
"@tauri-apps/cli-linux-x64-gnu": ["@tauri-apps/cli-linux-x64-gnu@2.9.6", "", { "os": "linux", "cpu": "x64" }, "sha512-vY0le8ad2KaV1PJr+jCd8fUF9VOjwwQP/uBuTJvhvKTloEwxYA/kAjKK9OpIslGA9m/zcnSo74czI6bBrm2sYA=="],
|
||||
|
||||
"@tauri-apps/cli-linux-x64-musl": ["@tauri-apps/cli-linux-x64-musl@2.11.4", "", { "os": "linux", "cpu": "x64" }, "sha512-o9GyhYor/nc7xarmwDE3ka2szuW3uuZzXjHWh64Q8YX5AtSgxdQkFWzrY4O8KiGtVNvFBI14H3Q49Qj5TOIP/A=="],
|
||||
"@tauri-apps/cli-linux-x64-musl": ["@tauri-apps/cli-linux-x64-musl@2.9.6", "", { "os": "linux", "cpu": "x64" }, "sha512-TOEuB8YCFZTWVDzsO2yW0+zGcoMiPPwcUgdnW1ODnmgfwccpnihDRoks+ABT1e3fHb1ol8QQWsHSCovb3o2ENQ=="],
|
||||
|
||||
"@tauri-apps/cli-win32-arm64-msvc": ["@tauri-apps/cli-win32-arm64-msvc@2.11.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-ld5Ehb598m0VkYyylRPNeCFsBe/km0jxis6KgMpl3IGY6I/i1RwQXO05I1AsXUXO2WC6AvB/Lw4qTf/asiuEiQ=="],
|
||||
"@tauri-apps/cli-win32-arm64-msvc": ["@tauri-apps/cli-win32-arm64-msvc@2.9.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-ujmDGMRc4qRLAnj8nNG26Rlz9klJ0I0jmZs2BPpmNNf0gM/rcVHhqbEkAaHPTBVIrtUdf7bGvQAD2pyIiUrBHQ=="],
|
||||
|
||||
"@tauri-apps/cli-win32-ia32-msvc": ["@tauri-apps/cli-win32-ia32-msvc@2.11.4", "", { "os": "win32", "cpu": "ia32" }, "sha512-12Hxi0XX/H5VFxO/bGgHkFWhml9VMgEOu9CidjeCeTNQ1l6fpUlbiGgSP7CLI3PFtW9/FfbeHieZ+kyWK5H7CA=="],
|
||||
"@tauri-apps/cli-win32-ia32-msvc": ["@tauri-apps/cli-win32-ia32-msvc@2.9.6", "", { "os": "win32", "cpu": "ia32" }, "sha512-S4pT0yAJgFX8QRCyKA1iKjZ9Q/oPjCZf66A/VlG5Yw54Nnr88J1uBpmenINbXxzyhduWrIXBaUbEY1K80ZbpMg=="],
|
||||
|
||||
"@tauri-apps/cli-win32-x64-msvc": ["@tauri-apps/cli-win32-x64-msvc@2.11.4", "", { "os": "win32", "cpu": "x64" }, "sha512-+vDiqBIU5dMISg/wNvX3sF+ZHfgJGJ5T0AcO+EHNXV9GGAG+P5fzodlDXD3QdKCRgZxMoCm5PPvj3BqLNjBthw=="],
|
||||
"@tauri-apps/cli-win32-x64-msvc": ["@tauri-apps/cli-win32-x64-msvc@2.9.6", "", { "os": "win32", "cpu": "x64" }, "sha512-ldWuWSSkWbKOPjQMJoYVj9wLHcOniv7diyI5UAJ4XsBdtaFB0pKHQsqw/ItUma0VXGC7vB4E9fZjivmxur60aw=="],
|
||||
|
||||
"@tauri-apps/plugin-log": ["@tauri-apps/plugin-log@2.9.0", "", { "dependencies": { "@tauri-apps/api": "^2.11.0" } }, "sha512-Ql8okrnsguk0eDq1GvRfttFV5KaeW/7vcao6bdbkXCRJ1+2sWE15ZJvJVEKVANrOKy1mRngqC3IFIAP+wP5qSw=="],
|
||||
|
||||
"@tauri-apps/plugin-opener": ["@tauri-apps/plugin-opener@2.5.4", "", { "dependencies": { "@tauri-apps/api": "^2.11.0" } }, "sha512-1HnPkb+AmgO29HBazm4uPLKB+r7zzcTBW1d0fyYp1uP+jwtpoiNDGKMMzz58SFp49nOIrxdE3aUJtT57lfO9CQ=="],
|
||||
"@tauri-apps/plugin-opener": ["@tauri-apps/plugin-opener@2.5.2", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-ei/yRRoCklWHImwpCcDK3VhNXx+QXM9793aQ64YxpqVF0BDuuIlXhZgiAkc15wnPVav+IbkYhmDJIv5R326Mew=="],
|
||||
|
||||
"@tauri-apps/plugin-os": ["@tauri-apps/plugin-os@2.3.2", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-n+nXWeuSeF9wcEsSPmRnBEGrRgOy6jjkSU+UVCOV8YUGKb2erhDOxis7IqRXiRVHhY8XMKks00BJ0OAdkpf6+A=="],
|
||||
|
||||
@@ -761,9 +761,9 @@
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"@tauri-apps/plugin-os/@tauri-apps/api": ["@tauri-apps/api@2.9.1", "", {}, "sha512-IGlhP6EivjXHepbBic618GOmiWe4URJiIeZFlB7x3czM0yDHHYviH1Xvoiv4FefdkQtn6v7TuwWCRfOGdnVUGw=="],
|
||||
"@tauri-apps/plugin-log/@tauri-apps/api": ["@tauri-apps/api@2.11.1", "", {}, "sha512-M2FPuYND2m+wh5hfW9ZpSdxMPdEJovPBWwoHJmwUpysTYNHaOkVFN419m/K0LIgjb/7KU2vBgsUepJWugQCvAA=="],
|
||||
|
||||
"@tauri-apps/plugin-process/@tauri-apps/api": ["@tauri-apps/api@2.9.1", "", {}, "sha512-IGlhP6EivjXHepbBic618GOmiWe4URJiIeZFlB7x3czM0yDHHYviH1Xvoiv4FefdkQtn6v7TuwWCRfOGdnVUGw=="],
|
||||
"@tauri-apps/plugin-updater/@tauri-apps/api": ["@tauri-apps/api@2.11.1", "", {}, "sha512-M2FPuYND2m+wh5hfW9ZpSdxMPdEJovPBWwoHJmwUpysTYNHaOkVFN419m/K0LIgjb/7KU2vBgsUepJWugQCvAA=="],
|
||||
|
||||
"@testing-library/dom/aria-query": ["aria-query@5.3.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A=="],
|
||||
|
||||
|
||||
@@ -33,7 +33,6 @@
|
||||
- [Spec Review Checklist](specs/SPEC-REVIEW-CHECKLIST.md)
|
||||
- [Playback Backend Unification](specs/playback-backend-unification.md)
|
||||
- [Linux Native Video Spike](specs/linux-native-video-spike.md)
|
||||
- [Backend-Owned Stream Selection](specs/backend-owned-stream-selection.md)
|
||||
- [Player Facade Enforcement](specs/player-facade-enforcement.md)
|
||||
- [Windows Native Audio Backend](specs/windows-native-audio-backend.md)
|
||||
- [libmpv2 Migration](specs/libmpv2-migration.md)
|
||||
|
||||
Vendored
-47
@@ -61,11 +61,6 @@ filled. Keep a couple of dated tags live and prune the rest.
|
||||
|
||||
The order matters — CI breaks if the workflow lands before the image exists.
|
||||
|
||||
A caveat learned the hard way: the *trailing* layer is only fast for `cargo
|
||||
install` tools. Adding an **apt** package invalidates the packaging layer, which
|
||||
sits above the `cargo-xwin`/`cargo-deny` installs, so those recompile too — a
|
||||
~20 minute rebuild rather than ~2.
|
||||
|
||||
```bash
|
||||
# 1. Edit Dockerfile.builder. Put new tools in the TRAILING layer: it exists so
|
||||
# a tool change is a ~2 min rebuild instead of ~15.
|
||||
@@ -85,48 +80,6 @@ docker run --rm gitea.tourolle.paris/dtourolle/jellytau-builder:2026.09 \
|
||||
toolchain inside the job — a toolchain install in CI. Bump both, rebuild, push,
|
||||
then merge.
|
||||
|
||||
## Tauri plugin versions are pinned in pairs
|
||||
|
||||
Every Tauri plugin exists twice: a Rust crate in `src-tauri/Cargo.toml` and an
|
||||
npm package in `package.json`. **The Tauri CLI refuses to build when the two are
|
||||
on different minor versions** — not a warning, a hard stop before compilation.
|
||||
|
||||
Both sides are therefore pinned *exactly* (`"2.8.0"`, not `"^2.8.0"`). A caret
|
||||
range is what let them drift apart in the first place: `bun add` took the latest
|
||||
npm package while cargo held an older crate, and nothing noticed until a release
|
||||
build refused to start.
|
||||
|
||||
Nothing in `build-and-test.yml` runs `tauri build` — that happens only on a tag —
|
||||
so this class of breakage used to be invisible until release day. The
|
||||
`Check Tauri plugin versions match` step runs `tauri info`, which performs the
|
||||
same comparison without building.
|
||||
|
||||
To upgrade a plugin, move **both** sides together and re-run that step. Expect
|
||||
the Rust side to be the constraint: a newer plugin crate may pull a large
|
||||
transitive upgrade (bumping `tauri-plugin-log` to 2.9.0 also moved `wry`,
|
||||
`wasm-bindgen`, `web-sys` and `webkit2gtk`), which touches the webview and
|
||||
therefore video playback. That is a change to make deliberately, with a full
|
||||
build and a playback check — not one to slip into a release.
|
||||
|
||||
## AppImage needs more than the Rust toolchain
|
||||
|
||||
`linuxdeploy` (which Tauri downloads at build time to assemble the AppImage)
|
||||
shells out to distro tools that a minimal server image does not have. It aborts
|
||||
the whole bundle on the first one missing:
|
||||
|
||||
```
|
||||
failed to bundle project: xdg-open binary not found
|
||||
```
|
||||
|
||||
The image therefore carries `xdg-utils`, `desktop-file-utils` and `zsync`. This
|
||||
is a class of failure that **cannot be caught by building locally**: a developer
|
||||
machine is a desktop and has all three, so the AppImage builds there and fails in
|
||||
CI. It cost one release build to find.
|
||||
|
||||
Tauri's AppImage bundler also downloads `linuxdeploy`, `AppRun` and two plugin
|
||||
scripts from GitHub during the build. That is Tauri's behaviour, not ours, but it
|
||||
means an AppImage build depends on GitHub being reachable from the runner.
|
||||
|
||||
## Secrets
|
||||
|
||||
Managed with the `tea` CLI (`tea actions secrets list`) or the repo settings UI.
|
||||
|
||||
@@ -410,11 +410,6 @@ Internal architecture, components, and application logic.
|
||||
| DR-216 | Dependencies are gated on known vulnerabilities and on licence compatibility, and the build graph is pinned to what is actually shipped. The project had no scanning of any kind: nothing checked the ~500-crate Rust graph or the JS packages against an advisory feed, and nothing checked that everything redistributed inside an MIT-licensed bundle permits it. The first run found eight vulnerabilities and one unsoundness — `bytes`, four in `rustls-webpki`, `time`, two in `quick-xml`, `rand` — every one closed by a `cargo update` nobody had reason to run. `cargo deny` (src-tauri/deny.toml) now runs in CI over advisories, licences, bans and sources. Two structural fixes matter as much as the gate: the graph is scoped to the targets actually shipped, so an advisory against an Apple-only path is correctly absent rather than ignored by ID; and the one git dependency (`libmpv`) is pinned by revision instead of by branch, since a branch means any `cargo update` silently substitutes new upstream code in the one dependency that is unsigned and links a C library into the player. Licence findings are recorded rather than waved through — `libmpv`/`libmpv-sys` are LGPL-2.1, which the app satisfies by dynamic linking, and that carries obligations (keep the linkage dynamic; ship libmpv's licence text with any bundle carrying the .so) | Tooling | - | Done |
|
||||
| DR-217 | In-app update, desktop only, over a manifest we control. `tauri-plugin-updater` and `tauri-plugin-process` are compiled for everything except Android/iOS — spelled as a target-triple cfg rather than `cfg(desktop)`, which Cargo does not evaluate in a `[target.'cfg(…)']` table and which therefore drops the dependency silently, surfacing much later as "Permission updater:default not found". The release workflow signs updater artifacts with a minisign key held in Gitea secrets and publishes `latest.json` to a dedicated `updater` branch, read over Gitea's raw-file URL: this instance serves `/releases/download/<tag>/<asset>` but returns 404 for `/releases/latest/download/<asset>`, so there is no stable latest-release URL to point at, and the docs branch is force-pushed by publish-docs.yml so it cannot host the manifest either. Bundle targets gain `appimage`, which the release notes had been advertising for months while `tauri.conf.json` never built it — the artifact step globbed for `*.AppImage`, found nothing, and said nothing | Tooling | UR-077 | Done |
|
||||
| DR-218 | Persistent, redacted logging and a diagnostics export. `tauri-plugin-log` replaces the `env_logger` stdout-only init, giving a rotating 5 MB file, a webview target in dev, and — the single largest gain — logcat on Android, where `env_logger`'s stdout went nowhere. **Redaction runs in the log formatter, not at export**: a credential in a file on the device is already a disclosure, so stripping it on the way out would be too late; the exporter redacts a second time to cover files written by older builds. `api_key`/`X-Emby-Token`/`Authorization`/`"AccessToken"`/`Token="…"` all reduce to `[REDACTED]` while host, item ids and filenames are deliberately kept — a bundle scrubbed of those is one nobody can debug from. The server URL is reduced to scheme and host, dropping any embedded `user:pass@`. The panic hook chains to the previous hook rather than replacing it, because `utils/lock.rs` installs a silencing hook around tests that provoke poisoned locks on purpose. The chosen level persists to disk and is re-applied at startup, since reproducing a bug usually means restarting into it. The frontend facade keeps its untouched `console.*` pass-through (DR-204) and additionally forwards a stringified copy at info and above, so one file holds both halves of the app in order — which is what makes a race between them legible after the fact | Tooling | UR-078 | Done |
|
||||
| DR-219 | Release notes are the reviewed CHANGELOG entry, not a generated draft. Every release from v0.0.1 to v0.9.1 published the same ~1,050 bytes of generic install instructions whose "What's New" section said "See CHANGELOG.md" — a link that does not resolve from a release page. Thirty-five releases, byte-identical, telling a reader nothing about what changed. The workflow now publishes the `## <version>` section of CHANGELOG.md and fails the release if that section is absent, since notes that say nothing are worse than a build that waits for two sentences. `release:notes` is printed into the job log as a drafting aid but is deliberately *not* published: CLAUDE.md calls its output "a reviewed draft, not a final changelog", and publishing it unreviewed proved why — a range containing a repo-wide formatting sweep resolved to nearly the entire requirement matrix and produced notes claiming one release had added the whole application. The script now skips cosmetic commits (`chore(format)`, `chore(deps)`, `style`) when deriving a range's files, and says how many it skipped rather than silently reporting a smaller set | Tooling | - | Done |
|
||||
| DR-220 | A release ships only its own artifacts. `src-tauri/target/*/release/bundle/` is not versioned, cargo never cleans it, and the CI runner reuses the target directory — so the copy step's `bundle/**/*-setup.exe` glob collected every installer ever built there. Every release from v0.1.0 to v0.8.2 shipped its predecessors': sixteen Windows installers on v0.8.2, thirteen of them stale, and a download list on v0.5.0 reaching back to 0.1.0. It went unnoticed for eight months because there was nothing to notice — the upload loop reported success, the files were real, and the page looked busy rather than wrong. It stopped only when an unrelated cache change wiped the runner's target dir, leaving the defect dormant rather than fixed. Both desktop builds now clear the bundle directory first, so a stale file cannot exist to be copied — filtering the copy by version would have hidden it instead. `scripts/check-release-artifacts.sh` is the backstop for the next route nobody predicts: it runs before the SBOM, the checksums and the upload, and refuses to publish when any artifact's embedded version disagrees with the tag | Tooling | - | Done |
|
||||
| DR-221 | The release path is exercised before a tag exists. Nothing in `build-and-test.yml` runs `tauri build` — only a tag does — so a whole class of breakage was invisible until release day, and two instances of it were sitting on master at once. Tauri refuses to build when a plugin's Rust crate and npm package differ by minor version, which the updater and logging work had introduced (`tauri-plugin-log 2.8.0` against `@tauri-apps/plugin-log 2.9.0`) while `cargo check`, clippy, the tests and `svelte-check` all passed; both sides are now pinned exactly rather than by caret, since a caret is what let them separate, and CI runs `tauri info` to compare them without building. The AppImage target had never once been built: linuxdeploy carries a `strip` too old to parse the `.relr.dyn` section modern toolchains emit, so bundling failed on every library — and Ubuntu 23.10+ links with `-z pack-relative-relocs` by default, so the builder image fails the same way a modern Arch host does. `NO_STRIP=true` is linuxdeploy's documented escape hatch; the cost is a larger, unstripped bundle. Both were found by building the target locally before tagging rather than by publishing a release that could not build | Tooling | - | Done |
|
||||
| DR-222 | Build tooling matches the package manager the project declares. `scripts/build-android.sh` ran `npm install` on its clean-build path — in a bun project, where `packageManager` says bun and `bun.lock` is the committed lockfile. npm ignores that lockfile, re-resolves the whole tree from package.json, and writes a `package-lock.json` that `.gitignore` then hides. That is not a style preference: the JS halves of the Tauri plugins are pinned exactly against Cargo.lock because the CLI refuses to build when a plugin's crate and package differ by minor version, and a silent re-resolve is precisely how they drift apart. It survived because clean builds are rare — the shape shared by nearly every defect found preparing v0.10.0, where the code running on every commit was healthy and the code running on a release, a tag or a clean build had no guard at all. `scripts/check-tooling.sh` fails on any npm/yarn/pnpm invocation or foreign lockfile | Tooling | - | Done |
|
||||
| DR-223 | The Android JavaVM and Application are published into `ndk_context` by this crate, not by a transitive dependency. Seven call sites (five in credentials.rs, two in lib.rs) read that process-global to reach JNI, and nothing here ever set it — `tao` did, three levels below anything this project names in Cargo.toml. tao 0.35.3 moved those pointers into a private struct and stopped publishing them, so the Tauri 2.11 upgrade made the first credential read abort the process on every launch: `PANIC ... android context was not initialized`. Our code had not changed; an undocumented side effect of the windowing layer had gone. The invariant is now owned here rather than assumed: `JNI_OnLoad` captures the JavaVM as the shared library loads, and the Application is resolved lazily via `ActivityThread.currentApplication()` and pinned as a global reference for the process lifetime — the Application rather than the Activity, since that is what `SecureStorage.initialize()` immediately reduces its argument to. Failure degrades to the encrypted-file credential path and is logged, rather than aborting. Found only by installing on a device: nothing in CI runs the app | Security | UR-012 | Done |
|
||||
| DR-198 | The webview runs under a real Content-Security-Policy, and the asset protocol is scoped to the one directory it still serves. `csp` was `null`, which disables CSP entirely: any script that reached the web layer — through a future `{@html}`, a dependency, or a devtools paste — would have inherited the whole IPC surface, and with it the user's session. `script-src 'self'` (Tauri injects a nonce for SvelteKit's inline bootstrap script at build time, so no `'unsafe-inline'` is needed) plus `object-src`/`frame-src 'none'` and `base-uri 'self'` is the part that is genuinely restrictive. `img-src`/`media-src`/`connect-src` cannot be: the Jellyfin origin is typed in by the user at run time and is commonly plain `http` on a LAN, so they allow `http:`/`https:` — a wide grant for *data*, but one that still bars `file:`, `filesystem:` and scripting schemes, and leaves `script-src` untouched. `style-src` keeps `'unsafe-inline'` because Svelte compiles `style="…"` attributes (including `app.html`'s `display: contents` wrapper) into markup; this is safe only while no `<style>` element survives into `index.html`, since a nonce there would make Tauri's injection outrank — and therefore void — `'unsafe-inline'`. `worker-src blob:` and `media-src blob:` are hls.js: it demuxes in a worker built from a blob and attaches MSE through `URL.createObjectURL`. `asset:` and `http://asset.localhost` are the same protocol under the two naming schemes `convertFileSrc` emits (custom scheme on Linux/macOS, `http` host on Windows/Android); `ipc:`/`http://ipc.localhost` is the invoke transport, which would otherwise be blocked by `connect-src`. A run-time CSP naming the server origin exactly was rejected: Tauri computes the header from immutable config when it serves the HTML, so it would mean rebuilding config and reloading the webview on every server change, for a policy the user can already point anywhere. The asset-protocol scope narrows from `$APPDATA/**` to `$APPDATA/thumbnails/**` — since DR-137 moved downloaded media to the loopback server, `imageCache` is the only `convertFileSrc` caller left, so the database and the encrypted-token fallback file no longer sit inside the grant | Security | UR-012, UR-071 | Done |
|
||||
|
||||
---
|
||||
@@ -714,7 +709,6 @@ Internal architecture, components, and application logic.
|
||||
| UT-207 | The hero banner's rotation timer restarts from the moment of a manual change: a swipe 5.5s into a 6s interval waits a further 6s instead of firing the leftover 500ms, repeated restarts never stack timers, and `stop()` ends rotation | DR-038 | Done |
|
||||
| UT-208 | The update decision: each numeric version field is compared in order, the installed version is not offered to itself, a leading `v` is tolerated because that is how the tags are written, a pre-release sorts below the release of the same number so 0.9.2-rc1 is not offered to somebody on 0.9.2, a missing patch field reads as zero rather than NaN, mobile reports link-only while desktop reports install, and absent release notes normalise to null rather than undefined | DR-217 | Done |
|
||||
| UT-209 | Redaction and forwarding. Rust: every credential shape reduces to `[REDACTED]` while the host, username and neighbouring parameters survive; redaction is idempotent, leaves ordinary lines alone, does not fire on the word "token" in prose, and does not panic on multi-byte input; a server URL keeps only scheme and host and drops an embedded `user:pass@`; an unparseable level falls back to info rather than failing at startup. Frontend: info and above forward while debug does not, a message the level filter suppressed is not forwarded, a throwing forwarder neither propagates nor prevents the console write, and an `Error` renders as name and message rather than the `{}` that `JSON.stringify` produces | DR-218 | Done |
|
||||
| UT-210 | Cosmetic-commit detection for release notes: a `chore(format)`, `chore(deps)` or `style` subject is skipped when deriving a range's changed files, while `fix`, `feat`, `ci`, `docs`, a bare `chore:` and `chore(release):` are kept; and the word "format" appearing later in a subject ("fix(duration): format times over 24 hours") does not make a real fix look cosmetic | DR-219 | Done |
|
||||
|
||||
### Integration Tests
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ know how something *works*, read
|
||||
|
||||
**Next free requirement ids** (always re-check
|
||||
[requirements.md](../requirements.md) before allocating): **UR-079**,
|
||||
**IR-033**, **DR-224**. Three specs below suggested ids that have since been
|
||||
**IR-033**, **DR-219**. Three specs below suggested ids that have since been
|
||||
taken by other work; each carries a ⚠️ note at the top.
|
||||
|
||||
## Partially implemented
|
||||
@@ -44,7 +44,6 @@ taken by other work; each carries a ⚠️ note at the top.
|
||||
|
||||
| Spec | Blocked on / note |
|
||||
|---|---|
|
||||
| [backend-owned-stream-selection.md](backend-owned-stream-selection.md) | Rust owns direct-play-vs-transcode, transport and quality; players consume one `StreamSelection`. Phase 1 (delete the `.m3u8` sniff) stands alone. Unblocks Linux native video. |
|
||||
| [build-provenance.md](build-provenance.md) | `build.rs` is still bare. ⚠️ suggested id DR-093 is taken. |
|
||||
| [player-facade-enforcement.md](player-facade-enforcement.md) | ~60 `commands.player*` sites still outside the facade; no lint rule. ⚠️ suggested id DR-095 is taken. |
|
||||
| [windows-native-audio-backend.md](windows-native-audio-backend.md) | Blocked on the libmpv2 swap. ⚠️ suggested id IR-030 is taken. |
|
||||
|
||||
@@ -1,242 +0,0 @@
|
||||
# Spec: Backend-owned stream selection
|
||||
|
||||
**Status:** Proposed
|
||||
**Requirements:** UR-079 (new) → DR-219 … DR-224 (new); **implements and extends
|
||||
DR-121**, currently allocated to
|
||||
[read-through-media-cache.md](read-through-media-cache.md) and not started.
|
||||
Re-check `requirements.md` before allocating — the ids moved twice while this was
|
||||
being written (`DR` max was 215, then 218).
|
||||
**UX spec:** the quality selector in `VideoPlayer.svelte` already exists; this
|
||||
changes what fills it, not how it looks.
|
||||
**Supersedes / revises:** takes DR-121 out of
|
||||
[read-through-media-cache.md](read-through-media-cache.md), which should keep
|
||||
only its capture/eviction half. Unblocks
|
||||
[linux-native-video-spike.md](linux-native-video-spike.md).
|
||||
|
||||
**Destination on completion:**
|
||||
[01-rust-backend.md](../architecture/01-rust-backend.md) — extends the
|
||||
"Streaming quality ladder" section; and
|
||||
[03-data-flow.md](../architecture/03-data-flow.md) — playback initiation. The
|
||||
durable half is the layer line and the `StreamSelection` contract; phases and
|
||||
acceptance criteria are disposable.
|
||||
|
||||
## Summary
|
||||
|
||||
Make Rust the single owner of *which stream to play* — direct play or transcode,
|
||||
at what ceiling, over what transport — and hand every player backend a
|
||||
self-describing selection instead of a bare URL. mpv, ExoPlayer and the HTML5
|
||||
`<video>`/hls.js path all become consumers of the same decision rather than three
|
||||
places that re-derive it.
|
||||
|
||||
Nothing about how playback *looks* changes. What changes is that the frontend
|
||||
stops inferring transport from a URL string, and that direct play becomes
|
||||
possible at all.
|
||||
|
||||
## Motivation
|
||||
|
||||
Four concrete problems, all the same shape.
|
||||
|
||||
**1. The frontend sniffs transport out of the URL.**
|
||||
[VideoPlayer.svelte:569](../../src/lib/components/player/VideoPlayer.svelte#L569):
|
||||
|
||||
```ts
|
||||
const isHlsStream = currentStreamUrl.includes(".m3u8");
|
||||
```
|
||||
|
||||
and again inline at line 2364. Rust *built* that URL and knows exactly what it
|
||||
is; the frontend re-derives it by substring match. Change the endpoint, add a DASH
|
||||
path, serve a progressive file, and this silently picks wrong. This is the
|
||||
boundary rule in miniature — not item-type taxonomy, but the same error: a
|
||||
domain fact reconstructed in the presentation layer because the wire shape did
|
||||
not carry it.
|
||||
|
||||
**2. There is no direct-play path.** `get_video_stream_url` always builds an HLS
|
||||
transcode URL (`TranscodingProtocol=hls`, `VideoCodec=h264` first). Every video
|
||||
play burns server CPU, even when the file would play untouched. This is the cost
|
||||
the Linux native-video work exists to remove, and it cannot be removed without a
|
||||
decision that does not currently exist anywhere in the codebase.
|
||||
|
||||
**3. Quality is a process-wide global.** `streaming_quality()` /
|
||||
`set_streaming_quality()` in `repository/online.rs` read and write a static.
|
||||
It is not per-session or per-item, so it cannot express "this 4K remux needs a
|
||||
ceiling, that podcast does not", and two concurrent playbacks would share one
|
||||
setting.
|
||||
|
||||
**4. Rust cannot say what qualities *this* media source supports.** The selector
|
||||
is populated from a fixed enum rather than from what the source actually offers.
|
||||
DR-121 already names this; it has not been built.
|
||||
|
||||
### The prior question
|
||||
|
||||
Finding 3 of [playback-backend-unification.md](playback-backend-unification.md)
|
||||
holds that hls.js gives us real adaptive bitrate and mpv would lose it. Evidence
|
||||
in this repo suggests **there is no ABR today**: a single rendition is requested,
|
||||
no level-handling code exists anywhere in the frontend, and a quality switch is
|
||||
implemented by re-opening the stream.
|
||||
|
||||
**Run this before sizing the adaptation work.** It needs a live server:
|
||||
|
||||
```
|
||||
curl -s "https://<server>/Videos/<itemId>/master.m3u8?api_key=<key>&…" \
|
||||
| grep -c EXT-X-STREAM-INF
|
||||
```
|
||||
|
||||
`1` → there is no adaptation to preserve, and the adaptation half of this spec
|
||||
collapses to "pick well at open". `>1` → finding 3 stands and DR-223 applies.
|
||||
**Everything else in this spec is worth doing either way** — the ownership
|
||||
problems above are independent of the answer.
|
||||
|
||||
## Layer assignment
|
||||
|
||||
| Logic / responsibility | Layer | Why it belongs there |
|
||||
|---|---|---|
|
||||
| Direct play vs direct stream vs transcode | Rust | Depends on Jellyfin's `PlaybackInfo`, container/codec support and the device profile. Changes when Jellyfin's API or our profile changes → domain, by the litmus test. |
|
||||
| Transport of the chosen stream (HLS / progressive / local file) | Rust | Rust constructs the URL; it is the only place that *knows* rather than infers. Today the frontend guesses from `.m3u8`. |
|
||||
| Which qualities this media source can offer | Rust | Derived from the source's own streams and the quality→transcode-parameter mapping that `get_video_download_url` already holds. DR-121. |
|
||||
| The quality ceiling in force, per playback session | Rust | Domain state that outlives any one view and must survive a backend swap or a mode transfer. Currently a process-wide static. |
|
||||
| Deciding to re-negotiate mid-playback (if adaptation is needed) | Rust | It performs the HTTP and already derives reachability from real traffic via `ConnectivityMonitor`. Throughput estimation is the same pattern on the same data — a side-channel probe would repeat the mistake that principle exists to prevent. |
|
||||
| Frame-level delivery *within* the selected stream, including a player's own ABR | **Player** | ExoPlayer has genuine adaptive selection; if Rust hands it a multi-variant playlist it should use it. Rust chooses *what to request*, never how a player paces bytes. See "The line". |
|
||||
| Rendering the selector, showing the current quality, ordering the list | Frontend | Pure presentation over a backend-supplied list. |
|
||||
| Poster, letterbox, controls, overlay z-order | Frontend | Unchanged. |
|
||||
|
||||
### The line
|
||||
|
||||
**Rust decides *what stream*. The player decides *how to deliver it*.**
|
||||
|
||||
This matters most for ExoPlayer, which already does real adaptive track selection
|
||||
over HLS. This spec must not reimplement that or fight it — if a multi-variant
|
||||
playlist reaches ExoPlayer, ExoPlayer adapts and Rust stays out of the way. The
|
||||
same restraint applies to any future backend that gains the capability. Rust only
|
||||
steps in where the player has no such ability (mpv) *and* the server actually
|
||||
offers a ladder.
|
||||
|
||||
Borderline row, with its tie-breaker: "which media source of a multi-source item"
|
||||
looks like a user choice, and its *presentation* is. The default and the
|
||||
constraint set are domain → **Rust**, per the borderline-defaults-to-Rust rule.
|
||||
|
||||
## Design
|
||||
|
||||
### The contract
|
||||
|
||||
One self-describing selection replaces the bare URL. Nested fields are
|
||||
camelCase over the wire (`#[serde(rename_all = "camelCase")]`); the enums are
|
||||
tagged so the frontend matches a tag instead of parsing a string.
|
||||
|
||||
```rust
|
||||
#[derive(Serialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct StreamSelection {
|
||||
pub url: String,
|
||||
pub transport: Transport,
|
||||
pub playback_kind: PlaybackKind,
|
||||
/// The negotiated rendition; None when direct-playing the source as-is.
|
||||
pub rendition: Option<Rendition>,
|
||||
/// What this media source can offer — fills the selector (DR-121).
|
||||
pub available: Vec<QualityOption>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Type)]
|
||||
#[serde(tag = "type", rename_all = "camelCase")]
|
||||
pub enum Transport { Hls, Progressive, LocalFile }
|
||||
|
||||
#[derive(Serialize, Type)]
|
||||
#[serde(tag = "type", rename_all = "camelCase")]
|
||||
pub enum PlaybackKind { DirectPlay, DirectStream, Transcode }
|
||||
```
|
||||
|
||||
`Transport` is the field that deletes the `.m3u8` sniff. The frontend picks
|
||||
hls.js on `Hls` and the element's own loader otherwise — a tag match, not a
|
||||
substring search.
|
||||
|
||||
### Re-negotiation
|
||||
|
||||
Rust emits `stream-selection-changed` (kebab-case, per convention) carrying a new
|
||||
`StreamSelection` plus the position to resume at. The existing
|
||||
`playerSetStreamQuality` response already has exactly the right shape — a tagged
|
||||
`strategy` that tells the caller who reloads, with the backend handling native
|
||||
itself and handing HTML5 a URL for `reloadSource`
|
||||
([index.ts:198](../../src/lib/player/index.ts#L198)). **Extend that; do not
|
||||
invent a second mechanism.** It is the one piece of this that is already right.
|
||||
|
||||
Note the existing wart to preserve or fix deliberately, not accidentally:
|
||||
tauri-specta keeps those response fields snake_case (`new_url`), and the facade
|
||||
comments say so.
|
||||
|
||||
### Phases
|
||||
|
||||
1. **DR-219** `StreamSelection` + `Transport`; delete the `.m3u8` sniff. No
|
||||
behaviour change — pure ownership move, and independently shippable.
|
||||
2. **DR-220** Per-session quality ceiling replacing the `online.rs` static.
|
||||
3. **DR-221** `available` populated from the media source (DR-121's substance).
|
||||
4. **DR-222** Direct-play/direct-stream negotiation via `PlaybackInfo`. This is
|
||||
the phase that unlocks native video and removes the transcode.
|
||||
5. **DR-223** Adaptation, **only if the playlist check says a ladder exists**.
|
||||
Cheapest sufficient design: re-negotiate on sustained throughput drop, reusing
|
||||
the phase-1 re-negotiation path. A local proxy synthesizing a single-variant
|
||||
playlist is a last resort, not a starting point.
|
||||
6. **DR-224** ExoPlayer and mpv consume `StreamSelection` unchanged, proving the
|
||||
contract is player-agnostic rather than HTML5-shaped.
|
||||
|
||||
Phases 1–4 stand on their own merits with no dependency on the ladder question.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Rendering, compositing, and the Linux native-video work itself. This spec
|
||||
unblocks [linux-native-video-spike.md](linux-native-video-spike.md); it does
|
||||
not contain it.
|
||||
- Replacing hls.js. It stays as the HLS loader for the webview path.
|
||||
- Reimplementing or overriding ExoPlayer's own adaptive selection. See "The line".
|
||||
- The download/capture half of [read-through-media-cache.md](read-through-media-cache.md)
|
||||
(DR-122, DR-124, DR-125), which keeps its own spec.
|
||||
- Audio. The same argument applies, but video is where the transcode cost is.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] The `.m3u8` substring check is gone from `VideoPlayer.svelte` (both sites)
|
||||
and transport comes from the tagged enum.
|
||||
- [ ] `bun run check`, `bun run test`, `bun run format:check`, `bun run lint` pass.
|
||||
- [ ] `cargo fmt` clean, `cargo clippy -D warnings` clean, `bun run test:rust` passes.
|
||||
- [ ] `bun run check:boundary` passes — and the reviewer confirms by reading that
|
||||
no transport/kind decision was reconstructed in `src/`, since the tripwire
|
||||
only catches item-type array literals.
|
||||
- [ ] `bindings.ts` regenerated from Rust, not hand-edited.
|
||||
- [ ] New code carries `// TRACES:` comments; `bun run traces:validate` passes and
|
||||
coverage stays ≥ the CI ratchet.
|
||||
- [ ] The `EXT-X-STREAM-INF` count is recorded in this spec before DR-223 is
|
||||
started or dropped.
|
||||
- [ ] DR-121 is removed from `read-through-media-cache.md` with a pointer here.
|
||||
|
||||
## Testing
|
||||
|
||||
- Rust: `PlaybackInfo` fixtures → expected `PlaybackKind`, one per branch
|
||||
(supported container direct-plays; unsupported codec transcodes; a ceiling
|
||||
below the source bitrate transcodes even when the codec is fine).
|
||||
- Rust: `Transport` round-trips through serde with the tag the frontend matches.
|
||||
- Frontend: adapter selection driven by `transport`, including the case a URL
|
||||
ending `.m3u8` is served as `Progressive` — that test fails on today's code,
|
||||
which is the point.
|
||||
- Extend `tauriIntegration.test.ts` for the new command params (camelCase rule).
|
||||
- No test asserts a URL substring.
|
||||
|
||||
## TRACES
|
||||
|
||||
| Piece | Tag |
|
||||
|---|---|
|
||||
| `StreamSelection` / `Transport` | `UR-079 \| DR-219` |
|
||||
| Per-session ceiling | `UR-074 \| DR-220` |
|
||||
| `available` from media source | `UR-079 \| DR-221, DR-121` |
|
||||
| Direct-play negotiation | `UR-079 \| DR-222` |
|
||||
| Adaptation, if built | `UR-079 \| DR-223` |
|
||||
| ExoPlayer/mpv consumers | `UR-003, UR-004 \| DR-224` |
|
||||
|
||||
## Notes for the implementer
|
||||
|
||||
- **Phase 1 is worth doing on its own**, even if everything after it is dropped.
|
||||
It removes a real leak and costs almost nothing.
|
||||
- Do not frame any phase as "no Rust changes required" — that framing is what
|
||||
produced the leak `scoped-search-boundary.md` records.
|
||||
- `ConnectivityMonitor` is the precedent for DR-223: derive network facts from
|
||||
real traffic, never from a side-channel poller.
|
||||
- A parallel Claude session may be active in this repo — `git diff` before
|
||||
"repairing" unexpected changes. Requirement ids in particular moved twice
|
||||
during the writing of this spec.
|
||||
@@ -1,6 +1,6 @@
|
||||
# Spec: Linux native video — bounded compositing spike
|
||||
|
||||
**Status:** **Run 2026-08-21 — compositing works; G5 carries an open crash.**
|
||||
**Status:** **Run 2026-08-21 — G1-G6 green except the Tauri-tree half of G1.**
|
||||
The compositing claim it set out to test is falsified on Linux. See "Result".
|
||||
This file stays open until the implementation spec exists; ABR is unresolved.
|
||||
**Requirements:** none allocated. This spike produces a decision record, not
|
||||
@@ -186,7 +186,7 @@ mpv's render API with an update callback, frame-gated repaints and
|
||||
| G2 webview paints transparently over it | ✅ green | `with_transparent(true)` alone. No window-level transparency was used or needed. |
|
||||
| G3 mpv renders into our FBO | ✅ green | `vo=libmpv` + `mpv_render_context_create` with `MPV_RENDER_PARAM_OPENGL_FBO` into the FBO GTK binds. |
|
||||
| G4 HTML over video | ✅ green | Opaque panel and a translucent control bar both drew over moving video. |
|
||||
| G5 resize / drag / fullscreen | 🟡 **green on appearance, suspect underneath** | No flicker, gap or misalignment, and smooth once frame pacing was correct (trap 3). But the only crash observed came from the only session where fullscreen was exercised — see "What is still open". |
|
||||
| G5 resize / drag / fullscreen | ✅ green | No flicker, no gap, no misalignment. Fullscreen juddered until frame pacing was done properly — see trap 3; it is smooth with `report_swap` in place. |
|
||||
| G6 X11 **and** Wayland | ✅ green | Identical on both; `GDK_BACKEND` flipped between runs. |
|
||||
|
||||
**Finding 2 of [playback-backend-unification.md](playback-backend-unification.md)
|
||||
@@ -244,12 +244,9 @@ which is the least efficient hardware path. An implementation should evaluate
|
||||
zero-copy VA-API on the iGPU (after confirming the driver is installed) before
|
||||
accepting `auto`.
|
||||
|
||||
`hwdec=auto-safe` probes Vulkan video decode, which this GPU does not support.
|
||||
It logs two `Failed setup for format vulkan` / `no frame!` pairs at start-up and
|
||||
then settles on `nvdec-copy` — the same place `auto` lands. A first reading of
|
||||
these logs mistook the start-up pair for a per-frame flood; **it is not**. Every
|
||||
run, clean or crashed, contains exactly two. `auto-safe` is not implicated in
|
||||
anything.
|
||||
`hwdec=auto-safe`, the default this spike started with, probes Vulkan video
|
||||
decode, which this GPU does not support. It failed per-frame and logged
|
||||
`no frame!` on every frame. Do not ship `auto-safe` here without checking that.
|
||||
|
||||
### What is still open
|
||||
|
||||
@@ -258,72 +255,15 @@ anything.
|
||||
Tauri's existing webview into an overlay. Low risk — the same widgets, one
|
||||
extra reparent — but unproven, and it is the only place Tauri-specific
|
||||
behaviour could still bite.
|
||||
- 🔴 **ABR — finding 3's premise is in doubt.** Finding 3 says mpv would regress
|
||||
streaming quality because "the webview path already has real ABR via hls.js".
|
||||
Three pieces of evidence in this repo suggest that is **not true of the URLs we
|
||||
actually build**:
|
||||
|
||||
1. `get_video_stream_url` (`repository/online.rs`) requests a *single*
|
||||
rendition — one `VideoBitrate`, one `MaxStreamingBitrate`, one `MaxHeight`.
|
||||
Jellyfin transcodes to what it is asked for; it does not build a ladder.
|
||||
2. The frontend contains **no level-handling code at all** — no `hls.levels`,
|
||||
no `LEVEL_SWITCH`, no `currentLevel`. The `abrEwma*` options in
|
||||
`VideoPlayer.svelte` are default tuning with nothing to act on. hls.js is
|
||||
serving as an HLS *demuxer* (WebKitGTK cannot play HLS natively), not as an
|
||||
adaptation engine.
|
||||
3. That function's own comment describes a quality switch as **rebuilding the
|
||||
URL** — "every path that re-opens a stream (quality switch, transcoded seek,
|
||||
audio-track switch)". Manual selection by stream re-open is what you build
|
||||
when there is no adaptation, and mpv can do the same thing.
|
||||
|
||||
**The decisive test has not been run** and needs a live server plus an API key:
|
||||
count `#EXT-X-STREAM-INF` lines in a real `master.m3u8`. One line means there
|
||||
is no ABR to lose and this blocker disappears. More than one means finding 3
|
||||
stands and the work below applies.
|
||||
|
||||
If ABR does turn out to be real, it belongs in **Rust**, not in mpv, and there
|
||||
are three designs in increasing cost: pick the variant at open; re-open at a
|
||||
new bitrate on sustained throughput drops (this is the quality-switch path the
|
||||
app already has, so it is nearly free); or run a local proxy serving mpv a
|
||||
synthesized single-variant playlist while swapping renditions underneath. The
|
||||
middle option is almost certainly sufficient.
|
||||
|
||||
Either way the **direct-play path still does not exist** — every video play
|
||||
currently goes through the HLS transcode endpoint. Building it is the real
|
||||
project; the compositing work proven above is the smaller half.
|
||||
- 🔴 **One unexplained SIGSEGV.** A ~180s
|
||||
run died in a *decoder* thread (libavcodec -> `av_log` -> libmpv's log handler
|
||||
-> libc). No Tauri, wry, WebKitGTK, GTK or GL frame appears anywhere in the
|
||||
stack, so the fault is on the mpv/ffmpeg side of the process rather than in the
|
||||
compositing seam.
|
||||
|
||||
Three hypotheses were tested and **none reproduced it**:
|
||||
|
||||
| Hypothesis | Test | Result |
|
||||
|---|---|---|
|
||||
| `hwdec=auto-safe`'s Vulkan failures | 300s soak on `auto-safe` | Survived. Also based on a misreading — the failures are 2 per run at start-up, not per-frame. Dead. |
|
||||
| Fullscreen transitions recreating the GL context under mpv's render context | 240s soak, ~120 automated transitions | Survived, no core dumped. |
|
||||
| Continuous resize thrashing the GL framebuffer | 240s soak, ~2000 resizes | Survived, no core dumped. |
|
||||
|
||||
**The crash is therefore unexplained.** It was observed exactly once, in the
|
||||
only session a human interacted with, and did not recur in ~13 minutes of
|
||||
targeted stress across the three most plausible causes. It is recorded here
|
||||
rather than dismissed precisely because nothing explains it: an intermittent
|
||||
fault that nobody can reproduce is worse to inherit than a deterministic one,
|
||||
not better.
|
||||
|
||||
The underlying concern stands regardless of which test eventually reproduces
|
||||
it. A SIGSEGV in an unrelated thread is characteristic of memory corruption,
|
||||
and this spike never calls `mpv_render_context_free` and never tears down on
|
||||
`unrealize` — it has no defence against the GL context being recreated beneath
|
||||
the render context. That is DR-184 on Android restated: a surface outliving its
|
||||
player. An implementation must bind the two lifetimes together whether or not
|
||||
this particular crash is ever explained.
|
||||
|
||||
**Therefore G5 is recorded green on appearance only**, and this crash is the
|
||||
single largest piece of unfinished business in the spike. Do not read the green
|
||||
gates above as "safe to build on" until it is explained or a long soak clears
|
||||
it.
|
||||
- **ABR — unchanged and still the blocker.** Nothing here addresses finding 3.
|
||||
What has changed is that the direct-play/transcode split is now worth designing
|
||||
rather than moot.
|
||||
- **One unexplained SIGSEGV.** A ~180s run crashed in a *decoder* thread
|
||||
(libavcodec -> `av_log` -> libmpv's log handler -> libc), not in the GL or
|
||||
compositing path, while `hwdec=auto-safe` was failing its Vulkan probe on every
|
||||
frame. It did **not** reproduce across five subsequent runs (2x45s, 3x20s) on
|
||||
`no`, `auto` and `vaapi`. Cause unconfirmed; recorded rather than dismissed.
|
||||
Anyone implementing this should run a multi-hour soak before trusting it.
|
||||
- Long-run stability, seeking, track switching, HDR, and multi-window were not
|
||||
exercised at all.
|
||||
|
||||
|
||||
@@ -111,17 +111,6 @@ The webview path already has real ABR via hls.js. Moving video to mpv would be a
|
||||
**downgrade** on every platform — no graceful degradation on weak networks, and
|
||||
quality changes requiring teardown and reload.
|
||||
|
||||
> **Premise in doubt (2026-08-21).** "The webview path already has real ABR"
|
||||
> was not verified against the URLs this app actually builds.
|
||||
> `get_video_stream_url` requests a *single* rendition (one `VideoBitrate`, one
|
||||
> `MaxHeight`), the frontend has **no** level-handling code (`hls.levels`,
|
||||
> `LEVEL_SWITCH`, `currentLevel` appear nowhere), and this repo implements a
|
||||
> quality switch by *re-opening the stream* — all of which point to a
|
||||
> single-variant playlist, i.e. no ABR to lose. The decisive test is counting
|
||||
> `#EXT-X-STREAM-INF` lines in a real `master.m3u8`; it needs a live server and
|
||||
> has not been run. See
|
||||
> [linux-native-video-spike.md](linux-native-video-spike.md).
|
||||
|
||||
### 4. Crossfade is architecturally blocked on mpv
|
||||
|
||||
mpv's audio chain is single-stream. FFmpeg's `acrossfade` is an `N→A` filter
|
||||
|
||||
+6
-7
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "jellytau",
|
||||
"version": "0.10.0",
|
||||
"version": "0.9.1",
|
||||
"description": "A cross-platform Jellyfin client built with Tauri, SvelteKit and Rust.",
|
||||
"author": "Duncan Tourolle <duncan@tourolle.paris>",
|
||||
"license": "MIT",
|
||||
@@ -29,7 +29,6 @@
|
||||
"format:check": "prettier --check .",
|
||||
"check:boundary": "bash scripts/check-frontend-boundary.sh",
|
||||
"check:links": "bash scripts/check-doc-links.sh",
|
||||
"check:tooling": "bash scripts/check-tooling.sh",
|
||||
"hooks:install": "./scripts/install-hooks.sh",
|
||||
"android:build": "./scripts/build-android.sh",
|
||||
"android:build:release": "./scripts/build-android.sh release",
|
||||
@@ -56,12 +55,12 @@
|
||||
"release:notes": "bun run scripts/release-notes.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.11.1",
|
||||
"@tauri-apps/plugin-log": "2.9.0",
|
||||
"@tauri-apps/plugin-opener": "^2.5.4",
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-log": "^2.9.0",
|
||||
"@tauri-apps/plugin-opener": "^2",
|
||||
"@tauri-apps/plugin-os": "^2.3.2",
|
||||
"@tauri-apps/plugin-process": "^2.3.1",
|
||||
"@tauri-apps/plugin-updater": "2.10.1",
|
||||
"@tauri-apps/plugin-updater": "^2.10.1",
|
||||
"hls.js": "^1.6.15",
|
||||
"svelte-dnd-action": "^0.9.69"
|
||||
},
|
||||
@@ -71,7 +70,7 @@
|
||||
"@sveltejs/kit": "^2.9.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@tauri-apps/cli": "^2.11.4",
|
||||
"@tauri-apps/cli": "^2",
|
||||
"@testing-library/svelte": "^5.3.1",
|
||||
"@vitest/coverage-v8": "^4.0.18",
|
||||
"@vitest/ui": "^4.0.16",
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
# tarball/VCS URL and drop the local-copy prepare() step.
|
||||
|
||||
pkgname=jellytau
|
||||
pkgver=0.10.0
|
||||
pkgver=0.9.1
|
||||
pkgrel=1
|
||||
pkgdesc="A cross-platform Jellyfin client"
|
||||
arch=('x86_64')
|
||||
|
||||
@@ -82,17 +82,7 @@ fi
|
||||
if [ "$CLEAN" = "1" ]; then
|
||||
echo "🧹 Clearing build caches (clean build)..."
|
||||
rm -rf node_modules/.vite dist .svelte-kit .next build target src-tauri/target 2>/dev/null || true
|
||||
# `bun install`, NOT `npm install`. This is a bun project (see packageManager
|
||||
# in package.json) and bun.lock is the lockfile that is committed; npm
|
||||
# ignores it, re-resolves the tree from package.json alone, and writes a
|
||||
# package-lock.json that .gitignore then hides.
|
||||
#
|
||||
# That is not cosmetic. The Tauri CLI refuses to build when a plugin's Rust
|
||||
# crate and npm package differ by minor version, so the JS side is pinned
|
||||
# exactly to match Cargo.lock; a re-resolve is precisely how those halves
|
||||
# drift apart again. A clean build must not be able to change what gets
|
||||
# installed.
|
||||
bun install > /dev/null 2>&1
|
||||
npm install > /dev/null 2>&1
|
||||
fi
|
||||
|
||||
# Step 1: Sync Android source files
|
||||
@@ -104,28 +94,22 @@ echo "🎨 Building frontend..."
|
||||
bun run build
|
||||
|
||||
# Step 2: Build Android APK
|
||||
# `--apk` is a boolean flag, NOT `--apk true`.
|
||||
#
|
||||
# tauri-cli took a value here until 2.10; from 2.11 it is a plain flag and the
|
||||
# stray `true` is parsed as a positional argument, failing with
|
||||
# "error: unexpected argument 'true' found" before the build starts. Found by
|
||||
# deploying to a device after the Tauri 2.9.5 -> 2.11.5 upgrade.
|
||||
if [ "$BUILD_TYPE" = "release" ] && [ "$SIDE_BY_SIDE" = "1" ]; then
|
||||
# A release build in the debug slot: R8 still runs, but the applicationId is
|
||||
# suffixed and the debug keystore signs it (read by build.gradle.kts from
|
||||
# JT_SIDE_BY_SIDE), so the real key is not needed and it replaces any other
|
||||
# .debug install cleanly. Deliberately does NOT write keystore.properties.
|
||||
echo "📦 Building side-by-side release APK (com.dtourolle.jellytau.debug)..."
|
||||
JT_SIDE_BY_SIDE=1 bun run tauri android build --apk "${TARGET_ARGS[@]}"
|
||||
JT_SIDE_BY_SIDE=1 bun run tauri android build --apk true "${TARGET_ARGS[@]}"
|
||||
elif [ "$BUILD_TYPE" = "release" ]; then
|
||||
# Configure release signing from .env (single source of truth). Must run
|
||||
# after sync-android-sources.sh, since gen/android is (re)generated there.
|
||||
./scripts/write-keystore-properties.sh
|
||||
echo "📦 Building release APK..."
|
||||
bun run tauri android build --apk "${TARGET_ARGS[@]}"
|
||||
bun run tauri android build --apk true "${TARGET_ARGS[@]}"
|
||||
else
|
||||
echo "📦 Building debug APK..."
|
||||
bun run tauri android build --apk --debug "${TARGET_ARGS[@]}"
|
||||
bun run tauri android build --apk true --debug "${TARGET_ARGS[@]}"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
@@ -26,25 +26,7 @@ bun run build
|
||||
|
||||
# --bundles overrides tauri.conf.json bundle.targets so this script controls
|
||||
# exactly which Linux formats are produced (never NSIS here).
|
||||
# TRACES: | DR-221
|
||||
#
|
||||
# 🔴 NO_STRIP=true is required for the AppImage bundle.
|
||||
#
|
||||
# linuxdeploy (which Tauri downloads and runs to build the AppImage) carries its
|
||||
# own `strip`, and that copy is too old to parse the `.relr.dyn` section modern
|
||||
# toolchains emit for RELR relocations. It fails on essentially every bundled
|
||||
# library:
|
||||
#
|
||||
# strip: libzstd.so.1: unknown type [0x13] section `.relr.dyn'
|
||||
# failed to bundle project `failed to run linuxdeploy-x86_64.AppImage`
|
||||
#
|
||||
# Ubuntu 23.10+ links with -z pack-relative-relocs by default, so the CI builder
|
||||
# image hits this exactly as a modern Arch host does. Skipping the strip step is
|
||||
# linuxdeploy's own documented escape hatch; the cost is an unstripped, larger
|
||||
# AppImage (~153 MB for a build that bundles libmpv and its ffmpeg stack).
|
||||
#
|
||||
# Remove this only after confirming a linuxdeploy release that understands RELR.
|
||||
NO_STRIP=true bun run tauri build --bundles "$BUNDLES"
|
||||
bun run tauri build --bundles "$BUNDLES"
|
||||
|
||||
BUNDLE_ROOT="src-tauri/target/release/bundle"
|
||||
echo ""
|
||||
|
||||
@@ -46,28 +46,6 @@ bun run build
|
||||
# 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.
|
||||
# TRACES: | DR-221
|
||||
#
|
||||
# 🔴 Clear the bundle output before building.
|
||||
#
|
||||
# The bundle directory is not versioned and is never cleaned by cargo, and the
|
||||
# CI runner reuses src-tauri/target between builds. The copy step below globs
|
||||
# `bundle/**/*-setup.exe`, so every stale installer left there was picked up and
|
||||
# attached to the release: v0.8.2 shipped sixteen Windows installers, thirteen
|
||||
# of them from earlier versions, and v0.5.0 offered users a download list going
|
||||
# back to 0.1.0. Every release from v0.1.0 to v0.8.2 did this. It stopped only
|
||||
# because an unrelated change wiped the runner's target dir, so it is dormant
|
||||
# rather than fixed.
|
||||
#
|
||||
# Filtering the copy by version would hide it; removing the directory means a
|
||||
# stale file cannot exist to be copied. scripts/check-release-artifacts.sh is
|
||||
# the backstop if some other path reintroduces one.
|
||||
BUNDLE_DIR="src-tauri/target/$TARGET/release/bundle"
|
||||
if [[ -d "$BUNDLE_DIR" ]]; then
|
||||
echo "🧹 Clearing previous bundle output at $BUNDLE_DIR"
|
||||
rm -rf "$BUNDLE_DIR"
|
||||
fi
|
||||
|
||||
if [[ "$WIN_BUNDLES" == "none" ]]; then
|
||||
bun run tauri build --runner cargo-xwin --target "$TARGET" --no-bundle
|
||||
else
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Refuse to publish a release whose artifacts are not all from this release.
|
||||
#
|
||||
# TRACES: | DR-220
|
||||
#
|
||||
# ./scripts/check-release-artifacts.sh <version> <dir> [<dir>...]
|
||||
#
|
||||
# e.g.
|
||||
# ./scripts/check-release-artifacts.sh v0.9.2 artifacts/linux artifacts/windows
|
||||
#
|
||||
# ## The defect this exists for
|
||||
#
|
||||
# Every JellyTau release from v0.1.0 to v0.8.2 shipped every Windows installer
|
||||
# ever built. `src-tauri/target/*/release/bundle/` is not versioned, cargo never
|
||||
# cleans it, and the CI runner reuses the target directory between builds — so
|
||||
# the copy step's `bundle/**/*-setup.exe` glob collected the whole history. By
|
||||
# v0.8.2 that was sixteen installers, thirteen of them stale. v0.5.0 offered
|
||||
# users a download list going back to 0.1.0.
|
||||
#
|
||||
# Nobody noticed for eight months. There was nothing to notice with: the upload
|
||||
# loop reported success, the assets were real files, and the release page looked
|
||||
# busy rather than wrong.
|
||||
#
|
||||
# The builds now clear the bundle directory first, which removes the cause. This
|
||||
# is the backstop for the next thing that reintroduces a stale file by a route
|
||||
# nobody predicted — a cached directory, a restored artifact, a hand-copied fix.
|
||||
#
|
||||
# ## What it checks
|
||||
#
|
||||
# Every file whose name embeds a semantic version must embed *this* version.
|
||||
# Files with no version in the name (jellytau-release.apk, jellytau.exe,
|
||||
# SHA256SUMS, latest.json) are accepted: they are produced fresh each build and
|
||||
# have no version to disagree with.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
if [ "$#" -lt 2 ]; then
|
||||
echo "usage: $0 <version> <dir> [<dir>...]" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
VERSION_RAW="$1"
|
||||
shift
|
||||
# Accept the tag form (v0.9.2) or the bare form (0.9.2).
|
||||
VERSION="${VERSION_RAW#v}"
|
||||
|
||||
echo "🔎 Checking release artifacts are all version ${VERSION}…"
|
||||
|
||||
FOUND=0
|
||||
STALE=0
|
||||
UNVERSIONED=0
|
||||
|
||||
for dir in "$@"; do
|
||||
if [ ! -d "$dir" ]; then
|
||||
echo " (no $dir — skipping)"
|
||||
continue
|
||||
fi
|
||||
|
||||
# -print0/read -d '' so a filename with a space cannot split into two.
|
||||
while IFS= read -r -d '' file; do
|
||||
name="$(basename "$file")"
|
||||
FOUND=$((FOUND + 1))
|
||||
|
||||
# First x.y.z in the filename, if any.
|
||||
embedded="$(printf '%s' "$name" | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1 || true)"
|
||||
|
||||
if [ -z "$embedded" ]; then
|
||||
UNVERSIONED=$((UNVERSIONED + 1))
|
||||
continue
|
||||
fi
|
||||
|
||||
if [ "$embedded" != "$VERSION" ]; then
|
||||
echo " ❌ $name carries version $embedded"
|
||||
STALE=$((STALE + 1))
|
||||
fi
|
||||
done < <(find "$dir" -type f -print0)
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo " $FOUND file(s) checked; $UNVERSIONED carry no version in the name."
|
||||
|
||||
if [ "$FOUND" -eq 0 ]; then
|
||||
echo "❌ No artifacts found at all. A release with no files is a failed build," >&2
|
||||
echo " not an empty one." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$STALE" -gt 0 ]; then
|
||||
echo ""
|
||||
echo "❌ $STALE artifact(s) belong to a different version than ${VERSION}." >&2
|
||||
echo "" >&2
|
||||
echo " This is how every release from v0.1.0 to v0.8.2 came to ship its" >&2
|
||||
echo " predecessors' Windows installers: src-tauri/target/*/release/bundle/" >&2
|
||||
echo " is never cleaned and the runner reuses it, so a glob picks up" >&2
|
||||
echo " whatever was left behind." >&2
|
||||
echo "" >&2
|
||||
echo " The builds clear that directory first, so seeing this means a stale" >&2
|
||||
echo " file arrived by some other route. Find it before publishing — do not" >&2
|
||||
echo " delete the file and re-run." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ Every versioned artifact is ${VERSION}."
|
||||
@@ -1,73 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Refuse tooling that contradicts what this project actually uses.
|
||||
#
|
||||
# TRACES: | DR-222
|
||||
#
|
||||
# ./scripts/check-tooling.sh
|
||||
#
|
||||
# ## Why
|
||||
#
|
||||
# This is a bun project: `packageManager` in package.json says so, bun.lock is
|
||||
# the committed lockfile, and .gitignore hides the other package managers'
|
||||
# lockfiles precisely so they cannot be committed by accident.
|
||||
#
|
||||
# scripts/build-android.sh nonetheless ran `npm install` on its clean-build
|
||||
# path. npm ignores bun.lock, re-resolves the whole tree from package.json, and
|
||||
# writes a package-lock.json that .gitignore then hides from view.
|
||||
#
|
||||
# That is not a style preference. The Tauri CLI refuses to build when a plugin's
|
||||
# Rust crate and npm package differ by minor version, so the JS side is pinned
|
||||
# exactly against Cargo.lock -- and a silent re-resolve is exactly how those
|
||||
# halves drift apart again. The drift already cost one release build.
|
||||
#
|
||||
# It survived because the clean-build path runs rarely. That is the shape of
|
||||
# nearly every defect found while preparing v0.10.0: the code that runs on every
|
||||
# commit was fine, and the code that runs on a release, a clean build or a tag
|
||||
# had no guard at all.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
REPO_ROOT="$(git rev-parse --show-toplevel)"
|
||||
cd "$REPO_ROOT" || exit 1
|
||||
|
||||
FAILED=0
|
||||
|
||||
echo "🔎 Checking build tooling is consistent with packageManager…"
|
||||
|
||||
# Only the *invocations* matter. A comment explaining why npm is wrong, or a
|
||||
# .gitignore entry naming package-lock.json, is not a violation -- so match a
|
||||
# command at the start of a line or after a shell separator.
|
||||
PATTERN='(^|[;&|(]|&&|\|\||\bthen |\bdo |[[:space:]]{4,})(npm|yarn|pnpm)[[:space:]]+(install|ci|add|run|exec)\b'
|
||||
|
||||
MATCHES="$(grep -rInE "$PATTERN" \
|
||||
--include='*.sh' --include='*.yml' --include='*.yaml' \
|
||||
scripts/ .gitea/ 2>/dev/null | grep -v '^\s*#' || true)"
|
||||
|
||||
if [ -n "$MATCHES" ]; then
|
||||
echo "❌ A non-bun package manager is invoked:"
|
||||
echo "$MATCHES" | sed 's/^/ /'
|
||||
echo ""
|
||||
echo " This project uses bun (packageManager in package.json, bun.lock"
|
||||
echo " committed). npm/yarn/pnpm ignore that lockfile and re-resolve the"
|
||||
echo " dependency tree, which is how the Tauri plugin crate/package"
|
||||
echo " versions drifted apart and broke a release build."
|
||||
echo ""
|
||||
echo " Use: bun install / bun run / bunx"
|
||||
FAILED=1
|
||||
fi
|
||||
|
||||
# A lockfile from another manager should never exist here; .gitignore hides
|
||||
# them, so one can sit in a working tree unnoticed and change what installs.
|
||||
for stray in package-lock.json yarn.lock pnpm-lock.yaml; do
|
||||
if [ -f "$stray" ]; then
|
||||
echo "❌ $stray exists. Another package manager has run here."
|
||||
echo " Delete it and run: bun install"
|
||||
FAILED=1
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$FAILED" -eq 0 ]; then
|
||||
echo "✅ Only bun is used, and no foreign lockfile is present."
|
||||
fi
|
||||
|
||||
exit "$FAILED"
|
||||
@@ -49,11 +49,6 @@ describe("isTracedSourceFile", () => {
|
||||
expect(isTracedSourceFile("src-tauri/deny.toml")).toBe(true);
|
||||
expect(isTracedSourceFile("src-tauri/rust-toolchain.toml")).toBe(true);
|
||||
expect(isTracedSourceFile("scripts/hooks/pre-commit")).toBe(true);
|
||||
// Shell tooling is listed individually, not globbed: most scripts/*.sh
|
||||
// implement nothing, and adding one should be a decision.
|
||||
expect(isTracedSourceFile("scripts/check-release-artifacts.sh")).toBe(true);
|
||||
expect(isTracedSourceFile("scripts/build-desktop-linux.sh")).toBe(true);
|
||||
expect(isTracedSourceFile("scripts/logcat.sh")).toBe(false);
|
||||
});
|
||||
|
||||
it("does not scan CI workflows, whose comments discuss TRACES in prose", () => {
|
||||
|
||||
@@ -95,14 +95,6 @@ const TOOLING_FILES = new Set([
|
||||
"scripts/hooks/pre-commit",
|
||||
"src-tauri/deny.toml",
|
||||
"src-tauri/rust-toolchain.toml",
|
||||
// Shell tooling that implements a requirement. Named individually rather than
|
||||
// globbing scripts/*.sh: most of these scripts implement nothing, and the
|
||||
// point of the list is that adding a file is a decision.
|
||||
"scripts/install-hooks.sh",
|
||||
"scripts/check-release-artifacts.sh",
|
||||
"scripts/build-desktop-linux.sh",
|
||||
"scripts/build-windows-cross.sh",
|
||||
"scripts/restore-ownership.sh",
|
||||
]);
|
||||
|
||||
/** Directory names that never contain hand-written traced source. */
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
/**
|
||||
* Tests for release-note derivation.
|
||||
*
|
||||
* TRACES: | DR-219 | UT-210
|
||||
*
|
||||
* The bug these were written against: `bun run release:notes v0.9.1..HEAD`
|
||||
* listed *every user requirement in the project* as a feature of the release.
|
||||
* The range contained a repo-wide `prettier --write` sweep, so `git diff
|
||||
* --name-only` reported 199 files, their TRACES comments resolved to nearly the
|
||||
* whole matrix, and the result claimed one release had added the entire
|
||||
* application.
|
||||
*
|
||||
* That mattered more than it looked: build-release.yml now generates the
|
||||
* published release body from this script, so the noise would have shipped.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { isCosmeticCommit } from "./release-notes";
|
||||
|
||||
describe("isCosmeticCommit", () => {
|
||||
it("treats a formatting sweep as cosmetic", () => {
|
||||
// The actual commit that triggered this.
|
||||
expect(isCosmeticCommit("chore(format): run prettier over src/ and scripts/")).toBe(true);
|
||||
expect(isCosmeticCommit("style: reindent the player module")).toBe(true);
|
||||
expect(isCosmeticCommit("style(player): reindent")).toBe(true);
|
||||
});
|
||||
|
||||
it("treats a lockfile-only dependency bump as cosmetic", () => {
|
||||
// Touches package.json/bun.lock, which carry no TRACES, but a `chore(deps)`
|
||||
// that also edits source would still be caught by that source file.
|
||||
expect(isCosmeticCommit("chore(deps): bump vitest to 4.1.11")).toBe(true);
|
||||
});
|
||||
|
||||
it("does NOT treat ordinary work as cosmetic", () => {
|
||||
expect(isCosmeticCommit("fix(player): restart the hero banner timer")).toBe(false);
|
||||
expect(isCosmeticCommit("feat(updater): in-app update on desktop")).toBe(false);
|
||||
expect(isCosmeticCommit("ci: make the frontend gates real")).toBe(false);
|
||||
expect(isCosmeticCommit("docs: add SECURITY.md")).toBe(false);
|
||||
});
|
||||
|
||||
it("does not mistake a chore that is not formatting for a formatting one", () => {
|
||||
// `chore(release)` bumps versions and must still be attributable; a bare
|
||||
// `chore:` could be anything, so it is NOT skipped by default.
|
||||
expect(isCosmeticCommit("chore(release): v0.9.2")).toBe(false);
|
||||
expect(isCosmeticCommit("chore: tidy up the queue helper")).toBe(false);
|
||||
});
|
||||
|
||||
it("is not fooled by the word format appearing later in a subject", () => {
|
||||
// A real fix to formatting *code* is not a cosmetic commit.
|
||||
expect(isCosmeticCommit("fix(duration): format times over 24 hours correctly")).toBe(false);
|
||||
expect(isCosmeticCommit("feat: add a format picker to settings")).toBe(false);
|
||||
});
|
||||
|
||||
it("handles an empty or malformed subject without throwing", () => {
|
||||
expect(isCosmeticCommit("")).toBe(false);
|
||||
expect(isCosmeticCommit(" ")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -54,86 +54,11 @@ function loadRequirementDescriptions(): Map<string, string> {
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Commit subjects whose changes carry no requirement meaning.
|
||||
*
|
||||
* `chore(format)` / `style` rewrite files without changing behaviour;
|
||||
* `chore(deps)` moves lockfiles. Anything else — including a bare `chore:` and
|
||||
* `chore(release):` — is assumed to mean something and is kept.
|
||||
*
|
||||
* Anchored at the start of the subject on purpose: "fix(duration): format times
|
||||
* over 24 hours" is a real fix to formatting *code*, not a formatting commit.
|
||||
*/
|
||||
const COSMETIC_SUBJECT = /^(chore\(format\)|chore\(deps\)|style)(\([^)]*\))?\s*:/i;
|
||||
|
||||
/**
|
||||
* Does this commit subject describe a change with no requirement meaning?
|
||||
*
|
||||
* Exported for scripts/release-notes.test.ts.
|
||||
*
|
||||
* TRACES: | DR-219
|
||||
*/
|
||||
export function isCosmeticCommit(subject: string): boolean {
|
||||
return COSMETIC_SUBJECT.test(subject.trim());
|
||||
}
|
||||
|
||||
/**
|
||||
* Files the range changed, excluding those touched only by cosmetic commits.
|
||||
*
|
||||
* Why not a plain `git diff --name-only <range>`: that is what this did, and a
|
||||
* single repo-wide `prettier --write` inside the range made it report 199 files
|
||||
* whose TRACES comments resolved to nearly the entire requirement matrix. The
|
||||
* generated notes for v0.9.2 claimed the release had added the whole
|
||||
* application — and build-release.yml publishes this output, so the noise would
|
||||
* have shipped.
|
||||
*
|
||||
* Walking commit by commit and skipping the cosmetic ones keeps a file that a
|
||||
* sweep *and* a real change both touched: it is still listed by the real
|
||||
* commit. Only files touched exclusively by cosmetic commits drop out, which is
|
||||
* exactly the intent.
|
||||
*
|
||||
* Merge commits produce no output from `git diff-tree` without `-m`, and are
|
||||
* skipped deliberately: everything they merge is already in the range as its
|
||||
* own commit, so including them would double-count.
|
||||
*/
|
||||
function changedFiles(range: string): string[] {
|
||||
// Untagged repo: describe everything currently traced.
|
||||
if (!range) {
|
||||
return sh("git ls-files")
|
||||
.split("\n")
|
||||
.filter((f) => f && existsSync(f));
|
||||
}
|
||||
|
||||
// NUL between hash and subject so a subject containing anything at all is safe.
|
||||
const log = sh(`git log --no-merges --format=%H%x00%s ${range}`);
|
||||
if (!log) return [];
|
||||
|
||||
const files = new Set<string>();
|
||||
let skipped = 0;
|
||||
|
||||
for (const line of log.split("\n")) {
|
||||
const [sha, ...subjectParts] = line.split("\u0000");
|
||||
const subject = subjectParts.join("\u0000");
|
||||
if (!sha) continue;
|
||||
|
||||
if (isCosmeticCommit(subject)) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const f of sh(`git diff-tree --no-commit-id --name-only -r ${sha}`).split("\n")) {
|
||||
if (f && existsSync(f)) files.add(f);
|
||||
}
|
||||
}
|
||||
|
||||
if (skipped > 0) {
|
||||
// Say what was dropped rather than silently reporting a smaller set.
|
||||
console.error(
|
||||
`ℹ️ Skipped ${skipped} cosmetic commit(s) (formatting/deps) when deriving notes.`,
|
||||
);
|
||||
}
|
||||
|
||||
return [...files];
|
||||
const cmd = range ? `git diff --name-only ${range}` : "git ls-files"; // untagged repo: describe everything currently traced
|
||||
return sh(cmd)
|
||||
.split("\n")
|
||||
.filter((f) => f && existsSync(f));
|
||||
}
|
||||
|
||||
/** Collect requirement IDs referenced by TRACES comments in the given files. */
|
||||
@@ -207,7 +132,4 @@ function main() {
|
||||
console.log(out.join("\n"));
|
||||
}
|
||||
|
||||
// Guarded so this module stays importable from release-notes.test.ts.
|
||||
if (import.meta.main) {
|
||||
main();
|
||||
}
|
||||
main();
|
||||
|
||||
Generated
+374
-557
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "jellytau"
|
||||
version = "0.10.0"
|
||||
version = "0.9.1"
|
||||
description = "A cross-platform Jellyfin client"
|
||||
authors = ["Duncan Tourolle <duncan@tourolle.paris>"]
|
||||
license = "MIT"
|
||||
|
||||
@@ -1,167 +0,0 @@
|
||||
//! Publishes the Android JavaVM and application Context into `ndk_context`.
|
||||
//!
|
||||
//! TRACES: UR-012 | DR-223
|
||||
//!
|
||||
//! # Why this exists
|
||||
//!
|
||||
//! Seven places in this crate (five in `credentials.rs`, two in `lib.rs`) reach
|
||||
//! the JNI environment through [`ndk_context::android_context`], which reads a
|
||||
//! process-global pair of pointers: the `JavaVM` and a `Context` jobject.
|
||||
//!
|
||||
//! Nothing here ever set that global. `tao` did — the windowing layer beneath
|
||||
//! `wry`, several dependencies below anything this project names. tao 0.34.5
|
||||
//! called `ndk_context::initialize_android_context(...)` while starting the
|
||||
//! Android activity, and our code simply read what it had left behind.
|
||||
//!
|
||||
//! **tao 0.35.3 stopped.** It keeps the same two pointers in a private
|
||||
//! `AndroidContext` struct of its own and no longer publishes them. The moment
|
||||
//! that landed (via the Tauri 2.9.5 → 2.11.5 upgrade), the first credential
|
||||
//! read on Android aborted the process:
|
||||
//!
|
||||
//! ```text
|
||||
//! PANIC at ndk-context/src/lib.rs:72: android context was not initialized
|
||||
//! 8: ndk_context::android_context
|
||||
//! 9: jellytau_lib::run::{{closure}}
|
||||
//! ```
|
||||
//!
|
||||
//! Not a crash in our code, and not a change to our code: an undocumented side
|
||||
//! effect of a transitive dependency disappeared. The lesson worth keeping is
|
||||
//! that relying on *someone else* to populate a global is a dependency you
|
||||
//! cannot see in `Cargo.toml` and will not be told about when it breaks.
|
||||
//!
|
||||
//! # Why restore the global rather than rewrite the call sites
|
||||
//!
|
||||
//! Threading a VM and Context handle through seven call sites — including the
|
||||
//! credential path — is a larger and riskier change than owning the invariant
|
||||
//! those call sites already depend on. This module makes the assumption true
|
||||
//! instead of removing it, and the seven callers are untouched.
|
||||
//!
|
||||
//! # How
|
||||
//!
|
||||
//! `JNI_OnLoad` gives us the `JavaVM` the instant the shared library loads,
|
||||
//! which is the earliest and most reliable moment available — nothing in the app
|
||||
//! can run before it. It does *not* give us a Context, so that is resolved
|
||||
//! lazily on first use via `ActivityThread.currentApplication()`, by which point
|
||||
//! the Application object certainly exists.
|
||||
//!
|
||||
//! The Context published is the **Application**, not the Activity. That is what
|
||||
//! the consumers want anyway (`SecureStorage.initialize()` immediately calls
|
||||
//! `context.applicationContext`), and it cannot outlive its own lifetime the way
|
||||
//! a retained Activity reference would.
|
||||
|
||||
use std::ffi::c_void;
|
||||
use std::sync::atomic::{AtomicPtr, Ordering};
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use jni::objects::GlobalRef;
|
||||
use jni::sys::{jint, JNI_VERSION_1_6};
|
||||
use jni::JavaVM;
|
||||
|
||||
/// The `JavaVM`, captured at library load.
|
||||
static JAVA_VM: AtomicPtr<c_void> = AtomicPtr::new(std::ptr::null_mut());
|
||||
|
||||
/// A global reference to the Application, kept alive for the process lifetime.
|
||||
///
|
||||
/// `ndk_context` stores a bare pointer and does not own the reference, so the
|
||||
/// `GlobalRef` must outlive every read. A local reference would be freed the
|
||||
/// moment the frame that created it returned, leaving a dangling jobject that
|
||||
/// only misbehaves later.
|
||||
static APP_CONTEXT: OnceLock<GlobalRef> = OnceLock::new();
|
||||
|
||||
/// Whether the `ndk_context` global has been populated.
|
||||
static PUBLISHED: OnceLock<bool> = OnceLock::new();
|
||||
|
||||
/// Called by the Android runtime when `libjellytau_lib.so` is loaded.
|
||||
///
|
||||
/// Verified that neither tao, wry nor tauri defines `JNI_OnLoad` in this
|
||||
/// library, so there is nothing to collide with. Returning the JNI version is
|
||||
/// mandatory — returning 0 makes `System.loadLibrary` fail.
|
||||
///
|
||||
/// TRACES: UR-012 | DR-223
|
||||
#[no_mangle]
|
||||
pub extern "system" fn JNI_OnLoad(vm: JavaVM, _reserved: *mut c_void) -> jint {
|
||||
JAVA_VM.store(vm.get_java_vm_pointer().cast(), Ordering::SeqCst);
|
||||
// Deliberately no logging here: the logger is not installed this early.
|
||||
JNI_VERSION_1_6
|
||||
}
|
||||
|
||||
/// Make [`ndk_context::android_context`] safe to call.
|
||||
///
|
||||
/// Idempotent and cheap after the first success. Returns an error rather than
|
||||
/// panicking: a failure here means credentials fall back to the encrypted-file
|
||||
/// path, which is a degraded mode the app already supports — far better than
|
||||
/// aborting the process, which is what the missing global did.
|
||||
///
|
||||
/// TRACES: UR-012 | DR-223
|
||||
pub fn ensure_initialized() -> Result<(), String> {
|
||||
if PUBLISHED.get().is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let vm_ptr = JAVA_VM.load(Ordering::SeqCst);
|
||||
if vm_ptr.is_null() {
|
||||
return Err(
|
||||
"JNI_OnLoad has not run: no JavaVM captured. The library was loaded in an \
|
||||
unexpected way, or JNI_OnLoad was stripped from the shared object."
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
let vm = unsafe { JavaVM::from_raw(vm_ptr.cast()) }
|
||||
.map_err(|e| format!("failed to adopt the JavaVM pointer: {e}"))?;
|
||||
|
||||
let mut env = vm
|
||||
.attach_current_thread()
|
||||
.map_err(|e| format!("failed to attach the current thread to the JVM: {e}"))?;
|
||||
|
||||
// ActivityThread.currentApplication() is the standard way to reach the
|
||||
// Application from native code without being handed a Context. It is a
|
||||
// hidden-but-stable API; it returns null only before the Application is
|
||||
// constructed, which cannot be the case by the time anything here runs.
|
||||
let activity_thread = env
|
||||
.find_class("android/app/ActivityThread")
|
||||
.map_err(|e| format!("android.app.ActivityThread not found: {e}"))?;
|
||||
|
||||
let application = env
|
||||
.call_static_method(
|
||||
activity_thread,
|
||||
"currentApplication",
|
||||
"()Landroid/app/Application;",
|
||||
&[],
|
||||
)
|
||||
.map_err(|e| format!("ActivityThread.currentApplication() failed: {e}"))?
|
||||
.l()
|
||||
.map_err(|e| format!("currentApplication() did not return an object: {e}"))?;
|
||||
|
||||
if application.is_null() {
|
||||
return Err(
|
||||
"ActivityThread.currentApplication() returned null — the Application has not \
|
||||
been created yet."
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
let global = env
|
||||
.new_global_ref(&application)
|
||||
.map_err(|e| format!("failed to pin the Application as a global reference: {e}"))?;
|
||||
|
||||
// Store first, publish second: `ndk_context` will hold a bare pointer into
|
||||
// this reference, so it must already be owned somewhere permanent.
|
||||
let stored = APP_CONTEXT.get_or_init(|| global);
|
||||
let context_ptr = stored.as_obj().as_raw().cast::<c_void>();
|
||||
|
||||
unsafe {
|
||||
ndk_context::initialize_android_context(vm_ptr, context_ptr);
|
||||
}
|
||||
let _ = PUBLISHED.set(true);
|
||||
|
||||
log::info!("[INIT] Android JavaVM and Application published to ndk_context");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Whether the global has been published, for callers that want to degrade
|
||||
/// rather than attempt a JNI call.
|
||||
#[allow(dead_code)]
|
||||
pub fn is_initialized() -> bool {
|
||||
PUBLISHED.get().is_some()
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
#[cfg(target_os = "android")]
|
||||
mod android_context;
|
||||
mod auth;
|
||||
mod commands;
|
||||
mod connectivity;
|
||||
@@ -616,14 +614,6 @@ fn create_player_backend(
|
||||
{
|
||||
info!("Android platform detected - initializing ExoPlayer backend");
|
||||
|
||||
// Same precondition as the credential path: ndk_context must be
|
||||
// populated before it is read, and nothing outside this crate populates
|
||||
// it any more. Idempotent, so it does not matter which of the two runs
|
||||
// first. TRACES: UR-012 | DR-223
|
||||
if let Err(e) = crate::android_context::ensure_initialized() {
|
||||
log::error!("[INIT] Android context unavailable for the player: {e}");
|
||||
}
|
||||
|
||||
// Get the Android context via ndk-context
|
||||
let ctx = ndk_context::android_context();
|
||||
|
||||
@@ -1280,21 +1270,6 @@ pub fn run() {
|
||||
// On Android, initialize SecureStorage BEFORE creating CredentialStore
|
||||
#[cfg(target_os = "android")]
|
||||
{
|
||||
// Publish the JavaVM and Application into ndk_context first.
|
||||
//
|
||||
// Everything below reads that global. tao used to populate it
|
||||
// and stopped doing so in 0.35 (Tauri 2.11), at which point the
|
||||
// first read here aborted the process on launch. See
|
||||
// android_context.rs. A failure is logged rather than fatal:
|
||||
// credentials then fall back to the encrypted-file path, which
|
||||
// is a supported degraded mode -- unlike aborting.
|
||||
//
|
||||
// TRACES: UR-012 | DR-223
|
||||
if let Err(e) = crate::android_context::ensure_initialized() {
|
||||
log::error!("[INIT] Android context unavailable: {e}");
|
||||
log::error!("[INIT] Secure credential storage will fall back to the encrypted file.");
|
||||
}
|
||||
|
||||
info!("[INIT] Initializing Android SecureStorage for credentials...");
|
||||
let ctx = ndk_context::android_context();
|
||||
let vm = unsafe { jni::JavaVM::from_raw(ctx.vm().cast()) };
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "JellyTau",
|
||||
"version": "0.10.0",
|
||||
"version": "0.9.1",
|
||||
"identifier": "com.dtourolle.jellytau",
|
||||
"build": {
|
||||
"beforeDevCommand": "bun run dev",
|
||||
@@ -44,7 +44,6 @@
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"createUpdaterArtifacts": true,
|
||||
"targets": [
|
||||
"deb",
|
||||
"rpm",
|
||||
|
||||
Reference in New Issue
Block a user