build: make the git tag the single source of truth for the version (DR-153)
The version lived in four files — package.json, tauri.conf.json, Cargo.toml and
Cargo.lock — that had to be hand-edited in lockstep, and the release workflow
rewrote exactly one of them. A tagged build therefore produced an installer
named for the tag wrapped around package metadata naming the previous release,
and the Linux job, which had no version step at all, shipped whatever happened
to be committed.
scripts/set-version.sh now writes all four from one argument and is the only
thing that does. Every release job calls it with the tag, including the Linux
job that was missing one. The committed versions become a placeholder for dev
builds rather than something to maintain by hand.
The Android versionCode moves into the same script, unchanged in formula
(1000 + major*10000 + minor*100 + patch). It stays inline-documented because the
reasoning is not obvious: builds already in the field shipped code 1000, and
Android refuses an update whose code is lower than the installed one, so a
formula that can emit a smaller number for a newer release bricks updates
irreversibly. UT-150 asserts that property directly — monotonic across an
upgrade sequence, and always above the floor.
Two edge cases the previous inline version got wrong:
- A prerelease tag (v0.6.0-rc1) made $(( 0-rc1 )) abort the step under set -e.
The suffix is stripped before the arithmetic; the manifests keep it.
- CI passes "${GITHUB_REF#refs/tags/}" unconditionally, which on a branch build
is still a full ref. That reached the validator verbatim and would have failed
every untagged Android build; a non-tag ref now falls back to git describe.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -96,6 +96,12 @@ jobs:
|
|||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: bun install
|
run: bun install
|
||||||
|
|
||||||
|
# The Linux job previously had no version step at all, so a tagged release
|
||||||
|
# built Linux packages from whatever version happened to be committed.
|
||||||
|
- name: Set app version from tag
|
||||||
|
run: ./scripts/set-version.sh "${GITHUB_REF#refs/tags/}"
|
||||||
|
if: startsWith(github.ref, 'refs/tags/v')
|
||||||
|
|
||||||
- name: Build for Linux
|
- name: Build for Linux
|
||||||
run: bun run tauri build
|
run: bun run tauri build
|
||||||
env:
|
env:
|
||||||
@@ -156,15 +162,13 @@ jobs:
|
|||||||
restore-keys: |
|
restore-keys: |
|
||||||
${{ runner.os }}-bun-
|
${{ runner.os }}-bun-
|
||||||
|
|
||||||
|
# The tag is the single source of truth for a release version; the script
|
||||||
|
# stamps every file that carries it (package.json, tauri.conf.json,
|
||||||
|
# Cargo.toml, Cargo.lock). This step used to sed only tauri.conf.json, so
|
||||||
|
# the other three shipped whatever was committed.
|
||||||
- name: Set app version from tag
|
- name: Set app version from tag
|
||||||
run: |
|
run: ./scripts/set-version.sh "${GITHUB_REF#refs/tags/}"
|
||||||
# On a tag build the tag is the single source of truth for the version.
|
if: startsWith(github.ref, 'refs/tags/v')
|
||||||
if echo "$GITHUB_REF" | grep -q '^refs/tags/v'; then
|
|
||||||
VERSION="${GITHUB_REF#refs/tags/v}"
|
|
||||||
echo "Setting version to $VERSION"
|
|
||||||
sed -i "s/\"version\": \"[^\"]*\"/\"version\": \"$VERSION\"/" src-tauri/tauri.conf.json
|
|
||||||
fi
|
|
||||||
grep '"version"' src-tauri/tauri.conf.json
|
|
||||||
|
|
||||||
- name: Build Windows (NSIS installer + exe)
|
- name: Build Windows (NSIS installer + exe)
|
||||||
run: OUTPUT_DIR="$PWD/dist/windows" WIN_BUNDLES=nsis ./scripts/build-windows-cross.sh
|
run: OUTPUT_DIR="$PWD/dist/windows" WIN_BUNDLES=nsis ./scripts/build-windows-cross.sh
|
||||||
@@ -217,48 +221,22 @@ jobs:
|
|||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: bun install
|
run: bun install
|
||||||
|
|
||||||
|
# Stamp before `android init`: it derives its generated project (including
|
||||||
|
# the initial versionCode) from tauri.conf.json.
|
||||||
- name: Set app version from tag
|
- name: Set app version from tag
|
||||||
run: |
|
run: ./scripts/set-version.sh "${GITHUB_REF#refs/tags/}"
|
||||||
# On a tag build, the tag is the single source of truth for the
|
if: startsWith(github.ref, 'refs/tags/v')
|
||||||
# version name. On non-tag runs keep whatever is in tauri.conf.json.
|
|
||||||
if echo "$GITHUB_REF" | grep -q '^refs/tags/v'; then
|
|
||||||
VERSION="${GITHUB_REF#refs/tags/v}"
|
|
||||||
echo "Setting version to $VERSION"
|
|
||||||
sed -i "s/\"version\": \"[^\"]*\"/\"version\": \"$VERSION\"/" src-tauri/tauri.conf.json
|
|
||||||
fi
|
|
||||||
grep '"version"' src-tauri/tauri.conf.json
|
|
||||||
|
|
||||||
- name: Initialize Android project
|
- name: Initialize Android project
|
||||||
run: bun run tauri android init
|
run: bun run tauri android init
|
||||||
|
|
||||||
|
# Re-run after init: tauri.properties only exists now, and its
|
||||||
|
# autogenerated versionCode (0.0.15 -> 15) is both tiny and NOT monotonic
|
||||||
|
# against the 1000 floor already shipped in the field. The script rewrites
|
||||||
|
# it as 1000 + major*10000 + minor*100 + patch. Runs unconditionally so
|
||||||
|
# untagged builds get a sane code too, derived from git describe.
|
||||||
- name: Pin a monotonic Android versionCode
|
- name: Pin a monotonic Android versionCode
|
||||||
run: |
|
run: ./scripts/set-version.sh "${GITHUB_REF#refs/tags/}"
|
||||||
# `tauri android init` autogenerates src-tauri/gen/android/app/tauri.properties
|
|
||||||
# with a versionCode derived from the semver (e.g. 0.0.15 -> 15). That
|
|
||||||
# number is (a) tiny and (b) NOT monotonic across our history: earlier
|
|
||||||
# local/dev builds shipped versionCode 1000 (from a 0.1.0 config), so a
|
|
||||||
# plain 15 would be a *downgrade* and Android would refuse the update.
|
|
||||||
#
|
|
||||||
# Derive an explicit code that is both monotonic in semver order and
|
|
||||||
# always above the 1000 floor already in the field:
|
|
||||||
# code = 1000 + major*10000 + minor*100 + patch
|
|
||||||
# e.g. 0.0.14 -> 1014, 0.0.15 -> 1015, 0.1.0 -> 1100, 1.0.0 -> 11000.
|
|
||||||
# POSIX sh only (the runner uses dash): no here-strings, no \s in sed.
|
|
||||||
PROPS="src-tauri/gen/android/app/tauri.properties"
|
|
||||||
VERSION=$(grep '"version"' src-tauri/tauri.conf.json | head -1 | sed -E 's/.*"version"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/')
|
|
||||||
MAJ=$(echo "$VERSION" | cut -d. -f1)
|
|
||||||
MIN=$(echo "$VERSION" | cut -d. -f2)
|
|
||||||
PAT=$(echo "$VERSION" | cut -d. -f3)
|
|
||||||
# Guard against a malformed/missing component so we never emit code 0.
|
|
||||||
: "${MAJ:=0}" "${MIN:=0}" "${PAT:=0}"
|
|
||||||
CODE=$(( 1000 + MAJ*10000 + MIN*100 + PAT ))
|
|
||||||
echo "version=$VERSION -> versionCode=$CODE"
|
|
||||||
if grep -q '^tauri.android.versionCode=' "$PROPS"; then
|
|
||||||
sed -i "s/^tauri.android.versionCode=.*/tauri.android.versionCode=$CODE/" "$PROPS"
|
|
||||||
else
|
|
||||||
echo "tauri.android.versionCode=$CODE" >> "$PROPS"
|
|
||||||
fi
|
|
||||||
cat "$PROPS"
|
|
||||||
|
|
||||||
- name: Sync custom Android sources & gradle config
|
- name: Sync custom Android sources & gradle config
|
||||||
run: ./scripts/sync-android-sources.sh
|
run: ./scripts/sync-android-sources.sh
|
||||||
|
|||||||
@@ -6,6 +6,58 @@ Entries are grouped by the capability they change, not by commit. Requirement
|
|||||||
IDs in parentheses point at [docs/requirements.md](docs/requirements.md); the
|
IDs in parentheses point at [docs/requirements.md](docs/requirements.md); the
|
||||||
generated trace matrix lives in [docs/traceability.md](docs/traceability.md).
|
generated trace matrix lives in [docs/traceability.md](docs/traceability.md).
|
||||||
|
|
||||||
|
## v0.5.0
|
||||||
|
|
||||||
|
### ✨ Features
|
||||||
|
|
||||||
|
- **Android video can render on the device's own video surface.** Settings →
|
||||||
|
Video Playback → **Native Video** (experimental, off by default) hands
|
||||||
|
decoding to ExoPlayer, which draws into a surface composited *behind* a
|
||||||
|
transparent WebView, with the player controls layered on top of it.
|
||||||
|
|
||||||
|
The backend had reported "this platform has a native video surface" on Android
|
||||||
|
all along, but the frontend threw that answer away in two separate places, so
|
||||||
|
the path had never actually run. Both are lifted. The setting can only ever
|
||||||
|
*suppress* the backend's choice, never override it upward: turning it off
|
||||||
|
forces the web player even where native is available, and turning it on does
|
||||||
|
nothing on platforms whose backend never offered it — Linux cannot composite
|
||||||
|
behind its webview, so it stays on the web player either way.
|
||||||
|
|
||||||
|
Verified playing on a physical device. Still unverified: the mini-player
|
||||||
|
transition, audio-track switching on the native path, and whether hardware
|
||||||
|
decoding measurably improves battery or CPU — so the toggle stays off by
|
||||||
|
default. (UR-003, UR-004 → DR-150)
|
||||||
|
|
||||||
|
### 🐛 Fixes
|
||||||
|
|
||||||
|
- **The video surface now reaches the screen at all.** The player built its
|
||||||
|
video surface, handed it to ExoPlayer, and then never added it to the view
|
||||||
|
hierarchy, because the Activity reference it needed was never supplied — so
|
||||||
|
native video would have decoded to a surface nobody could see, whatever else
|
||||||
|
was fixed. This also silently disabled picture-in-picture for video, which
|
||||||
|
gated on that same never-attached surface. (UR-003, UR-041 → DR-151)
|
||||||
|
|
||||||
|
- **Platform playback support is no longer guessed from the browser user
|
||||||
|
agent.** The frontend re-derived "does this platform decode audio natively" by
|
||||||
|
string-matching `navigator.userAgent` — a second copy of a decision the
|
||||||
|
backend already makes, free to drift out of step with the backends it was
|
||||||
|
describing. The backend now reports its own capabilities and the frontend
|
||||||
|
consumes them. (UR-003, UR-005 → DR-152)
|
||||||
|
|
||||||
|
### 🔧 Internal
|
||||||
|
|
||||||
|
- **The git tag is now the single source of truth for a release version.** The
|
||||||
|
version lived in four files that had to be edited in lockstep, and the release
|
||||||
|
workflow rewrote exactly one of them — so a tagged build produced an installer
|
||||||
|
named for the tag wrapped around package metadata naming the *previous*
|
||||||
|
release, and the Linux job, which had no version step at all, shipped whatever
|
||||||
|
happened to be committed. `scripts/set-version.sh` now writes all four from
|
||||||
|
one argument and every release job calls it with the tag. The Android
|
||||||
|
`versionCode` is derived in the same place, guarded by tests for the property
|
||||||
|
that actually matters: it must increase monotonically and stay above the value
|
||||||
|
already installed in the field, or Android silently refuses the update.
|
||||||
|
(DR-153)
|
||||||
|
|
||||||
## v0.4.8
|
## v0.4.8
|
||||||
|
|
||||||
### 🐛 Fixes
|
### 🐛 Fixes
|
||||||
|
|||||||
@@ -315,6 +315,7 @@ Internal architecture, components, and application logic.
|
|||||||
| DR-150 | Android video renders on the native ExoPlayer surface behind a transparent WebView, behind the `experimentalNativeVideo` opt-in. Rust already reported `use_html5_element: false` on Android, but two frontend overrides discarded it — `createAdapter()` hardcoded `"html5"`, and `VideoPlayer.svelte` forced `useHtml5Element = true` and stopped the native backend `player_play_item` had just started. The flag is a **suppressor, never a promoter**: off forces HTML5 even where Rust says native, so an in-progress spike cannot ship as the default, but it can never select native where Rust reported HTML5 (Linux cannot composite behind WebKitGTK, so promoting there is a black screen). Compositing requires clearing two independent opaque layers, and clearing only one leaves audio over a black picture — the WebView widget background and window drawable from Kotlin (`AndroidVideoSurface.setTransparent`), and the page's `html`/`body` and app-shell background from CSS (`data-native-video`). Transparency is declared in `tauri.android.conf.json` rather than the base config, because a transparent window on Linux has nothing behind it, and is toggled per playback session rather than set once, because a permanently transparent window shows the launcher through the rest of the app | Playback | UR-003, UR-004 | Done (behind `experimentalNativeVideo`, default off) |
|
| DR-150 | Android video renders on the native ExoPlayer surface behind a transparent WebView, behind the `experimentalNativeVideo` opt-in. Rust already reported `use_html5_element: false` on Android, but two frontend overrides discarded it — `createAdapter()` hardcoded `"html5"`, and `VideoPlayer.svelte` forced `useHtml5Element = true` and stopped the native backend `player_play_item` had just started. The flag is a **suppressor, never a promoter**: off forces HTML5 even where Rust says native, so an in-progress spike cannot ship as the default, but it can never select native where Rust reported HTML5 (Linux cannot composite behind WebKitGTK, so promoting there is a black screen). Compositing requires clearing two independent opaque layers, and clearing only one leaves audio over a black picture — the WebView widget background and window drawable from Kotlin (`AndroidVideoSurface.setTransparent`), and the page's `html`/`body` and app-shell background from CSS (`data-native-video`). Transparency is declared in `tauri.android.conf.json` rather than the base config, because a transparent window on Linux has nothing behind it, and is toggled per playback session rather than set once, because a permanently transparent window shows the launcher through the rest of the app | Playback | UR-003, UR-004 | Done (behind `experimentalNativeVideo`, default off) |
|
||||||
| DR-151 | The player's video SurfaceView actually reaches the view hierarchy. `JellyTauPlayer.setActivity()` had zero callers, so `currentActivity` was always null and `autoAttachSurface()` returned at "Cannot attach surface - no Activity reference". The surface was created and handed to ExoPlayer but never added to the content view, so native video decoded to a surface that was never on screen — independent of any webview transparency. `MainActivity.onCreate` now supplies the reference, which also revives PiP on the video path: `canEnterPip()` gates on `isVideoSurfaceAttached()`, which had been permanently false | Playback | UR-003, UR-041 | Done |
|
| DR-151 | The player's video SurfaceView actually reaches the view hierarchy. `JellyTauPlayer.setActivity()` had zero callers, so `currentActivity` was always null and `autoAttachSurface()` returned at "Cannot attach surface - no Activity reference". The surface was created and handed to ExoPlayer but never added to the content view, so native video decoded to a surface that was never on screen — independent of any webview transparency. `MainActivity.onCreate` now supplies the reference, which also revives PiP on the video path: `canEnterPip()` gates on `isVideoSurfaceAttached()`, which had been permanently false | Playback | UR-003, UR-041 | Done |
|
||||||
| DR-152 | Platform playback facilities are reported by Rust, not sniffed from the user agent. `webviewAudio.ts` re-derived "does this platform have a native audio backend" by matching `navigator.userAgent` against `android`/`linux` — a second copy of the `cfg!` gate the backends are compiled under, free to drift from it. `player_get_capabilities` now returns `usesWebviewAudio` and `supportsNativeVideo` from the same cfg gates, and the frontend consumes them; the settings toggle for native video is hidden entirely where the platform cannot support it | Player | UR-003, UR-005 | Done |
|
| DR-152 | Platform playback facilities are reported by Rust, not sniffed from the user agent. `webviewAudio.ts` re-derived "does this platform have a native audio backend" by matching `navigator.userAgent` against `android`/`linux` — a second copy of the `cfg!` gate the backends are compiled under, free to drift from it. `player_get_capabilities` now returns `usesWebviewAudio` and `supportsNativeVideo` from the same cfg gates, and the frontend consumes them; the settings toggle for native video is hidden entirely where the platform cannot support it | Player | UR-003, UR-005 | Done |
|
||||||
|
| DR-153 | The git tag is the single source of truth for a release version. The version lived in four files (`package.json`, `tauri.conf.json`, `Cargo.toml`, `Cargo.lock`) that had to be hand-edited in lockstep, and CI's release job rewrote exactly one of them — so a tagged build produced an installer named for the tag wrapped around package metadata naming the previous release, while the Linux job had no version step at all and shipped whatever was committed. `scripts/set-version.sh` writes all four from one argument and is the only thing that does; every release job calls it with the tag. The Android `versionCode` is derived in the same place as `1000 + major*10000 + minor*100 + patch`, which is monotonic in semver order and clears the 1000 floor already installed in the field — a lower code than the installed one makes Android refuse the update. A prerelease suffix is stripped before that arithmetic, which would otherwise abort the script, and a non-tag ref (CI passes `${GITHUB_REF#refs/tags/}` unconditionally) falls back to `git describe` rather than failing a branch build | Build | - | Done |
|
||||||
| DR-143 | Flipping the offline downloaded-only gate actually re-queries the listing. The gate (DR-078) is a process-wide flag in Rust consulted only *while a query runs*, but no library surface re-queried when its inputs changed: `useServerReachabilityReload` fires only on the offline → **online** transition, and `GenericMediaListPage`, `GenericGenreBrowser` and the favourites page never even called its `checkServerReachability`. So going offline left the full server catalog on screen under a now-closed gate, and toggling "Show all server media" only greyed cards — `MediaCard.isServerOnly` is a pure frontend derivation that updates instantly — without adding or removing a single row. The filter therefore read as "shows everything until I filter, then greys some of it" while the backend gate was correct and simply never exercised. `catalogFilterVersion` is the refetch signal: `pushCatalogVisibility` now awaits `set_show_server_catalog` and bumps the version only **after** the backend accepts the new flag, since a reload racing the push would re-query under the old gate and undo itself. A failed push clears `lastIncludeCatalog` instead of latching it, so the next identical transition is retried rather than skipped as a no-op and left permanently disagreeing with the backend. `useOfflineFilterReload` subscribes pages to that signal, skipping the value they already loaded under; it is wired into both generic list components and the movies/music/tv/favourites landing pages and the `/library/[id]` detail page | UI | UR-052 | Done |
|
| DR-143 | Flipping the offline downloaded-only gate actually re-queries the listing. The gate (DR-078) is a process-wide flag in Rust consulted only *while a query runs*, but no library surface re-queried when its inputs changed: `useServerReachabilityReload` fires only on the offline → **online** transition, and `GenericMediaListPage`, `GenericGenreBrowser` and the favourites page never even called its `checkServerReachability`. So going offline left the full server catalog on screen under a now-closed gate, and toggling "Show all server media" only greyed cards — `MediaCard.isServerOnly` is a pure frontend derivation that updates instantly — without adding or removing a single row. The filter therefore read as "shows everything until I filter, then greys some of it" while the backend gate was correct and simply never exercised. `catalogFilterVersion` is the refetch signal: `pushCatalogVisibility` now awaits `set_show_server_catalog` and bumps the version only **after** the backend accepts the new flag, since a reload racing the push would re-query under the old gate and undo itself. A failed push clears `lastIncludeCatalog` instead of latching it, so the next identical transition is retried rather than skipped as a no-op and left permanently disagreeing with the backend. `useOfflineFilterReload` subscribes pages to that signal, skipping the value they already loaded under; it is wired into both generic list components and the movies/music/tv/favourites landing pages and the `/library/[id]` detail page | UI | UR-052 | Done |
|
||||||
| DR-135 | A download's media type comes from the item, not a default. `download_item` — the path a media card uses to queue an item while offline — never records `media_type`, and the reconnect resolver read that NULL as `'audio'`, so a **movie** queued from a card had its URL resolved by `get_audio_stream_url`. The file that landed on disk was an audio-only transcode, which is why a "downloaded" film could never play offline no matter how the path or protocol was fixed. The resolver now falls back to the item's own `item_type` (`VIDEO_ITEM_TYPES` in Rust, so the frontend never learns which types are video) and only defaults to audio when the item is not cached locally. An explicit `media_type` on the row still wins | Downloads | UR-071, UR-052 | Done |
|
| DR-135 | A download's media type comes from the item, not a default. `download_item` — the path a media card uses to queue an item while offline — never records `media_type`, and the reconnect resolver read that NULL as `'audio'`, so a **movie** queued from a card had its URL resolved by `get_audio_stream_url`. The file that landed on disk was an audio-only transcode, which is why a "downloaded" film could never play offline no matter how the path or protocol was fixed. The resolver now falls back to the item's own `item_type` (`VIDEO_ITEM_TYPES` in Rust, so the frontend never learns which types are video) and only defaults to audio when the item is not cached locally. An explicit `media_type` on the row still wins | Downloads | UR-071, UR-052 | Done |
|
||||||
| DR-136 | Rows already downloaded under the audio default are repaired, not just prevented. They are identifiable after the fact — no `media_type`, but a video item — so on reconnect they are reset to `pending` with their audio URL cleared and re-resolved by DR-135's corrected logic, overwriting the audio file in place. Without this the fix is invisible to anyone who had already queued a film: the row still reads "downloaded" and still fails to play. Rows carrying an explicit `media_type` and genuine audio downloads are left untouched | Downloads | UR-071 | Done |
|
| DR-136 | Rows already downloaded under the audio default are repaired, not just prevented. They are identifiable after the fact — no `media_type`, but a video item — so on reconnect they are reset to `pending` with their audio URL cleared and re-resolved by DR-135's corrected logic, overwriting the audio file in place. Without this the fix is invisible to anyone who had already queued a film: the row still reads "downloaded" and still fails to play. Rows carrying an explicit `media_type` and genuine audio downloads are left untouched | Downloads | UR-071 | Done |
|
||||||
@@ -547,6 +548,7 @@ Internal architecture, components, and application logic.
|
|||||||
| UT-141 | The advertised channel cap: an unknown or zero reading falls back to stereo, a real route keeps its channels, an absurd driver reading is capped at 7.1, and mono is taken at its word | DR-141 | Done |
|
| UT-141 | The advertised channel cap: an unknown or zero reading falls back to stereo, a real route keeps its channels, an absurd driver reading is capped at 7.1, and mono is taken at its word | DR-141 | Done |
|
||||||
| UT-148 | Forcing a transcode from the client: an undecodable default track forces one, a decodable track does not, the default track decides rather than the first, the first decides when nothing is marked default, and neither an audio-less source nor an unnamed codec is second-guessed | DR-149 | Done |
|
| UT-148 | Forcing a transcode from the client: an undecodable default track forces one, a decodable track does not, the default track decides rather than the first, the first decides when nothing is marked default, and neither an audio-less source nor an unnamed codec is second-guessed | DR-149 | Done |
|
||||||
| UT-149 | `createAdapter` returns the native adapter only when Rust reports native AND `experimentalNativeVideo` is on; the flag off forces HTML5 even when Rust says native, and the flag on never promotes a platform Rust reported as HTML5 | DR-150 | Done |
|
| UT-149 | `createAdapter` returns the native adapter only when Rust reports native AND `experimentalNativeVideo` is on; the flag off forces HTML5 even when Rust says native, and the flag on never promotes a platform Rust reported as HTML5 | DR-150 | Done |
|
||||||
|
| UT-150 | `set-version.sh` stamps all four manifests without touching dependency versions, and the Android versionCode is monotonic across an upgrade sequence, clears the 1000 floor, and survives a prerelease suffix | DR-153 | Done |
|
||||||
| UT-142 | The audio codecs offered for video direct play: a Dolby device's real `MediaCodecList` output drops `ac3`/`eac3`, AMR and raw PCM are dropped too, a fully-supported list is passed through untouched, a list with nothing decodable still claims `aac`, and stray spacing or casing does not decide whether the user gets sound | DR-148 | Done |
|
| UT-142 | The audio codecs offered for video direct play: a Dolby device's real `MediaCodecList` output drops `ac3`/`eac3`, AMR and raw PCM are dropped too, a fully-supported list is passed through untouched, a list with nothing decodable still claims `aac`, and stray spacing or casing does not decide whether the user gets sound | DR-148 | Done |
|
||||||
| UT-143 | Subtitle URLs resolve to plain strings before they reach the markup (never a Promise), unresolvable tracks are dropped, a stale selection collapses to "Off", and a server-default track is never auto-selected | UR-020, DR-023 | Done |
|
| UT-143 | Subtitle URLs resolve to plain strings before they reach the markup (never a Promise), unresolvable tracks are dropped, a stale selection collapses to "Off", and a server-default track is never auto-selected | UR-020, DR-023 | Done |
|
||||||
| UT-144 | VideoPlayer actually renders `<track kind="subtitles">` children carrying `data-stream-index`, with no `default` attribute and no async `getSubtitleUrl()` bound to `src` | UR-020, DR-023 | Done |
|
| UT-144 | VideoPlayer actually renders `<track kind="subtitles">` children carrying `data-stream-index`, with no `default` attribute and no async `getSubtitleUrl()` bound to `src` | UR-020, DR-023 | Done |
|
||||||
|
|||||||
+560
-544
File diff suppressed because it is too large
Load Diff
@@ -175,8 +175,8 @@ describe("live requirements.md", () => {
|
|||||||
|
|
||||||
expect(defined.UR).toBe(71);
|
expect(defined.UR).toBe(71);
|
||||||
expect(defined.IR).toBe(32);
|
expect(defined.IR).toBe(32);
|
||||||
expect(defined.DR).toBe(147);
|
expect(defined.DR).toBe(148);
|
||||||
expect(defined.JA).toBe(35);
|
expect(defined.JA).toBe(35);
|
||||||
expect(defined.total).toBe(285);
|
expect(defined.total).toBe(286);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Executable
+111
@@ -0,0 +1,111 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Stamp the release version into every file that carries it.
|
||||||
|
#
|
||||||
|
# The git tag is the single source of truth for a release version. The versions
|
||||||
|
# committed in package.json / tauri.conf.json / Cargo.toml are a placeholder for
|
||||||
|
# dev builds; a tagged build overwrites all of them from the tag so they cannot
|
||||||
|
# disagree with each other or with the tag.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./scripts/set-version.sh 0.5.0 # explicit
|
||||||
|
# JELLYTAU_VERSION=0.5.0 ./scripts/set-version.sh
|
||||||
|
# ./scripts/set-version.sh # derive from git describe (dev builds)
|
||||||
|
#
|
||||||
|
# Accepts the version with or without a leading "v".
|
||||||
|
#
|
||||||
|
# Why a script and not four sed lines in CI: the version lived in four files and
|
||||||
|
# CI only ever rewrote one of them (tauri.conf.json), so a tagged release shipped
|
||||||
|
# a matching installer name and mismatched package metadata. Keeping the write in
|
||||||
|
# one place is what makes "the tag is authoritative" actually true.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
VERSION="${1:-${JELLYTAU_VERSION:-}}"
|
||||||
|
|
||||||
|
# CI passes "${GITHUB_REF#refs/tags/}" unconditionally, which on an untagged
|
||||||
|
# build is still a full ref ("refs/heads/master"). Treat anything that is not a
|
||||||
|
# bare version as "no version given" and fall through to git describe, so a
|
||||||
|
# branch build gets a sane dev version instead of failing the job.
|
||||||
|
case "$VERSION" in
|
||||||
|
refs/*) VERSION="" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
if [ -z "$VERSION" ]; then
|
||||||
|
# No explicit version: derive from the most recent tag. Dev builds land on
|
||||||
|
# something like 0.5.0 (exact tag) or 0.5.0-3-gabc1234 (ahead of the tag).
|
||||||
|
VERSION="$(git describe --tags --always --match 'v*' 2>/dev/null || echo "0.0.0")"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Tags are written v0.5.0; the files carry a bare semver.
|
||||||
|
VERSION="${VERSION#v}"
|
||||||
|
|
||||||
|
# Validate before writing anything — a malformed version silently propagated
|
||||||
|
# into four files is far worse than a failed script.
|
||||||
|
if ! echo "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+([-+][0-9A-Za-z.-]+)?$'; then
|
||||||
|
echo "❌ Not a valid semver: '$VERSION'" >&2
|
||||||
|
echo " Expected MAJOR.MINOR.PATCH with an optional -prerelease/+build suffix." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "📌 Setting version to $VERSION"
|
||||||
|
|
||||||
|
# --- The three committed manifests -----------------------------------------
|
||||||
|
# Anchored to the first "version" key so a dependency's version is never hit.
|
||||||
|
|
||||||
|
# package.json — the top-level "version", which sits in the first few lines.
|
||||||
|
perl -0pi -e 's/("version"\s*:\s*)"[^"]*"/$1"'"$VERSION"'"/' package.json
|
||||||
|
|
||||||
|
# tauri.conf.json — likewise; this is the one the bundler reads for installer
|
||||||
|
# names, and the one CI used to patch alone.
|
||||||
|
perl -0pi -e 's/("version"\s*:\s*)"[^"]*"/$1"'"$VERSION"'"/' src-tauri/tauri.conf.json
|
||||||
|
|
||||||
|
# Cargo.toml — only the [package] version, never a dependency's. Restricted to
|
||||||
|
# the first occurrence of a line-anchored `version = "..."`.
|
||||||
|
perl -0pi -e 's/^(version\s*=\s*)"[^"]*"/$1"'"$VERSION"'"/m' src-tauri/Cargo.toml
|
||||||
|
|
||||||
|
# Cargo.lock — the jellytau entry. Left alone if the lock has not been generated
|
||||||
|
# yet; the next cargo invocation writes it. Cargo would otherwise rewrite the
|
||||||
|
# lock mid-build and dirty the tree.
|
||||||
|
if [ -f src-tauri/Cargo.lock ]; then
|
||||||
|
perl -0pi -e 's/(name = "jellytau"\nversion = )"[^"]*"/$1"'"$VERSION"'"/' src-tauri/Cargo.lock
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- Android versionCode ----------------------------------------------------
|
||||||
|
# Only when the generated Android project exists (i.e. after `tauri android
|
||||||
|
# init`); on Linux/Windows jobs there is nothing to stamp.
|
||||||
|
#
|
||||||
|
# `tauri android init` derives a versionCode from the semver (0.0.15 -> 15).
|
||||||
|
# That is both tiny and NOT monotonic across our history: earlier local/dev
|
||||||
|
# builds shipped versionCode 1000 (from a 0.1.0 config), so a plain 15 is a
|
||||||
|
# *downgrade* and Android refuses the update.
|
||||||
|
#
|
||||||
|
# code = 1000 + major*10000 + minor*100 + patch
|
||||||
|
# e.g. 0.0.14 -> 1014, 0.0.15 -> 1015, 0.1.0 -> 1100, 1.0.0 -> 11000.
|
||||||
|
PROPS="src-tauri/gen/android/app/tauri.properties"
|
||||||
|
if [ -f "$PROPS" ]; then
|
||||||
|
# Strip any -rc1/+build suffix first: it is not numeric, and feeding it to
|
||||||
|
# $(( )) would abort the script under `set -e`.
|
||||||
|
CORE="${VERSION%%-*}"
|
||||||
|
CORE="${CORE%%+*}"
|
||||||
|
MAJ=$(echo "$CORE" | cut -d. -f1)
|
||||||
|
MIN=$(echo "$CORE" | cut -d. -f2)
|
||||||
|
PAT=$(echo "$CORE" | cut -d. -f3)
|
||||||
|
: "${MAJ:=0}" "${MIN:=0}" "${PAT:=0}"
|
||||||
|
CODE=$(( 1000 + MAJ*10000 + MIN*100 + PAT ))
|
||||||
|
echo " versionCode=$CODE (from $CORE)"
|
||||||
|
if grep -q '^tauri.android.versionCode=' "$PROPS"; then
|
||||||
|
sed -i "s/^tauri.android.versionCode=.*/tauri.android.versionCode=$CODE/" "$PROPS"
|
||||||
|
else
|
||||||
|
echo "tauri.android.versionCode=$CODE" >> "$PROPS"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- Report -----------------------------------------------------------------
|
||||||
|
echo "✅ Version stamped:"
|
||||||
|
grep -m1 '"version"' package.json | sed 's/^/ package.json: /'
|
||||||
|
grep -m1 '"version"' src-tauri/tauri.conf.json | sed 's/^/ tauri.conf.json: /'
|
||||||
|
grep -m1 '^version' src-tauri/Cargo.toml | sed 's/^/ Cargo.toml: /'
|
||||||
|
[ -f "$PROPS" ] && grep '^tauri.android.versionCode=' "$PROPS" | sed 's/^/ tauri.properties: /'
|
||||||
|
exit 0
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
/**
|
||||||
|
* Guards for scripts/set-version.sh — the release version stamper.
|
||||||
|
*
|
||||||
|
* TRACES: DR-153 | UT-150
|
||||||
|
*
|
||||||
|
* These run the real script against a throwaway copy of the manifests, because
|
||||||
|
* the failure modes are all in the shell, not in any TS logic: a regex that also
|
||||||
|
* matches a dependency's version, arithmetic that aborts on a `-rc1` suffix, or
|
||||||
|
* a CI ref reaching the validator verbatim.
|
||||||
|
*
|
||||||
|
* The versionCode formula matters most. Android refuses an update whose code is
|
||||||
|
* lower than the installed one, and builds already in the field shipped code
|
||||||
|
* 1000 — so any formula that can emit a smaller number for a *newer* release
|
||||||
|
* bricks updates for those users, silently and irreversibly.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, expect, it, beforeEach, afterEach } from "vitest";
|
||||||
|
import { execFileSync } from "child_process";
|
||||||
|
import * as fs from "fs";
|
||||||
|
import * as path from "path";
|
||||||
|
import * as os from "os";
|
||||||
|
|
||||||
|
const repoRoot = path.resolve(path.dirname(new URL(import.meta.url).pathname), "..");
|
||||||
|
const script = path.join(repoRoot, "scripts", "set-version.sh");
|
||||||
|
|
||||||
|
let tmp: string;
|
||||||
|
|
||||||
|
/** A minimal repo skeleton: just the files the script rewrites. */
|
||||||
|
function seed(dir: string) {
|
||||||
|
fs.mkdirSync(path.join(dir, "src-tauri", "gen", "android", "app"), { recursive: true });
|
||||||
|
fs.mkdirSync(path.join(dir, "scripts"), { recursive: true });
|
||||||
|
fs.copyFileSync(script, path.join(dir, "scripts", "set-version.sh"));
|
||||||
|
fs.chmodSync(path.join(dir, "scripts", "set-version.sh"), 0o755);
|
||||||
|
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(dir, "package.json"),
|
||||||
|
JSON.stringify({ name: "jellytau", version: "0.0.1", dependencies: { hls: "1.2.3" } }, null, 2)
|
||||||
|
);
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(dir, "src-tauri", "tauri.conf.json"),
|
||||||
|
JSON.stringify({ productName: "jellytau", version: "0.0.1" }, null, 2)
|
||||||
|
);
|
||||||
|
// A dependency carrying its own `version =` is the trap: a greedy regex
|
||||||
|
// rewrites it too and the build then resolves the wrong crate.
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(dir, "src-tauri", "Cargo.toml"),
|
||||||
|
['[package]', 'name = "jellytau"', 'version = "0.0.1"', '', '[dependencies]', 'serde = { version = "1.0.100" }', ''].join("\n")
|
||||||
|
);
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(dir, "src-tauri", "Cargo.lock"),
|
||||||
|
['[[package]]', 'name = "serde"', 'version = "1.0.100"', '', '[[package]]', 'name = "jellytau"', 'version = "0.0.1"', ''].join("\n")
|
||||||
|
);
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(dir, "src-tauri", "gen", "android", "app", "tauri.properties"),
|
||||||
|
"tauri.android.versionCode=1\n"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function run(version: string, dir = tmp) {
|
||||||
|
return execFileSync("bash", [path.join(dir, "scripts", "set-version.sh"), version], {
|
||||||
|
cwd: dir,
|
||||||
|
encoding: "utf-8",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function read(rel: string): string {
|
||||||
|
return fs.readFileSync(path.join(tmp, rel), "utf-8");
|
||||||
|
}
|
||||||
|
|
||||||
|
function versionCode(): number {
|
||||||
|
const m = read("src-tauri/gen/android/app/tauri.properties").match(
|
||||||
|
/^tauri\.android\.versionCode=(\d+)$/m
|
||||||
|
);
|
||||||
|
return m ? Number(m[1]) : NaN;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tmp = fs.mkdtempSync(path.join(os.tmpdir(), "setversion-"));
|
||||||
|
seed(tmp);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
fs.rmSync(tmp, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("set-version.sh", () => {
|
||||||
|
it("stamps the version into all four manifests", () => {
|
||||||
|
run("0.5.0");
|
||||||
|
expect(JSON.parse(read("package.json")).version).toBe("0.5.0");
|
||||||
|
expect(JSON.parse(read("src-tauri/tauri.conf.json")).version).toBe("0.5.0");
|
||||||
|
expect(read("src-tauri/Cargo.toml")).toContain('version = "0.5.0"');
|
||||||
|
expect(read("src-tauri/Cargo.lock")).toMatch(/name = "jellytau"\nversion = "0\.5\.0"/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts a leading v, as git tags are written", () => {
|
||||||
|
run("v0.5.0");
|
||||||
|
expect(JSON.parse(read("package.json")).version).toBe("0.5.0");
|
||||||
|
});
|
||||||
|
|
||||||
|
// The regression that motivates anchoring the patterns.
|
||||||
|
it("does not rewrite dependency versions", () => {
|
||||||
|
run("0.5.0");
|
||||||
|
expect(read("src-tauri/Cargo.toml")).toContain('serde = { version = "1.0.100" }');
|
||||||
|
expect(read("src-tauri/Cargo.lock")).toMatch(/name = "serde"\nversion = "1\.0\.100"/);
|
||||||
|
expect(JSON.parse(read("package.json")).dependencies.hls).toBe("1.2.3");
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Android versionCode", () => {
|
||||||
|
// Codes below 1000 are already in the field; a newer release must never
|
||||||
|
// produce a smaller number than an older one.
|
||||||
|
it("clears the 1000 floor shipped by earlier builds", () => {
|
||||||
|
run("0.0.1");
|
||||||
|
expect(versionCode()).toBeGreaterThan(1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses 1000 + major*10000 + minor*100 + patch", () => {
|
||||||
|
const cases: Array<[string, number]> = [
|
||||||
|
["0.0.14", 1014],
|
||||||
|
["0.0.15", 1015],
|
||||||
|
["0.1.0", 1100],
|
||||||
|
["0.4.8", 1408],
|
||||||
|
["0.5.0", 1500],
|
||||||
|
["1.0.0", 11000],
|
||||||
|
];
|
||||||
|
for (const [version, code] of cases) {
|
||||||
|
seed(tmp);
|
||||||
|
run(version);
|
||||||
|
expect(versionCode(), `versionCode for ${version}`).toBe(code);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("increases monotonically across an upgrade sequence", () => {
|
||||||
|
const ordered = ["0.0.14", "0.0.15", "0.1.0", "0.4.8", "0.5.0", "1.0.0"];
|
||||||
|
const codes = ordered.map((v) => {
|
||||||
|
seed(tmp);
|
||||||
|
run(v);
|
||||||
|
return versionCode();
|
||||||
|
});
|
||||||
|
const sorted = [...codes].sort((a, b) => a - b);
|
||||||
|
expect(codes).toEqual(sorted);
|
||||||
|
expect(new Set(codes).size).toBe(codes.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
// `$(( 0-rc1 ))` aborts the script under `set -e`, so the suffix has to be
|
||||||
|
// stripped before the arithmetic.
|
||||||
|
it("derives the code from the numeric core of a prerelease", () => {
|
||||||
|
run("0.6.0-rc1");
|
||||||
|
expect(versionCode()).toBe(1600);
|
||||||
|
expect(JSON.parse(read("package.json")).version).toBe("0.6.0-rc1");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("input validation", () => {
|
||||||
|
it("rejects a malformed version without writing anything", () => {
|
||||||
|
expect(() => run("not-a-version")).toThrow();
|
||||||
|
// The manifests must be untouched, not half-written.
|
||||||
|
expect(JSON.parse(read("package.json")).version).toBe("0.0.1");
|
||||||
|
expect(JSON.parse(read("src-tauri/tauri.conf.json")).version).toBe("0.0.1");
|
||||||
|
});
|
||||||
|
|
||||||
|
// CI passes "${GITHUB_REF#refs/tags/}" unconditionally; on a branch build
|
||||||
|
// that is still a full ref, and must not fail the job.
|
||||||
|
it("falls back to a dev version when handed a non-tag ref", () => {
|
||||||
|
const out = run("refs/heads/master");
|
||||||
|
expect(out).not.toMatch(/refs\/heads/);
|
||||||
|
expect(JSON.parse(read("package.json")).version).not.toBe("0.0.1");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user