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/<tag>/<asset> but 404s on
/releases/latest/download/<asset> (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.
This commit is contained in:
@@ -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,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=="],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
|
||||
Generated
+139
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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") {
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user