From 3211c96ecf5dfd79949a72ab4bdc1ebb46896d8f Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Fri, 21 Aug 2026 18:41:50 +0200 Subject: [PATCH] feat(updater): in-app update on desktop, releases link on Android Anyone who installed an AppImage or ran the Windows installer was frozen on that version forever. Nothing in the app ever mentioned a new release existed, and the release notes were the only announcement. Desktop now checks a signed manifest, shows the version and its notes in Settings, and installs and relaunches on request. The signature check is the whole point: it is what stops a substituted download from being installed by the app itself. Windows binaries stay unsigned for SmartScreen purposes -- that is a code-signing certificate, a separate problem -- but the update payload is verified against our own key. Android is deliberately not wired to the updater. An app may not replace its own APK; that is the package installer's job, and the plugin has no Android implementation. It gets a link to the releases page instead of a button that would throw. The plugins are gated with a target-triple cfg rather than cfg(desktop). Cargo only evaluates target cfgs in a [target.'cfg(..)'] table, so cfg(desktop) matches nothing, silently drops the dependency, and fails much later with "Permission updater:default not found" -- which is exactly what the first attempt here did. Where the manifest lives took some finding. This Gitea serves /releases/download// but 404s on /releases/latest/download/ (verified against a real asset), so there is no stable latest-release URL. The gitea-pages branch is force-pushed wholesale by publish-docs.yml, so it cannot host the file either. latest.json therefore gets its own orphan branch, read over the raw-file URL, and is published from a scratch repo in RUNNER_TEMP rather than by switching branches in the checkout -- doing that would have left the following steps standing on a one-commit history, and the next step but one runs release:notes against the real commit range. Also fixed, all of it release-integrity: - "appimage" is in bundle.targets. The release notes have advertised an AppImage for months; tauri.conf.json never built one, the artifact step globbed for *.AppImage, found nothing, and said nothing. The step now fails instead. - The .AppImage.tar.gz/.sig pair and the NSIS .sig are collected. A manifest referencing a signature that was never uploaded fails only on the user's machine, so the manifest step also refuses to write an entry with an empty signature. - Release notes are generated by release:notes from the traceability graph, which is what CLAUDE.md has asked for all along, instead of a fixed heredoc that said "see CHANGELOG.md for detailed changes" and linked "GitHub Issues" on a Gitea-hosted project. - The notes tell users how to verify a download with SHA256SUMS. Requirements UR-077 / DR-217, tests UT-208 (12 cases over the version comparison and the platform decision, including that a pre-release does not offer itself as an upgrade to the matching release). Verified: 1070 frontend tests, cargo check for both the host and aarch64-linux-android (confirming the plugins are absent there), clippy -D warnings, svelte-check 0 errors. --- .gitea/workflows/build-release.yml | 229 +++++++++++++++++++++------- bun.lock | 8 + docs/release-checklist.md | 18 +++ docs/requirements.md | 4 + package.json | 2 + scripts/build-windows-cross.sh | 8 +- src-tauri/Cargo.lock | 139 +++++++++++++++++ src-tauri/Cargo.toml | 21 +++ src-tauri/capabilities/updater.json | 8 + src-tauri/src/lib.rs | 18 +++ src-tauri/tauri.conf.json | 12 ++ src/lib/utils/updateCheck.test.ts | 97 ++++++++++++ src/lib/utils/updateCheck.ts | 179 ++++++++++++++++++++++ src/routes/settings/+page.svelte | 145 ++++++++++++++++++ 14 files changed, 832 insertions(+), 56 deletions(-) create mode 100644 src-tauri/capabilities/updater.json create mode 100644 src/lib/utils/updateCheck.test.ts create mode 100644 src/lib/utils/updateCheck.ts diff --git a/.gitea/workflows/build-release.yml b/.gitea/workflows/build-release.yml index affbb855..2ed80ae1 100644 --- a/.gitea/workflows/build-release.yml +++ b/.gitea/workflows/build-release.yml @@ -138,10 +138,19 @@ jobs: run: ./scripts/set-version.sh "${GITHUB_REF#refs/tags/}" if: startsWith(github.ref, 'refs/tags/v') + # TAURI_SKIP_UPDATER is gone: it was suppressing the updater artifacts + # (.AppImage.tar.gz + .sig) that the update manifest points at, back when + # there was no updater to feed. With the signing key present, `tauri build` + # emits and signs them. + # + # 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. - name: Build for Linux run: bun run tauri build env: - TAURI_SKIP_UPDATER: true + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} - name: Prepare Linux artifacts run: | @@ -160,14 +169,27 @@ jobs: # step (which is why v0.9.0 and v0.9.1 built but never published). # Without nullglob an unmatched pattern stays literal, so test each # candidate instead. Same POSIX-only rule as traceability-check.yml. + # + # 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.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 cp -v "$bundle" dist/linux/ done + # An AppImage that did not build means no updater artifact either, and + # the release notes have advertised an AppImage for months. Fail rather + # than publish a release whose manifest points at nothing. + if ! ls dist/linux/*.AppImage >/dev/null 2>&1; then + echo "::error::No AppImage produced -- check bundle.targets in tauri.conf.json" + exit 1 + fi + # A release with no Linux package is a failure, not a quiet success. if [ -z "$(ls -A dist/linux/)" ]; then echo "::error::No Linux bundles found under src-tauri/target/release/bundle/" @@ -244,6 +266,9 @@ jobs: - name: Build Windows (NSIS installer + exe) run: OUTPUT_DIR="$PWD/dist/windows" WIN_BUNDLES=nsis ./scripts/build-windows-cross.sh + env: + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} - name: List Windows artifacts run: ls -lah dist/windows/ @@ -411,6 +436,106 @@ jobs: # # Written with paths relative to the asset directory so `sha256sum -c # SHA256SUMS` works in the directory a user downloaded into. + # The update manifest. Built before the checksums so latest.json is not + # itself hashed into SHA256SUMS (it is metadata about the release, not a + # download), and after the artifacts exist so the signatures can be read. + # + # Why a dedicated `updater` branch and a raw-file URL: this Gitea serves + # /releases/download// but returns 404 for + # /releases/latest/download/, so there is no stable "latest release" + # URL to point a client at. The gitea-pages branch is force-pushed whole by + # publish-docs.yml, so hosting the manifest there would delete it on the + # next docs build. An orphan branch that only ever contains latest.json is + # the one location both stable and ours. + - name: Build update manifest (latest.json) + id: manifest + run: | + set -e + VERSION="${{ steps.tag_name.outputs.VERSION }}" + # The manifest carries the bare version; the tag carries the v prefix. + PLAIN="${VERSION#v}" + BASE="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/releases/download/${VERSION}" + + # Tauri matches on "-". We ship one desktop arch today. + APPIMAGE_SIG="" + NSIS_SIG="" + APPIMAGE_URL="" + NSIS_URL="" + + for f in artifacts/linux/*.AppImage.tar.gz; do + [ -e "$f" ] || continue + APPIMAGE_URL="${BASE}/$(basename "$f")" + [ -e "$f.sig" ] && APPIMAGE_SIG="$(cat "$f.sig")" + done + + for f in artifacts/windows/*-setup.exe; do + [ -e "$f" ] || continue + NSIS_URL="${BASE}/$(basename "$f")" + [ -e "$f.sig" ] && NSIS_SIG="$(cat "$f.sig")" + done + + # A manifest with an empty signature is worse than no manifest: the + # client rejects it after downloading the whole payload. + if [ -z "$APPIMAGE_SIG" ] || [ -z "$NSIS_SIG" ]; then + echo "::error::Missing updater signature (appimage='$APPIMAGE_SIG' nsis='$NSIS_SIG')." + echo "::error::Check that TAURI_SIGNING_PRIVATE_KEY reached both desktop build jobs." + exit 1 + fi + + # 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" \ + --arg notes "$NOTES" \ + --arg pub_date "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ + --arg lin_sig "$APPIMAGE_SIG" --arg lin_url "$APPIMAGE_URL" \ + --arg win_sig "$NSIS_SIG" --arg win_url "$NSIS_URL" \ + '{ + version: $version, + notes: $notes, + pub_date: $pub_date, + platforms: { + "linux-x86_64": { signature: $lin_sig, url: $lin_url }, + "windows-x86_64": { signature: $win_sig, url: $win_url } + } + }' > latest.json + + echo "πŸ“„ latest.json:" + cat latest.json + + - name: Publish latest.json to the updater branch + env: + GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} + AUTO_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -e + TOKEN="${GITEA_TOKEN:-$AUTO_TOKEN}" + HOST="$(echo "$GITHUB_SERVER_URL" | sed -E 's#^https?://##')" + REMOTE="https://oauth2:${TOKEN}@${HOST}/${GITHUB_REPOSITORY}.git" + + # Built in a scratch repo, NOT by switching branches in the checkout. + # `git checkout --orphan` here would leave every later step standing on + # a one-commit branch -- and the next step but one runs + # `bun run release:notes`, which resolves a commit range against the + # real history and would silently produce nothing. + WORK="$RUNNER_TEMP/updater-branch" + rm -rf "$WORK" + mkdir -p "$WORK" + cp latest.json "$WORK/latest.json" + cd "$WORK" + git init -q + git config user.email "ci@jellytau" + git config user.name "JellyTau CI" + git add latest.json + git commit -qm "chore(updater): manifest for ${{ steps.tag_name.outputs.VERSION }}" + echo "πŸš€ Force-pushing update manifest to the updater branch" + # Force-push: the branch holds exactly one file and no history worth + # keeping, same shape as publish-docs.yml's gitea-pages. + git push -f "$REMOTE" HEAD:refs/heads/updater + echo "βœ… Served at ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/raw/branch/updater/latest.json" + - name: Generate SHA256SUMS run: | set -e @@ -424,64 +549,58 @@ jobs: # release rather than shipping and failing for users. sha256sum -c SHA256SUMS + # 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 "## JellyTau $VERSION Release" > release_notes.md - echo "" >> release_notes.md - echo "### Downloads" >> release_notes.md - echo "" >> release_notes.md - echo "#### Linux" >> release_notes.md - echo "- **AppImage** - Run directly on most Linux distributions" >> release_notes.md - echo "- **DEB** - Install via \`sudo dpkg -i JellyTau_*.deb\` (Ubuntu/Debian)" >> release_notes.md - echo "- **RPM** - Install via \`sudo rpm -i JellyTau-*.rpm\` (Fedora/openSUSE)" >> release_notes.md - echo "" >> release_notes.md - echo "#### Windows" >> release_notes.md - echo "- **Installer (.exe)** - Run \`JellyTau_*-setup.exe\` (NSIS). Unsigned β€” SmartScreen may warn on first run." >> release_notes.md - echo "" >> release_notes.md - echo "#### Android" >> release_notes.md - echo "- **APK** - Install via \`adb install jellytau-release.apk\` or sideload via file manager" >> release_notes.md - echo "- **AAB** - Upload to Google Play Console or testing platforms" >> release_notes.md - echo "" >> release_notes.md - echo "### What's New" >> release_notes.md - echo "" >> release_notes.md - echo "See [CHANGELOG.md](CHANGELOG.md) for detailed changes." >> release_notes.md - echo "" >> release_notes.md - echo "### Installation" >> release_notes.md - echo "" >> release_notes.md - echo "#### Linux (AppImage)" >> release_notes.md - echo "\`\`\`bash" >> release_notes.md - echo "chmod +x JellyTau_*.AppImage" >> release_notes.md - echo "./JellyTau_*.AppImage" >> release_notes.md - echo "\`\`\`" >> release_notes.md - echo "" >> release_notes.md - echo "#### Linux (DEB)" >> release_notes.md - echo "\`\`\`bash" >> release_notes.md - echo "sudo dpkg -i JellyTau_*.deb" >> release_notes.md - echo "jellytau" >> release_notes.md - echo "\`\`\`" >> release_notes.md - echo "" >> release_notes.md - echo "#### Android" >> release_notes.md - echo "- Sideload: Download APK and install via file manager or ADB" >> release_notes.md - echo "- Play Store: Coming soon" >> release_notes.md - echo "" >> release_notes.md - echo "### Known Issues" >> release_notes.md - echo "" >> release_notes.md - echo "See [GitHub Issues](../../issues) for reported bugs." >> release_notes.md - echo "" >> release_notes.md - echo "### Requirements" >> release_notes.md - echo "" >> release_notes.md - echo "**Linux:**" >> release_notes.md - echo "- 64-bit Linux system" >> release_notes.md - echo "- GLIBC 2.29+" >> release_notes.md - echo "" >> release_notes.md - echo "**Android:**" >> release_notes.md - echo "- Android 8.0 or higher" >> release_notes.md - echo "- 50MB free storage" >> release_notes.md - echo "" >> release_notes.md - echo "---" >> release_notes.md - echo "Built with Tauri, SvelteKit, and Rust" >> release_notes.md + { + 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 "" + echo "| Platform | File |" + echo "|---|---|" + echo "| Linux (portable) | \`*.AppImage\` β€” \`chmod +x\` and run |" + echo "| Linux (Debian/Ubuntu) | \`*.deb\` β€” \`sudo dpkg -i\` |" + echo "| Linux (Fedora/openSUSE) | \`*.rpm\` β€” \`sudo rpm -i\` |" + 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 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 "" + echo "\`\`\`bash" + echo "sha256sum -c SHA256SUMS" + echo "\`\`\`" + echo "" + echo "\`SHA256SUMS\` covers every file in this release. An SBOM" + echo "(\`*.cdx.json\`, \`frontend-dependencies.txt\`) lists what went into it." + echo "" + echo "### Requirements" + echo "" + echo "- **Linux:** 64-bit, GLIBC 2.29+" + echo "- **Windows:** 64-bit Windows 10 or later" + echo "- **Android:** 8.0 or later, ~50 MB free" + echo "" + echo "---" + echo "Report a problem: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/issues" + } > release_notes.md + echo "πŸ“ Release notes:" + cat release_notes.md - name: Publish Gitea release & upload assets env: diff --git a/bun.lock b/bun.lock index 731349e7..3fee1ecd 100644 --- a/bun.lock +++ b/bun.lock @@ -8,6 +8,8 @@ "@tauri-apps/api": "^2", "@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", "hls.js": "^1.6.15", "svelte-dnd-action": "^0.9.69", }, @@ -282,6 +284,10 @@ "@tauri-apps/plugin-os": ["@tauri-apps/plugin-os@2.3.2", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-n+nXWeuSeF9wcEsSPmRnBEGrRgOy6jjkSU+UVCOV8YUGKb2erhDOxis7IqRXiRVHhY8XMKks00BJ0OAdkpf6+A=="], + "@tauri-apps/plugin-process": ["@tauri-apps/plugin-process@2.3.1", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-nCa4fGVaDL/B9ai03VyPOjfAHRHSBz5v6F/ObsB73r/dA3MHHhZtldaDMIc0V/pnUw9ehzr2iEG+XkSEyC0JJA=="], + + "@tauri-apps/plugin-updater": ["@tauri-apps/plugin-updater@2.10.1", "", { "dependencies": { "@tauri-apps/api": "^2.10.1" } }, "sha512-NFYMg+tWOZPJdzE/PpFj2qfqwAWwNS3kXrb1tm1gnBJ9mYzZ4WDRrwy8udzWoAnfGCHLuePNLY1WVCNHnh3eRA=="], + "@testing-library/dom": ["@testing-library/dom@10.4.1", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "picocolors": "1.1.1", "pretty-format": "^27.0.2" } }, "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg=="], "@testing-library/svelte": ["@testing-library/svelte@5.3.1", "", { "dependencies": { "@testing-library/dom": "9.x.x || 10.x.x", "@testing-library/svelte-core": "1.0.0" }, "peerDependencies": { "svelte": "^3 || ^4 || ^5 || ^5.0.0-next.0", "vite": "*", "vitest": "*" }, "optionalPeers": ["vite", "vitest"] }, "sha512-8Ez7ZOqW5geRf9PF5rkuopODe5RGy3I9XR+kc7zHh26gBiktLaxTfKmhlGaSHYUOTQE7wFsLMN9xCJVCszw47w=="], @@ -752,6 +758,8 @@ "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "@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=="], "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.6", "", {}, "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw=="], diff --git a/docs/release-checklist.md b/docs/release-checklist.md index 5b32c26d..94a8ce6e 100644 --- a/docs/release-checklist.md +++ b/docs/release-checklist.md @@ -126,6 +126,24 @@ git push origin v1.2.0 - [ ] All artifacts are uploaded - [ ] Release type is correct (prerelease vs release) +- [ ] Verify integrity metadata (DR-216): + - [ ] `SHA256SUMS` is present, and `sha256sum -c SHA256SUMS` passes in the + directory you downloaded into + - [ ] SBOM files are present (`*.cdx.json`, `frontend-dependencies.txt`) + +- [ ] Verify the update path (DR-217) β€” this is the step that catches a broken + updater *before* users hit it, because a bad manifest fails only on their + machine: + - [ ] `latest.json` is live and names this version: + `curl -s https://gitea.tourolle.paris/dtourolle/jellytau/raw/branch/updater/latest.json | jq .version` + - [ ] Both platform entries carry a non-empty `signature` + - [ ] The `.AppImage.tar.gz`, its `.sig`, and the NSIS `.sig` are among the + release assets β€” the manifest points at them + - [ ] Install the **previous** release, launch it, and use Settings β†’ Updates: + it should offer this version, install it, and relaunch + - [ ] On Android, Settings β†’ Updates offers the releases page rather than an + install button (the updater plugin is not compiled for that target) + - [ ] Announce release: - [ ] Post to relevant channels/communities - [ ] Update website/docs diff --git a/docs/requirements.md b/docs/requirements.md index ffa7ce13..2471fd4d 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -86,6 +86,7 @@ For a narrative overview of the system design, see | UR-072 | Each page opens where a page should open. Moving to a new screen starts at the top of it, and going Back returns the viewer to the place they left β€” their position in a long library grid or home screen, not the top of it. A page never inherits the scroll position of the page before it | Medium | Done | | UR-075 | Artwork is shown at the shape it was made in. Where a screen presents a set of things side by side β€” the libraries on the library page and on home β€” they are laid out as a mosaic: rows of a common height in which each tile is as wide as its own picture, rather than a grid that crops every cover to one box. Favourites are reachable per category from that same mosaic, beside the library they belong to, not only as one undifferentiated list | Medium | Done | | UR-076 | Music browsing shows only what the listener considers music. A Jellyfin server commonly keeps podcasts, audiobooks, sound effects or sample packs in their own folders inside a music library; those folders can be **excluded by choice**, once, and every music surface β€” library grids, artist and album listings, genre rows, search and the home screen β€” then agrees on what is in scope. The choice is by folder, not by a name the app happens to recognise, so a folder called anything at all can be excluded and an item is never dropped because its title matched a word | Medium | Done | +| UR-077 | The app can update itself, or tell the user how. Somebody who installed an AppImage or ran the Windows installer had no upgrade path at all: nothing in the app ever mentioned that a newer version existed, and the release notes were the only announcement. On Linux and Windows the app checks a signed manifest, offers the new version with its notes, and installs and relaunches on request β€” the signature check is the point, since it is what stops a substituted download from being installed by the app itself. Android cannot do this (an app may not overwrite its own APK; that is the package installer's job) and is given the honest alternative, a link to the releases page, rather than a button that would throw | Medium | Done | | UR-074 | Video streaming can be held to a **bandwidth budget the viewer sets**, rather than spent at whatever rate the server would otherwise send. A ceiling chosen once β€” from the source's own bitrate down to a rung that still plays on a poor connection β€” governs every video the app opens, live TV included, and survives a restart, so a metered connection is not quietly drained by the next thing played. A single video can be moved to a different ceiling from the player, resuming where it was, without disturbing that default | Medium | Done | --- @@ -406,6 +407,7 @@ Internal architecture, components, and application logic. | DR-214 | The app identifies itself correctly everywhere a user or a package manager reads its name. `productName` was the scaffold's lowercase `jellytau`, which is what the Android release build showed under its icon and what the deb/rpm/NSIS bundles carried as their display name β€” invisible in development because `build.gradle.kts` overrides the label to "JellyTau Debug" for the debug build type, so the install a developer looks at daily was the only correctly-cased one. `mainBinaryName` pins the executable filename so nothing that resolves a path by name has to change. `strings.xml` moves into the canonical android tree, where `sync-android-sources.sh` already copies `res/values/*.xml`, so the fix survives regenerating `gen/`. Bundle metadata (publisher, copyright, category, descriptions, licence) was entirely absent, which is why the packages shipped with no maintainer or description β€” the hand-written Arch PKGBUILD and `.desktop` had all of it, so only the *generated* packaging was wrong | Packaging | - | Done | | DR-215 | Frontend test coverage is a ratcheted CI gate rather than a number nobody looks at. `test:coverage` had been configured since the suite was created and was silently broken: `@vitest/coverage-v8` resolved to 4.1.10, whose peer range pins `vitest` exactly, while `package.json` asked for `>=1.0.0 <5.0.0` and got 4.0.16 β€” so every invocation died on a missing `BaseCoverageProvider` export and no coverage figure had been produced in months. Fixing the range is half the requirement; the other half is that a measured figure that gates nothing decays the same way an unrun script does. Thresholds sit a few points under the measured result (statements 54.6, branches 48.7, functions 49.6, lines 55.1 when this landed) and only ever move up, matching `MIN_THRESHOLD` in the traceability gate and the eslint `--max-warnings` ratchet. The absolute numbers are held down by `.svelte` components, which this project deliberately does not test directly β€” the pattern is to extract the logic to a plain module and test that | Tooling | - | Done | | 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//` but returns 404 for `/releases/latest/download/`, 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-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 `