tauri-specta propagates Rust doc comments into bindings.ts as JSDoc, so adding TRACES comments to command functions changes generated output. Regeneration happens at build time, so this was left dirty by the branch that added them. Doc-comment-only: no signature or exported-symbol changes. Also records the audit corrections made during device verification (B1 mechanism, B7 re-framing, B8, D3 magnitude).
505 lines
26 KiB
Markdown
505 lines
26 KiB
Markdown
# JellyTau Codebase Audit
|
||
|
||
**Date:** 2026-08-16 · **Version:** v0.6.0 · **Commit:** `be907b49` (master)
|
||
|
||
A review of the Rust/Svelte/Android codebase against its own requirements matrix
|
||
and against current Android and Tauri v2 platform practice. Every finding was
|
||
verified by running the project's own tooling or reading the code it points at —
|
||
nothing here is inferred from documentation alone.
|
||
|
||
**Scale:** 55,835 LOC Rust · 50,490 LOC TS/Svelte · 530 requirements · 824 traces
|
||
|
||
| Severity | Count |
|
||
|----------|-------|
|
||
| High | 5 |
|
||
| Medium | 9 |
|
||
| Low | 6 |
|
||
| Tests passing | 1,719 |
|
||
| Untraced requirements | 86 |
|
||
| Traceability coverage | 86% (285/330) |
|
||
|
||
> **Revisions, 2026-08-16.** Three rankings changed after device testing and
|
||
> platform research, all documented in place:
|
||
> - **B1 High → Low.** The predicted impact was refuted on a physical Android 16
|
||
> device. The residual risk turned out to be a different, narrower one.
|
||
> - **B7 Low → Medium, re-framed.** The original reading of predictive back was
|
||
> backwards: at targetSdk 36 it is already enabled, not merely un-opted-into.
|
||
> - **B8 added (Medium).** Android 16 Local Network Protections versus a
|
||
> LAN-hosted Jellyfin server.
|
||
> - **D3 Medium → Low.** The "820 unwraps" figure was a measurement error; the
|
||
> real number is 19, and none are in command handlers.
|
||
> - **B1's stated mechanism was wrong** even though its conclusion held. FGS
|
||
> notifications are *not* exempt from `POST_NOTIFICATIONS`; media-session
|
||
> notifications are. See B1 — the distinction changes what the fix should be.
|
||
>
|
||
> Original ranking was 6 High / 8 Medium / 5 Low.
|
||
|
||
**Verified by running:** `bun run check` · `bun run test` · `cargo test` ·
|
||
`cargo clippy --all-targets` · `bun run check:boundary` · `bun run traces:json`
|
||
|
||
**Device-verified (2026-08-16):** B1 and B2 were checked against a physical HONOR
|
||
ROD2-W09 running Android 16 (SDK 36) with the shipped app installed. B2 was
|
||
confirmed; B1 was refuted and downgraded.
|
||
|
||
**Not covered:** the e2e suite (`test:e2e` is not wired into CI and was not run),
|
||
Windows and Arch packaging paths, and the docs-site build. B3, C1 and C2 still
|
||
need a device/desktop playback pass.
|
||
|
||
---
|
||
|
||
## A. Requirements versus code
|
||
|
||
The traceability matrix is the project's own claim about what is built. Of 530
|
||
defined requirement IDs, 86 carry no `TRACES:` tag anywhere in the tree. Most of
|
||
those gaps are documentation debt rather than missing features — which is
|
||
precisely the problem, because it makes the matrix unreliable as evidence.
|
||
|
||
### A1 · High · Twelve requirements are marked "Done" but have zero traces
|
||
|
||
`UR-006` (lockscreen/BLE control), `UR-037` (video library presentation),
|
||
`IR-006` (Android MediaSession), `IR-008` (audio focus), `IR-022` (person/cast
|
||
API), `IR-024` (home-screen API) and six Jellyfin API requirements (`JA-006`,
|
||
`JA-009`, `JA-013`, `JA-014`, `JA-015`, `JA-018`) all claim completion with
|
||
nothing pointing at an implementation.
|
||
|
||
These features demonstrably work — lockscreen control, Next Up, favourites are
|
||
all shipped. The code is there; the tags are not. That means the matrix currently
|
||
over-reports on exactly the requirements a reviewer would most want to verify,
|
||
and a regression in any of them would leave no trace to follow.
|
||
|
||
**Fix:** Tag the existing implementations. Highest value per keystroke in the
|
||
whole audit: six of the twelve are single Jellyfin API call sites.
|
||
|
||
### A2 · Medium · Requirement statuses contradict each other across layers
|
||
|
||
`UR-020` (subtitle selection) and `UR-021` (audio track selection) are marked
|
||
*Done*, while the integration requirements they decompose into — `IR-018` and
|
||
`IR-019`, both libmpv-specific — are still *Planned*. Similarly `IR-005` (MPRIS)
|
||
sits at *Planned* under a *Done* `UR-006`.
|
||
|
||
The likely truth is that these user requirements were satisfied through a
|
||
different path than the one originally specified (HTML5 `<video>` and ExoPlayer
|
||
rather than libmpv), and the IRs were never re-scoped. Left as-is, the matrix
|
||
reads as though shipped features depend on unbuilt integrations.
|
||
|
||
**Fix:** Re-scope or retire the stale IRs so each Done UR rests on Done IRs.
|
||
|
||
### A3 · Medium · The traceability gate is set far below actual coverage
|
||
|
||
`traceability-check.yml` fails only below 50%. Real coverage is well above that,
|
||
so the gate cannot catch a coverage regression until roughly half the matrix has
|
||
rotted. A gate that can only fire after a catastrophe is not protecting anything.
|
||
|
||
**Measured coverage: 86% (285/330)** — UR 71/75, IR 19/32, DR 166/187, JA 29/36.
|
||
IR is by far the weakest dimension, which corroborates A1.
|
||
|
||
**Fix applied:** `MIN_THRESHOLD` ratcheted 50 → 82, with the ratchet policy
|
||
written into the workflow (only goes up; never lowered to make a red build pass).
|
||
The same figure is mirrored as `MIN_COVERAGE_PERCENT` in
|
||
`scripts/extract-traces.ts` so local `traces:coverage` gates on the same bar, and
|
||
a test parses the workflow YAML and fails if the two drift apart.
|
||
|
||
### A4 · Low · Two traced IDs do not exist in the requirements document
|
||
|
||
`DR-189` and `UT-188` are referenced by `TRACES:` comments but are defined
|
||
nowhere in `docs/requirements.md`. The extraction tool accepts them silently, so
|
||
typos and renames pass unnoticed.
|
||
|
||
**Fix:** Add a dangling-ID check to the extractor and fail CI on it — cheap, and
|
||
it keeps the matrix honest in both directions.
|
||
|
||
### A5 · Not a gap · The remaining untraced requirements are legitimately unbuilt
|
||
|
||
`UR-016`, `UR-022` and `UR-070` are Planned or Proposed, and `UR-031`
|
||
(crossfade) is explicitly blocked by `DR-034`. Their absence from the trace graph
|
||
is correct and needs no action — noted so it does not get swept into the fix list.
|
||
|
||
---
|
||
|
||
## B. Android platform practice
|
||
|
||
The app targets SDK 36 with a minSdk of 24. Several manifest and WebView settings
|
||
still reflect an earlier target level.
|
||
|
||
### B1 · Low · `POST_NOTIFICATIONS` is declared but never requested at runtime
|
||
|
||
*Downgraded from High. The original ranking was refuted by device testing — the
|
||
evidence is below, and it is the reason this finding is now near-trivial.*
|
||
|
||
The permission appears in the manifest, but there is no `requestPermissions` call
|
||
anywhere in the Kotlin, Rust or TypeScript sources, and
|
||
`JellyTauPlaybackService.startForeground()` runs with no `checkSelfPermission`
|
||
guard. On Android 13+ notification permission defaults to denied.
|
||
|
||
This was ranked High on the theory that it would suppress the media notification
|
||
and with it the lockscreen transport controls (`UR-006`). Testing on an HONOR
|
||
ROD2-W09 running **Android 16 (SDK 36)**, with the shipped app installed and
|
||
playing, shows otherwise. The permission is genuinely denied:
|
||
|
||
```
|
||
POST_NOTIFICATIONS: granted=false, flags=[USER_SENSITIVE_WHEN_GRANTED|USER_SENSITIVE_WHEN_DENIED]
|
||
appops POST_NOTIFICATION: ignore
|
||
```
|
||
|
||
and the notification is nonetheless live and complete:
|
||
|
||
```
|
||
ServiceRecord{... com.dtourolle.jellytau/.player.JellyTauPlaybackService}
|
||
isForeground=true foregroundId=1 types=0x00000002
|
||
foregroundNoti=Notification(flags=NO_CLEAR|FOREGROUND_SERVICE
|
||
category=transport actions=3 vis=PUBLIC)
|
||
```
|
||
|
||
**`UR-006` is not at risk.** But the *reason* is not the one this audit first
|
||
gave, and the correction is load-bearing rather than pedantic.
|
||
|
||
The first explanation here was "foreground-service notifications are exempt." That
|
||
is wrong. Android's own wording is that the permission covers "non-exempt
|
||
(**including Foreground Services (FGS)**) notifications", and that users who deny
|
||
it see FGS notices "in the Task Manager but [not] in the notification drawer" — an
|
||
FGS notification is explicitly *not* exempt. What is exempt is **media-session**
|
||
notifications. The platform predicate is `Notification.isMediaNotification()`,
|
||
requiring `MediaStyle`/`DecoratedMediaCustomViewStyle` **and** a non-null
|
||
`EXTRA_MEDIA_SESSION`; it is byte-identical across API 33–36, and
|
||
`NotificationManagerService` has no FGS clause in either enforcement site.
|
||
|
||
Why the difference matters: under the FGS theory, anything the service posts is
|
||
safe, and the code needs no care. Under the correct one, the exemption is earned
|
||
per-notification by the token — so losing the token loses not just the shade entry
|
||
but the lockscreen controls entirely, since SystemUI's media carousel
|
||
(`MediaDataProcessor.onNotificationAdded`) gates on the *same* predicate. A
|
||
token-less notification never even reaches the notification listener.
|
||
|
||
**The real risk here is not the permission — it is how narrowly the exemption is
|
||
earned.** AOSP's `Notification.isMediaNotification()` grants it only when the
|
||
style is `MediaStyle`/`DecoratedMediaCustomViewStyle` **and**
|
||
`Notification.EXTRA_MEDIA_SESSION` holds a non-null *platform* session token. If
|
||
either is missing while the permission is denied, the notification is **silently
|
||
suppressed** — no exception, no log.
|
||
|
||
JellyTau earns it at two sites, both of which hang it on a null-safe call:
|
||
|
||
```kotlin
|
||
androidx.media.app.NotificationCompat.MediaStyle()
|
||
.setMediaSession(mediaSessionCompat?.sessionToken) // :273 and :466
|
||
```
|
||
|
||
Ordering currently saves it — `mediaSessionCompat` is assigned in `onCreate`
|
||
(:195) and `createBasicNotification()` is only reached from `onStartCommand`
|
||
(:251) — and the device test confirms it works. But it is one reordering away
|
||
from breaking invisibly, and only for users who denied the permission, which is
|
||
a population most developers never test as.
|
||
|
||
**Fix:** Keep the permission declared — download-service FGS notifications are
|
||
*not* covered by the media exemption, and this app has a downloads feature that
|
||
may want them. Comment both `setMediaSession` sites to record what earns the
|
||
exemption, and log loudly if the token is ever null at build time, converting a
|
||
silent failure into a diagnosable one.
|
||
|
||
**Location:** `src-tauri/android/src/main/java/com/dtourolle/jellytau/player/JellyTauPlaybackService.kt:251`, `:273`, `:466`
|
||
|
||
### B2 · High · Cloud backup is on by default, and it will break credential restore
|
||
|
||
The manifest sets neither `android:allowBackup="false"` nor a
|
||
`dataExtractionRules`/`fullBackupContent` file, so Android's default applies: the
|
||
app's data directory is backed up to the user's Google account. That ships the
|
||
SQLite catalogue — library metadata and watch history — off the device.
|
||
|
||
The credential path makes it worse rather than better. `SecureStorage.kt`
|
||
encrypts with AES/GCM under an Android Keystore key, and Keystore keys are never
|
||
backed up. A user restoring onto a new phone therefore gets the ciphertext
|
||
without the key: undecryptable credentials and a silent authentication failure,
|
||
with no code path that recognises the situation.
|
||
|
||
**Fix applied.** `allowBackup="false"`. Extraction rules that merely excluded the
|
||
DB and credential prefs would have left nothing worth backing up: the SQLite
|
||
catalogue is a rebuildable mirror of the server and watch state lives server-side,
|
||
so there is no user-authored data to preserve.
|
||
|
||
**A gap this audit missed:** on API 31+, `allowBackup="false"` disables *cloud*
|
||
backup but **not device-to-device transfer**, which reproduces the identical
|
||
failure — the prefs travel, the Keystore key does not. A
|
||
`data_extraction_rules.xml` excluding all five domains from both `<cloud-backup>`
|
||
and `<device-transfer>` was added to close it.
|
||
|
||
**A real bug found while fixing this:** the Rust encrypted-file fallback in
|
||
`credentials.rs` propagated a decrypt failure as `CredentialError::Encryption`,
|
||
which `storage_get_access_token` turned into a hard `Err` — so an undecryptable
|
||
blob was an error state, not a logout. It now logs and returns an empty map, so
|
||
the caller sees `NotFound` → `Ok(None)` → login screen, and the next sign-in
|
||
self-heals the file. `SecureStorage.getCredential` on the Kotlin side already
|
||
returned null, but could not distinguish "nothing stored" from "unreadable" and
|
||
left the dead blob in prefs forever; it now separates the cases and discards it.
|
||
Three tests written and watched fail first, per the red→green rule.
|
||
|
||
### B3 · High · `MIXED_CONTENT_ALWAYS_ALLOW` undoes the network security config
|
||
|
||
`network_security_config.xml` is careful and well-argued: cleartext blocked
|
||
everywhere, exempted only for `127.0.0.1` so the local media server can serve
|
||
downloads. Its own comment warns "this must not become a blanket cleartext
|
||
opt-in."
|
||
|
||
But `MainActivity.kt` sets `mixedContentMode = MIXED_CONTENT_ALWAYS_ALLOW`, which
|
||
permits the WebView to load http subresources into an https page from any origin.
|
||
Alongside it, `allowFileAccess = true` and `allowContentAccess = true` are both
|
||
broader than anything the app needs, since Tauri serves the UI from its own scheme
|
||
and media comes from the token-guarded loopback server. These read as leftovers
|
||
from before the media server existed.
|
||
|
||
**Fix:** Drop to `MIXED_CONTENT_COMPATIBILITY_MODE` and set both file and content
|
||
access to false, then verify offline video still plays.
|
||
|
||
**Location:** `src-tauri/android/src/main/java/com/dtourolle/jellytau/MainActivity.kt:504-507`
|
||
|
||
### B4 · Medium · Android TV is half-declared
|
||
|
||
The manifest advertises `LEANBACK_LAUNCHER` and a non-required leanback feature,
|
||
but omits `<uses-feature android:name="android.hardware.touchscreen"
|
||
android:required="false"/>` and an `android:banner`. That combination fails Play's
|
||
TV validation, and on a real TV the app would launch into a UI with no D-pad focus
|
||
model behind it.
|
||
|
||
**Fix:** Either commit to TV — add the feature declaration, a banner, and a focus
|
||
pass — or remove the leanback category until you do.
|
||
|
||
### B5 · Medium · `jvmTarget` is pinned to 1.8 under compileSdk 36
|
||
|
||
The Kotlin target has not moved with the SDK. AGP 8 warns on it, and it locks the
|
||
Kotlin sources out of APIs and desugaring behaviour that everything else in the
|
||
toolchain assumes.
|
||
|
||
**Fix:** Move `jvmTarget` and the Java source/target compatibility to 17.
|
||
|
||
### B6 · Low · Media3 is several minor versions behind
|
||
|
||
`androidx.media3` is pinned at 1.5.0 across exoplayer, hls, session and common.
|
||
Given how much of this app's hard-won behaviour lives in ExoPlayer edge cases —
|
||
truncated progressive streams, background audio handoff, HLS resume — staying
|
||
current on its bug-fix releases has unusually high value here.
|
||
|
||
**Fix:** Schedule a Media3 bump with a device pass over the playback regression list.
|
||
|
||
### B7 · Medium · Predictive back is already on, not merely un-opted-into
|
||
|
||
*Upgraded from Low, and re-framed — the original framing was backwards.*
|
||
|
||
The audit first read the absent `enableOnBackInvokedCallback` as the app
|
||
*forgoing* the Android 13+ back-gesture preview. That is not what the flag means
|
||
at this target level. Predictive back is enabled by default for apps targeting
|
||
recent SDKs, and Android 16's own behaviour-change list carries "Migration or
|
||
opt-out required for predictive back" — with the opt-out being removed. Targeting
|
||
36, JellyTau is already getting predictive back; it simply hasn't been checked
|
||
against it.
|
||
|
||
That matters more than a missing opt-in would, because the app does not use
|
||
ordinary Android back. It runs a WebView with its own history model —
|
||
`src/lib/utils/navigation.ts` tracks a depth counter, applies a popstate delta,
|
||
and falls back to a path when `history.back()` would trap the user, with
|
||
`scrollRestore.ts` keying off the same popstate events. That is exactly the kind
|
||
of custom back handling predictive back is most likely to disagree with.
|
||
|
||
**Fix:** This is a device test, not a code change — exercise the back gesture
|
||
(including the drag-and-release preview and the cancel) from a library page, a
|
||
detail page, the player, and the settings screen, and watch for the depth counter
|
||
desynchronising. Only change code if it misbehaves.
|
||
|
||
Separately and unrelatedly: `JellyTauPlaybackService` is `exported="true"` with a
|
||
`MediaSessionService` intent filter — conventional for Media3, but it means any
|
||
app on the device can attempt to bind and drive playback. Confirm the session's
|
||
`onConnect` callback rejects unknown packages.
|
||
|
||
### B8 · Medium (forward-looking) · Android 16 Local Network Protections vs a LAN Jellyfin server
|
||
|
||
*New finding, surfaced while researching B1.*
|
||
|
||
Android 16's behaviour-change list includes **Local Network Permission**. JellyTau's
|
||
entire purpose is reaching a Jellyfin server that, for most users, sits on the
|
||
local network — so a permission gate on local-network access is a direct threat to
|
||
the app's core function, not a peripheral concern.
|
||
|
||
Stated carefully, because the timing matters: in Android 16 this is **opt-in for
|
||
testing**, not enforced by default, with enforcement signalled for a future
|
||
release. Nothing is broken today, and the device test will not surface it. But
|
||
this is the rare platform change that could stop the app working at all, and it
|
||
is much cheaper to handle before it is mandatory.
|
||
|
||
**Fix:** Investigate what the permission will require, then test the app against
|
||
it with the opt-in flag enabled on the Android 16 device already to hand. Track it
|
||
as a release-blocking item for whichever Android version enforces it.
|
||
|
||
---
|
||
|
||
## C. Tauri v2 configuration
|
||
|
||
The capability model here is genuinely well done — see section E. The gaps are in
|
||
the two settings that govern what a compromised web layer could reach.
|
||
|
||
### C1 · High · `"csp": null` contradicts the project's own security convention
|
||
|
||
`CLAUDE.md` lists "keep the CSP restrictive in `tauri.conf.json`" as a standing
|
||
rule; the config disables CSP entirely. With it off, any script that reaches the
|
||
web layer inherits the full IPC surface.
|
||
|
||
The realistic exposure today is low, and worth stating plainly rather than
|
||
inflating: the frontend has a single `{@html}` — an app-owned icon in
|
||
`GenericGenreBrowser.svelte`, not server data — and no `innerHTML`, `eval` or
|
||
`new Function` outside tests. So this is a missing defence rather than an open
|
||
hole. But it is the defence that stops the next careless interpolation of a
|
||
Jellyfin-supplied string from becoming a full compromise.
|
||
|
||
**Fix:** Set a CSP permitting `'self'`, `asset.localhost`, `http://127.0.0.1:*`
|
||
for media, and the configured Jellyfin origin for images. Expect one or two
|
||
iterations against HLS playback.
|
||
|
||
### C2 · Medium · The asset protocol scope is wider than what it serves
|
||
|
||
`assetProtocol.scope` is `$APPDATA/**`, which covers the whole app data directory
|
||
— the SQLite database and the credential store included — while the protocol only
|
||
needs to reach cached thumbnails and downloaded media.
|
||
|
||
Since `DR-137` introduced the token-guarded loopback media server, the asset
|
||
protocol's remaining job may be thumbnails alone, which would make the narrowing
|
||
nearly free.
|
||
|
||
**Fix:** Scope it to the thumbnail and download subdirectories, and confirm
|
||
nothing else still resolves through `convertFileSrc`.
|
||
|
||
### C3 · Low · Shipped desktop bundles have no update path
|
||
|
||
The bundle targets deb, rpm and nsis, but `tauri-plugin-updater` is not among the
|
||
dependencies. Every desktop user upgrades by manually fetching a new package,
|
||
which in practice means a long tail of installs pinned to whatever version they
|
||
first downloaded.
|
||
|
||
**Fix:** Add the updater plugin with a signed release manifest, or document the
|
||
manual upgrade path in the README so the omission is at least deliberate.
|
||
|
||
---
|
||
|
||
## D. CI and code health
|
||
|
||
Local discipline in this project is strong and well documented. CI enforces only
|
||
part of it, which means the discipline holds exactly as long as every contributor
|
||
remembers it.
|
||
|
||
### D1 · High · CI runs neither `cargo clippy` nor `cargo fmt --check`
|
||
|
||
`CLAUDE.md` requires both before committing. Neither appears anywhere in
|
||
`.gitea/workflows/`. The build-and-test job runs the boundary check, the frontend
|
||
tests, the Rust tests and an Android `cargo check` — a good set, with the two lint
|
||
gates missing.
|
||
|
||
Clippy currently reports 51 warnings across the lib and its tests, including
|
||
unused imports and a redundant import that a gate would have stopped at the door.
|
||
|
||
**Fix:** Add both to the test job. Start with `-D warnings` on new code only if
|
||
clearing the existing 51 is too large a first step.
|
||
|
||
### D2 · Medium · A flaky test will intermittently redden CI
|
||
|
||
`offlineCatalog.test.ts` — "pushes include=true while the server is reachable"
|
||
(`UT-068`) — timed out at the 5 s limit during a full-suite run, then passed twice
|
||
in isolation taking 1.13 s and 0.61 s.
|
||
|
||
**Root cause (corrected):** this audit originally attributed it to a real
|
||
wall-clock timer. It isn't. The cost is the **first dynamic
|
||
`import("./offlineCatalog")`**, which pays to transform the service and its whole
|
||
dependency graph (~1072 ms cold) inside a test body, charged against vitest's 5 s
|
||
default. Later re-imports after `vi.resetModules()` cost ~30 ms. Under full-suite
|
||
contention the cold transform alone crosses the limit.
|
||
|
||
**Fix applied:** warm the import once at collection time with a top-level
|
||
`await import(...)`, so no test is timing the compiler. Slowest test 1072 ms →
|
||
129 ms; file total 1170 ms → 238 ms. Timeout deliberately left at the default.
|
||
A latent cross-test leak was also fixed alongside it — the store shim's
|
||
subscribers were never cleared, so every module instance discarded by
|
||
`resetModules()` kept pushing its own visibility value.
|
||
|
||
**Location:** `src/lib/services/offlineCatalog.test.ts:58`
|
||
|
||
### D3 · Low · ~~820~~ **19** production `unwrap()`/`expect()` calls
|
||
|
||
*Downgraded from Medium. This audit substantially overstated the problem, and the
|
||
correction is worth recording because the measurement error is instructive.*
|
||
|
||
The original 820 figure came from grepping for `unwrap()`/`expect()` and filtering
|
||
lines containing "test". That does not exclude test *modules* — it only excludes
|
||
lines with "test" in them. Scripting the actual `#[cfg(test)]` boundaries gives
|
||
**19 real production sites**, not 820. `player/mod.rs`'s 154 hits, for instance,
|
||
are *all* past its `#[cfg(test)]` at line 2183, as are the bulk of
|
||
`repository/offline.rs`, `storage/mod.rs` and `commands/download/mod.rs`.
|
||
|
||
**More importantly: zero bare unwraps exist in any `#[tauri::command]` handler.**
|
||
The specific risk this finding was built around — a panic inside a command killing
|
||
the task and stranding shared player state — is already absent.
|
||
|
||
The same correction applies to the lock half: all 33 raw `.lock().unwrap()` hits
|
||
were in test modules (three weren't even code, but prose in `utils/lock.rs`'s doc
|
||
comment). Production was already fully on `lock_safe()`/`read_safe()`/
|
||
`write_safe()`. Converting them was consistency work, not a bug fix.
|
||
|
||
**What is genuinely worth doing** is a three-site cluster, all the same pattern —
|
||
`Runtime::new().unwrap()` in threads owning playback-critical state:
|
||
|
||
| | Site | Consequence of a panic |
|
||
|---|------|------------------------|
|
||
| 1 | `session_poller/mod.rs:102` | Poller thread dies silently; it drives remote-mode state *and* offline→online recovery, so the app strands offline with nothing surfaced |
|
||
| 2 | `player/mpv_backend.rs:424` | Position reporting stops mid-playback; the scrubber freezes while audio keeps going |
|
||
| 3 | `player/android/mod.rs:761` | Same pattern across a JNI boundary; progress reporting dies and no resume points are written |
|
||
|
||
**Fix:** One shared helper returning `Option<Runtime>` and logging on failure
|
||
retires all three. The remaining 16 are startup `expect()`s and two provably
|
||
infallible calls.
|
||
|
||
### D4 · Low · Five files carry a disproportionate share of the complexity
|
||
|
||
`player/mod.rs` (4,726 lines), `repository/offline.rs` (4,696),
|
||
`repository/online.rs` (3,702), `commands/player/mod.rs` (3,299) and
|
||
`commands/download/mod.rs` (3,226), plus `VideoPlayer.svelte` (2,778) on the
|
||
frontend.
|
||
|
||
These are the same files the changelog keeps returning to for deadlocks and
|
||
playback regressions. Not a defect in itself, and not worth a speculative
|
||
refactor — but the next time one of them needs substantial work, splitting it is
|
||
likely cheaper than continuing to grow it.
|
||
|
||
---
|
||
|
||
## E. Verified sound
|
||
|
||
Things this audit specifically went looking for and found in good order —
|
||
including one that looked alarming from the warning output and turned out to be
|
||
fine.
|
||
|
||
| Area | Finding |
|
||
|------|---------|
|
||
| **The 9 "MutexGuard across await" warnings are test-only** | All nine sit in `#[tokio::test]` functions holding a serialization lock, not in the production async paths that `CLAUDE.md`'s deadlock gotcha warns about. |
|
||
| **The local media server is exemplary** | Loopback-only bind, a 32-hex-char per-session token, lexical `..` folding rather than `canonicalize`, and a test asserting reads stay inside the data directory. |
|
||
| **Tauri capabilities are minimal** | Three permissions total — `core:default`, `opener:default`, `core:path:default`. No blanket grants, no `withGlobalTauri`. |
|
||
| **SQL is parameterised** | Two `format!`-built statements in the whole Rust tree, neither interpolating caller-controlled input into a query. |
|
||
| **R8 keep rules are correct and explained** | JNI-loaded player and security classes, the JavascriptInterface bridges and Media3 are all kept, each with a comment naming the crash it prevents. |
|
||
| **Type and boundary gates are green** | `svelte-check`: 0 errors, 0 warnings. `check:boundary` passes with three reviewed allowlist entries. 698 Rust tests and 1,021 frontend tests pass. |
|
||
|
||
---
|
||
|
||
## F. Suggested order
|
||
|
||
Sequenced so the cheap gates land before the work they would have caught. B2
|
||
leads because it is the finding a user is most likely to actually feel.
|
||
|
||
*(B1 originally led this list. It was demoted to row 10 after device testing —
|
||
see B1. This is a good advertisement for testing a finding before scheduling
|
||
work against it.)*
|
||
|
||
| # | Finding | What it buys | Effort |
|
||
|---|---------|--------------|--------|
|
||
| 1 | B2 | Catalogue and credentials stop leaving the device; restore stops failing silently | S — **confirmed on device**: `ALLOW_BACKUP` set, Google transport active |
|
||
| 3 | D1 | Lint discipline becomes enforced rather than remembered | S |
|
||
| 4 | B3 | The network security config actually holds | S — needs an offline-playback check |
|
||
| 5 | A1 | The matrix stops over-reporting on twelve shipped requirements | M — mostly mechanical |
|
||
| 6 | D2 | CI stops flaking | S |
|
||
| 7 | C1 · C2 | The web layer stops being one interpolation away from full IPC | M — iterate against HLS |
|
||
| 8 | A2 · A3 · A4 | The matrix becomes self-consistent and defended by a real gate | M |
|
||
| 9 | B4 · B5 · B6 · B7 | Platform hygiene brought level with the SDK target | M |
|
||
| 10 | B1 · D3 · C3 · D4 | Long-tail robustness; opportunistic rather than scheduled | L |
|