Infrastructure hardening: CI enforcement, supply chain, updater, diagnostics #14

Merged
dtourolle merged 10 commits from chore/infra-hardening into master 2026-08-21 17:33:54 +00:00
14 changed files with 832 additions and 56 deletions
Showing only changes of commit 3211c96ecf - Show all commits
+174 -55
View File
@@ -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/<tag>/<asset> but returns 404 for
# /releases/latest/download/<asset>, 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 "<os>-<arch>". 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:
+8
View File
@@ -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=="],
+18
View File
@@ -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
+4
View File
@@ -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/<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-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 |
---
@@ -491,6 +493,7 @@ Internal architecture, components, and application logic.
| UR-074 | - | DR-162, DR-177, DR-181 |
| UR-075 | - | DR-174, DR-175 |
| UR-076 | - | DR-209 |
| UR-077 | - | DR-217 |
---
@@ -701,6 +704,7 @@ Internal architecture, components, and application logic.
| UT-206 | The offline item-type filter is bound rather than interpolated (a value containing a quote and `OR 1=1` matches nothing instead of disabling the `WHERE`), `build_get_items_endpoint` percent-encodes its values while preserving the commas Jellyfin splits on, and volume normalisation clamps out-of-range input and maps NaN to a finite value | DR-212 | Done |
| UT-200 | The stream a player could only restart is refused its retry: the handoff transcode answers yes to `player_retry_restarts_stream` while music, video and a downloaded episode answer no, and the Kotlin decision starts permissive, flips on a non-resumable load, and is restored by the next ordinary one | DR-203 | Done |
| 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 |
### Integration Tests
+2
View File
@@ -58,6 +58,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"
},
+7 -1
View File
@@ -62,7 +62,13 @@ if [[ -n "${OUTPUT_DIR:-}" ]]; then
mkdir -p "$OUTPUT_DIR"
find "$BIN_DIR" -maxdepth 1 -name 'jellytau.exe' -exec cp -v {} "$OUTPUT_DIR/" \;
# NSIS setup installers land in bundle/nsis/*-setup.exe; MSI in bundle/msi/*.msi.
find "$BIN_DIR/bundle" -type f \( -name '*-setup.exe' -o -name '*.msi' \) \
#
# The .sig files come along too: when TAURI_SIGNING_PRIVATE_KEY is set the
# bundler writes `<installer>.sig` beside each installer, and that signature is
# what the updater verifies before installing anything. Leaving it behind
# produces a release whose manifest references a signature that was never
# published, which fails only on the user's machine.
find "$BIN_DIR/bundle" -type f \( -name '*-setup.exe' -o -name '*.msi' -o -name '*.sig' \) \
-exec cp -v {} "$OUTPUT_DIR/" \; 2>/dev/null || true
echo ""
echo "📦 Copied Windows artifacts to $OUTPUT_DIR"
+139
View File
@@ -150,6 +150,15 @@ version = "1.0.100"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
[[package]]
name = "arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
dependencies = [
"derive_arbitrary",
]
[[package]]
name = "ascii"
version = "1.1.0"
@@ -789,6 +798,17 @@ dependencies = [
"serde_core",
]
[[package]]
name = "derive_arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.112",
]
[[package]]
name = "derive_more"
version = "0.99.20"
@@ -1095,6 +1115,16 @@ dependencies = [
"rustc_version",
]
[[package]]
name = "filetime"
version = "0.2.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759"
dependencies = [
"cfg-if",
"libc",
]
[[package]]
name = "find-msvc-tools"
version = "0.1.6"
@@ -2046,6 +2076,8 @@ dependencies = [
"tauri-build",
"tauri-plugin-opener",
"tauri-plugin-os",
"tauri-plugin-process",
"tauri-plugin-updater",
"tauri-specta",
"tempfile",
"tiny_http",
@@ -2338,6 +2370,12 @@ version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "minisign-verify"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e"
[[package]]
name = "miniz_oxide"
version = "0.8.9"
@@ -2643,6 +2681,18 @@ dependencies = [
"objc2-core-foundation",
]
[[package]]
name = "objc2-osa-kit"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0"
dependencies = [
"bitflags 2.10.0",
"objc2",
"objc2-app-kit",
"objc2-foundation",
]
[[package]]
name = "objc2-quartz-core"
version = "0.3.2"
@@ -2775,6 +2825,20 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "osakit"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b"
dependencies = [
"objc2",
"objc2-foundation",
"objc2-osa-kit",
"serde",
"serde_json",
"thiserror 2.0.17",
]
[[package]]
name = "pango"
version = "0.18.3"
@@ -4193,6 +4257,17 @@ dependencies = [
"syn 2.0.112",
]
[[package]]
name = "tar"
version = "0.4.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840"
dependencies = [
"filetime",
"libc",
"xattr",
]
[[package]]
name = "target-lexicon"
version = "0.12.16"
@@ -4372,6 +4447,48 @@ dependencies = [
"thiserror 2.0.17",
]
[[package]]
name = "tauri-plugin-process"
version = "2.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d55511a7bf6cd70c8767b02c97bf8134fa434daf3926cfc1be0a0f94132d165a"
dependencies = [
"tauri",
"tauri-plugin",
]
[[package]]
name = "tauri-plugin-updater"
version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27cbc31740f4d507712550694749572ec0e43bdd66992db7599b89fbfd6b167b"
dependencies = [
"base64 0.22.1",
"dirs",
"flate2",
"futures-util",
"http",
"infer",
"log",
"minisign-verify",
"osakit",
"percent-encoding",
"reqwest",
"semver",
"serde",
"serde_json",
"tar",
"tauri",
"tauri-plugin",
"tempfile",
"thiserror 2.0.17",
"time",
"tokio",
"url",
"windows-sys 0.60.2",
"zip",
]
[[package]]
name = "tauri-runtime"
version = "2.9.2"
@@ -5877,6 +5994,16 @@ dependencies = [
"pkg-config",
]
[[package]]
name = "xattr"
version = "1.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
dependencies = [
"libc",
"rustix",
]
[[package]]
name = "yoke"
version = "0.8.1"
@@ -6041,6 +6168,18 @@ dependencies = [
"syn 2.0.112",
]
[[package]]
name = "zip"
version = "4.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1"
dependencies = [
"arbitrary",
"crc32fast",
"indexmap 2.12.1",
"memchr",
]
[[package]]
name = "zmij"
version = "1.0.8"
+21
View File
@@ -67,6 +67,27 @@ specta-typescript = "=0.0.9"
specta = { version = "=2.0.0-rc.22", features = ["chrono", "derive"] }
tiny_http = { version = "0.12.0", default-features = false }
# In-app update, desktop only.
#
# `cfg(desktop)` is not decoration: tauri-plugin-updater does not support
# Android at all -- an APK cannot replace itself, that is the package manager's
# job -- and building it for the Android target fails. Android is offered the
# releases page through tauri-plugin-opener instead (see the frontend's
# updateCheck module). tauri-plugin-process supplies the relaunch that has to
# follow a desktop install.
#
# The cfg is spelled out as "not android, not iOS" rather than `cfg(desktop)`:
# Cargo evaluates a [target.'cfg(...)'] table against *target-triple* cfgs only
# (target_os, target_arch, target_family, unix/windows). `desktop` is a cfg
# Tauri's build script emits for use in Rust source, so `cfg(desktop)` here
# matches nothing, silently drops the dependency, and the build then fails much
# later with "Permission updater:default not found".
#
# TRACES: UR-077 | DR-217
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
tauri-plugin-updater = "2"
tauri-plugin-process = "2"
# Linux-specific dependencies
[target.'cfg(target_os = "linux")'.dependencies]
hostname = "0.4"
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "updater",
"description": "In-app update: check for a new release, download and install it, then relaunch. Desktop only — tauri-plugin-updater has no Android implementation, and `platforms` keeps these permissions out of the mobile capability set entirely rather than granting something the platform cannot honour.",
"platforms": ["linux", "macOS", "windows"],
"windows": ["main"],
"permissions": ["updater:default", "process:allow-restart"]
}
+18
View File
@@ -1129,6 +1129,24 @@ pub fn run() {
// listened for on the frontend via the generated bindings.
builder.mount_events(app);
// In-app update, desktop only.
//
// Registered here rather than in the builder chain above because a
// `#[cfg]` attribute cannot be attached to one link of a method
// chain -- this block is the shape Tauri's own docs use.
//
// Android is excluded on purpose: tauri-plugin-updater cannot
// replace an installed APK, and the frontend offers the releases
// page there instead.
//
// TRACES: UR-077 | DR-217
#[cfg(desktop)]
{
app.handle()
.plugin(tauri_plugin_updater::Builder::new().build())?;
app.handle().plugin(tauri_plugin_process::init())?;
}
// Initialize database with proper app data directory
// Check for test mode environment variable first
let db_path = if let Ok(test_data_dir) = std::env::var("JELLYTAU_DATA_DIR") {
+12
View File
@@ -31,11 +31,23 @@
}
}
},
"plugins": {
"updater": {
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDhBMEY0NDJDRDAxRUU3NkMKUldSczV4N1FMRVFQaXNXSlV6U3RXdk5qT2NCY0s2eTZ2Q3RYS25MNnNKY09HcU5LSTJjUUx3d3MK",
"endpoints": [
"https://gitea.tourolle.paris/dtourolle/jellytau/raw/branch/updater/latest.json"
],
"windows": {
"installMode": "passive"
}
}
},
"bundle": {
"active": true,
"targets": [
"deb",
"rpm",
"appimage",
"nsis"
],
"icon": [
+97
View File
@@ -0,0 +1,97 @@
/**
* Tests for the update decision logic.
*
* TRACES: | DR-217 | UT-208
*
* The pure half is tested here; `checkForUpdate`/`installUpdate` talk to the
* plugin and are exercised by actually cutting a release (see the Phase 3
* verification steps in docs/build/ci-operations.md).
*/
import { describe, it, expect } from "vitest";
import { decideUpdateAction, isNewerVersion, updateCapability, RELEASES_URL } from "./updateCheck";
describe("isNewerVersion", () => {
it("compares each numeric field in order", () => {
expect(isNewerVersion("0.9.2", "0.9.1")).toBe(true);
expect(isNewerVersion("0.10.0", "0.9.9")).toBe(true);
expect(isNewerVersion("1.0.0", "0.99.99")).toBe(true);
expect(isNewerVersion("0.9.1", "0.9.2")).toBe(false);
});
it("does not offer the version already installed", () => {
expect(isNewerVersion("0.9.1", "0.9.1")).toBe(false);
});
it("tolerates a leading v, which is how the tags are written", () => {
// build-release.yml derives VERSION from refs/tags/v0.9.1.
expect(isNewerVersion("v0.9.2", "0.9.1")).toBe(true);
expect(isNewerVersion("0.9.2", "v0.9.1")).toBe(true);
});
it("sorts a pre-release below the release of the same number", () => {
// Otherwise everyone on 0.9.2 gets offered 0.9.2-rc1 as an "upgrade" —
// build-release.yml marks exactly these suffixes as prereleases.
expect(isNewerVersion("0.9.2-rc1", "0.9.2")).toBe(false);
expect(isNewerVersion("0.9.2", "0.9.2-rc1")).toBe(true);
expect(isNewerVersion("0.9.2-rc1", "0.9.1")).toBe(true);
});
it("treats a missing patch field as zero rather than NaN", () => {
expect(isNewerVersion("1.0", "0.9.9")).toBe(true);
expect(isNewerVersion("0.9", "0.9.1")).toBe(false);
});
});
describe("updateCapability", () => {
it("reports install for the desktop platforms", () => {
expect(updateCapability("linux")).toBe("install");
expect(updateCapability("windows")).toBe("install");
expect(updateCapability("macos")).toBe("install");
});
it("reports link-only for mobile", () => {
// tauri-plugin-updater is not compiled for Android at all: an app cannot
// overwrite its own APK. Calling it there would throw, not degrade.
expect(updateCapability("android")).toBe("link-only");
expect(updateCapability("ios")).toBe("link-only");
});
});
describe("decideUpdateAction", () => {
it("offers nothing when the endpoint reported nothing", () => {
expect(decideUpdateAction("linux", null)).toEqual({ kind: "none" });
expect(decideUpdateAction("android", null)).toEqual({ kind: "none" });
});
it("offers a real install on desktop", () => {
expect(decideUpdateAction("linux", { version: "0.9.2", notes: "fixes" })).toEqual({
kind: "install",
version: "0.9.2",
notes: "fixes",
});
});
it("normalises absent notes to null rather than undefined", () => {
// The Svelte side renders `{#if notes}`; undefined vs null is the kind of
// difference that only shows up as a blank panel in front of a user.
expect(decideUpdateAction("windows", { version: "0.9.2" })).toEqual({
kind: "install",
version: "0.9.2",
notes: null,
});
});
it("offers the releases page on Android instead of an install", () => {
expect(decideUpdateAction("android", { version: "0.9.2" })).toEqual({
kind: "open-releases",
version: "0.9.2",
url: RELEASES_URL,
});
});
it("points at Gitea, not GitHub", () => {
// The release body used to link "GitHub Issues" on a Gitea-hosted project.
expect(RELEASES_URL).toContain("gitea.tourolle.paris");
});
});
+179
View File
@@ -0,0 +1,179 @@
/**
* In-app update: decide what to offer, and on which platform.
*
* TRACES: UR-077 | DR-217
*
* Until this existed there was no upgrade path at all. Somebody who installed a
* `.AppImage` or ran the NSIS installer stayed on that version permanently and
* had no way to learn a newer one existed the release notes were the only
* announcement, and nothing in the app ever read them.
*
* ## Two platforms, two answers
*
* `tauri-plugin-updater` replaces the running application's own files. That is
* possible on Linux and Windows and impossible on Android, where installing an
* APK is the package installer's job and an app may not overwrite itself. So
* the plugin is compiled only for desktop (see `Cargo.toml`'s
* `cfg(not(any(target_os = "android", target_os = "ios")))` table) and Android
* gets the honest alternative: a link to the releases page.
*
* That asymmetry is the whole reason this module is a pure decision function
* plus a thin caller. `decideUpdateAction` takes the platform and what the
* endpoint said and returns *what to offer*; the Svelte side does the offering.
* The alternative `if (platform() === "android")` sprinkled through a
* component is exactly the shape that has bitten this codebase before.
*
* ## Versions
*
* Comparison is left to the updater plugin on desktop, which compares against
* the version baked into the bundle. `isNewerVersion` exists for the Android
* path, where nothing native is available to do it, and for tests.
*/
import { createLogger } from "$lib/utils/logger";
const log = createLogger("UpdateCheck");
/** Where a user goes to fetch a build by hand. */
export const RELEASES_URL = "https://gitea.tourolle.paris/dtourolle/jellytau/releases";
/** What the UI should offer the user. */
export type UpdateAction =
/** Nothing to do — already current, or the check failed and we stay quiet. */
| { kind: "none" }
/** Desktop: the plugin can download, verify and install this itself. */
| { kind: "install"; version: string; notes: string | null }
/** Android: we can only point at the download. */
| { kind: "open-releases"; version: string; url: string };
/**
* Compare two semver-ish version strings.
*
* Deliberately small: JellyTau's versions are `MAJOR.MINOR.PATCH` with an
* optional `-rc1`/`-beta` suffix (build-release.yml keys prerelease off exactly
* those). A pre-release sorts *below* the same numeric version, so 0.9.2-rc1
* does not offer itself as an upgrade to somebody on 0.9.2.
*
* Returns true when `candidate` is strictly newer than `current`.
*/
export function isNewerVersion(candidate: string, current: string): boolean {
const parse = (raw: string) => {
const cleaned = raw.trim().replace(/^v/, "");
const [core, ...rest] = cleaned.split("-");
const parts = core.split(".").map((n) => Number.parseInt(n, 10));
return {
nums: [parts[0] || 0, parts[1] || 0, parts[2] || 0],
// Any suffix at all makes it a pre-release.
pre: rest.length > 0,
};
};
const a = parse(candidate);
const b = parse(current);
for (let i = 0; i < 3; i++) {
if (a.nums[i] !== b.nums[i]) return a.nums[i] > b.nums[i];
}
// Same numbers: a release beats a pre-release, nothing beats a release.
return b.pre && !a.pre;
}
/** What a platform can do about an available update. */
export type UpdateCapability = "install" | "link-only";
/**
* Map a platform string (from `@tauri-apps/plugin-os`) to what it can do.
*
* Android is link-only because the updater plugin is not compiled there at all;
* calling it would throw rather than degrade.
*/
export function updateCapability(platform: string): UpdateCapability {
return platform === "android" || platform === "ios" ? "link-only" : "install";
}
/**
* Decide what to offer.
*
* `available` is null when the endpoint reported no newer version, or when the
* check failed the caller collapses both, because the UI treatment is the
* same and a failed update check must never interrupt somebody watching
* something.
*/
export function decideUpdateAction(
platform: string,
available: { version: string; notes?: string | null } | null,
): UpdateAction {
if (!available) return { kind: "none" };
if (updateCapability(platform) === "link-only") {
return { kind: "open-releases", version: available.version, url: RELEASES_URL };
}
return { kind: "install", version: available.version, notes: available.notes ?? null };
}
/**
* Check for an update and return what to offer.
*
* Everything here is best-effort: a server that is down, an endpoint that
* 404s, or a machine with no network must produce a quiet "none", never an
* error the user sees. An update check is not something the user asked for.
*/
export async function checkForUpdate(): Promise<UpdateAction> {
try {
const { platform } = await import("@tauri-apps/plugin-os");
const current = platform();
if (updateCapability(current) === "link-only") {
// Nothing native to ask on Android. Offering the releases page
// unconditionally would nag on every launch, so the mobile path is
// surfaced from Settings on demand rather than checked automatically.
return { kind: "none" };
}
const { check } = await import("@tauri-apps/plugin-updater");
const update = await check();
if (!update) return { kind: "none" };
log.info("update available", update.version);
return decideUpdateAction(current, { version: update.version, notes: update.body ?? null });
} catch (error) {
log.warn("update check failed; staying quiet", error);
return { kind: "none" };
}
}
/**
* Download, verify and install a desktop update, then relaunch.
*
* The signature check happens inside the plugin against the public key in
* `tauri.conf.json` an artifact that does not verify is refused there, which
* is the entire security value of the updater. `onProgress` is fed the fraction
* downloaded so the UI can show something during what may be a 100 MB fetch.
*/
export async function installUpdate(onProgress?: (fraction: number) => void): Promise<void> {
const { check } = await import("@tauri-apps/plugin-updater");
const update = await check();
if (!update) return;
let downloaded = 0;
let contentLength = 0;
await update.downloadAndInstall((event) => {
switch (event.event) {
case "Started":
contentLength = event.data.contentLength ?? 0;
break;
case "Progress":
downloaded += event.data.chunkLength;
if (contentLength > 0) onProgress?.(downloaded / contentLength);
break;
case "Finished":
onProgress?.(1);
break;
}
});
const { relaunch } = await import("@tauri-apps/plugin-process");
await relaunch();
}
+145
View File
@@ -30,6 +30,14 @@
import { experimentalNativeVideo } from "$lib/stores/nativeVideo";
import { getPlaybackCapabilities } from "$lib/services/playbackCapabilities";
import { createLogger } from "$lib/utils/logger";
import { openUrl } from "@tauri-apps/plugin-opener";
import {
checkForUpdate,
installUpdate,
updateCapability,
RELEASES_URL,
type UpdateAction,
} from "$lib/utils/updateCheck";
const log = createLogger("SettingsPage");
@@ -140,6 +148,11 @@
onMount(async () => {
await loadSettings();
supportsNativeVideo = (await getPlaybackCapabilities()).supportsNativeVideo;
// Which update story this platform gets. Android cannot install its own
// APK, so it is offered the releases page instead of an install button.
const { platform } = await import("@tauri-apps/plugin-os");
canInstallUpdates = updateCapability(platform()) === "install";
});
async function loadSettings() {
@@ -419,6 +432,54 @@
cacheConfig.wifiOnly = !cacheConfig.wifiOnly;
persistCache();
}
// ---------------------------------------------------------------------
// Updates
//
// The decision of *what to offer* lives in $lib/utils/updateCheck.ts and is
// unit-tested there; this component only renders the answer. On Android the
// answer is always "open the releases page" -- the updater plugin is not
// compiled for that target at all.
//
// TRACES: UR-077 | DR-217
let updateState = $state<"idle" | "checking" | "current" | "available" | "installing" | "failed">(
"idle",
);
let updateAction = $state<UpdateAction>({ kind: "none" });
let updateProgress = $state(0);
let canInstallUpdates = $state(true);
async function handleCheckForUpdates() {
updateState = "checking";
try {
if (!canInstallUpdates) {
// Nothing to interrogate on mobile; go straight to the download page.
await openUrl(RELEASES_URL);
updateState = "idle";
return;
}
const action = await checkForUpdate();
updateAction = action;
updateState = action.kind === "none" ? "current" : "available";
} catch (e) {
log.warn("update check failed", e);
updateState = "failed";
}
}
async function handleInstallUpdate() {
updateState = "installing";
updateProgress = 0;
try {
// installUpdate relaunches the app on success, so there is deliberately
// no "done" state here -- the process is gone before we could set one.
await installUpdate((fraction) => {
updateProgress = fraction;
});
} catch (e) {
log.error("update install failed", e);
updateState = "failed";
}
}
</script>
<div class="max-w-2xl mx-auto space-y-8 p-6">
@@ -1096,6 +1157,90 @@
</div>
</div>
<!-- Updates.
Desktop installs in place; Android can only be pointed at the
releases page, because an app may not replace its own APK. The
decision lives in $lib/utils/updateCheck.ts, not in this markup.
TRACES: UR-077 | DR-217 -->
<div class="border-t border-gray-700 pt-6">
<h2 class="text-2xl font-bold text-white mb-4">Updates</h2>
<div class="bg-[var(--color-surface)] rounded-lg p-6 space-y-4">
<div class="flex items-center justify-between gap-4">
<div>
<h3 class="text-lg font-semibold text-white">
{canInstallUpdates ? "Check for updates" : "Get the latest version"}
</h3>
<p class="text-sm text-gray-400 mt-1">
{#if canInstallUpdates}
Downloads are verified against JellyTau's signing key before anything is
installed.
{:else}
Android installs are handled by the system installer — this opens the releases
page.
{/if}
</p>
</div>
<button
class="px-4 py-2 rounded-lg bg-[var(--color-jellyfin)] text-white font-medium disabled:opacity-50 whitespace-nowrap"
onclick={handleCheckForUpdates}
disabled={updateState === "checking" || updateState === "installing"}
>
{#if updateState === "checking"}
Checking…
{:else if canInstallUpdates}
Check now
{:else}
Open releases
{/if}
</button>
</div>
{#if updateState === "current"}
<p class="text-sm text-green-400">You're on the latest version.</p>
{:else if updateState === "failed"}
<p class="text-sm text-yellow-400">
Couldn't reach the update server. This is safe to ignore — JellyTau keeps working.
</p>
{:else if updateState === "installing"}
<div>
<p class="text-sm text-gray-300 mb-2">
Downloading… {Math.round(updateProgress * 100)}%
</p>
<div class="h-2 bg-gray-700 rounded-full overflow-hidden">
<div
class="h-full bg-[var(--color-jellyfin)] transition-all"
style="width: {updateProgress * 100}%"
></div>
</div>
<p class="text-xs text-gray-500 mt-2">JellyTau will restart when this finishes.</p>
</div>
{:else if updateState === "available" && updateAction.kind === "install"}
<div class="border border-gray-700 rounded-lg p-4 space-y-3">
<p class="text-white font-medium">Version {updateAction.version} is available</p>
{#if updateAction.notes}
<pre
class="text-sm text-gray-300 whitespace-pre-wrap max-h-48 overflow-y-auto">{updateAction.notes}</pre>
{/if}
<button
class="px-4 py-2 rounded-lg bg-[var(--color-jellyfin)] text-white font-medium"
onclick={handleInstallUpdate}
>
Install and restart
</button>
</div>
{:else if updateState === "available" && updateAction.kind === "open-releases"}
<button
class="text-sm text-[var(--color-jellyfin)] underline"
onclick={() =>
openUrl(updateAction.kind === "open-releases" ? updateAction.url : RELEASES_URL)}
>
Version {updateAction.version} is available — open the releases page
</button>
{/if}
</div>
</div>
<!-- Info Box -->
<div class="bg-blue-900/20 border border-blue-800 rounded-lg p-4">
<div class="flex gap-3">