feat(diagnostics): persistent redacted logging and an exportable bundle
🏗️ Build and Test JellyTau / Run Tests (pull_request) Successful in 22m12s
🏗️ Build and Test JellyTau / Supply Chain (pull_request) Successful in 37s
Traceability Validation / Check Requirement Traces (pull_request) Successful in 11s
🏗️ Build and Test JellyTau / Android Compile Check (pull_request) Successful in 4m10s

The app forgot everything it did the moment it exited. The Rust half
logged through env_logger to stdout only -- invisible to anyone who
launched from a desktop icon, and on Android worse than that: stdout is
not logcat, so the backend produced no visible output at all on the
platform carrying this project's hardest bugs. The autoplay deadlock,
the truncated-stream restart and the background-audio stall were all
diagnosed by talking a user through `adb logcat`, because there was no
other way to see anything. A panic left nothing behind at all.

Logs now go to a size-capped rotating file, to logcat on Android, and to
the webview console in dev. A panic is recorded with its backtrace before
the process dies. The frontend's messages are forwarded into the same
file, so one timeline holds both halves of the app in order -- which is
what makes a race between them legible after the fact, and races between
them are the expensive bug class here.

Redaction runs in the log FORMATTER, not at export time. A credential
sitting in a file on the device is already a disclosure; stripping it on
the way out would be too late. The exporter redacts a second time to
cover files written by builds that predate this. api_key, X-Emby-Token,
Authorization, "AccessToken" and Token="..." all reduce to [REDACTED],
while host, item ids and filenames are deliberately kept -- a log scrubbed
of those is one nobody can debug anything from. Server URLs keep scheme
and host and drop any embedded user:pass@.

Two things the tests caught that review would not have:

  - redact_headers recursed on its own output. The replacement keeps the
    header NAME, so the next call matched the same header forever; the
    test died with a stack overflow. It is a forward scan now.
  - The frontend forwarder used `void plugin.error(...)`. `void` discards
    a promise's value but not its rejection, so in any webview without
    IPC -- a unit test, SSR, a browser preview -- every log line became an
    unhandled rejection. 20 of them showed up the first time coverage
    ran. Each call now attaches a catch.

Only info and above cross the IPC boundary: debug is per-tick player
state and forwarding it would be thousands of calls a minute for output
nobody reads. A failing forwarder never propagates and never prevents the
console write.

Nothing is transmitted anywhere. The export writes a zip and reports its
path; the user attaches it themselves, which is also what keeps this from
becoming telemetry. An Android share intent is explicitly out of scope --
it is Kotlin work that belongs with the other native code.

The panic hook chains to the previous hook rather than replacing it,
because utils/lock.rs installs a silencing hook around tests that provoke
poisoned locks on purpose.

Spec in docs/specs/diagnostics-and-logging.md; UR-078 / DR-218 / UT-209.

Verified: 1079 frontend tests and the coverage gate, 759 Rust tests,
clippy -D warnings, svelte-check 0 errors, and cargo check for
aarch64-linux-android.
This commit is contained in:
2026-08-21 18:58:57 +02:00
parent fb72bf3005
commit f3fa45f742
16 changed files with 1758 additions and 9 deletions
+5
View File
@@ -6,6 +6,7 @@
"name": "jellytau", "name": "jellytau",
"dependencies": { "dependencies": {
"@tauri-apps/api": "^2", "@tauri-apps/api": "^2",
"@tauri-apps/plugin-log": "^2.9.0",
"@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-opener": "^2",
"@tauri-apps/plugin-os": "^2.3.2", "@tauri-apps/plugin-os": "^2.3.2",
"@tauri-apps/plugin-process": "^2.3.1", "@tauri-apps/plugin-process": "^2.3.1",
@@ -280,6 +281,8 @@
"@tauri-apps/cli-win32-x64-msvc": ["@tauri-apps/cli-win32-x64-msvc@2.9.6", "", { "os": "win32", "cpu": "x64" }, "sha512-ldWuWSSkWbKOPjQMJoYVj9wLHcOniv7diyI5UAJ4XsBdtaFB0pKHQsqw/ItUma0VXGC7vB4E9fZjivmxur60aw=="], "@tauri-apps/cli-win32-x64-msvc": ["@tauri-apps/cli-win32-x64-msvc@2.9.6", "", { "os": "win32", "cpu": "x64" }, "sha512-ldWuWSSkWbKOPjQMJoYVj9wLHcOniv7diyI5UAJ4XsBdtaFB0pKHQsqw/ItUma0VXGC7vB4E9fZjivmxur60aw=="],
"@tauri-apps/plugin-log": ["@tauri-apps/plugin-log@2.9.0", "", { "dependencies": { "@tauri-apps/api": "^2.11.0" } }, "sha512-Ql8okrnsguk0eDq1GvRfttFV5KaeW/7vcao6bdbkXCRJ1+2sWE15ZJvJVEKVANrOKy1mRngqC3IFIAP+wP5qSw=="],
"@tauri-apps/plugin-opener": ["@tauri-apps/plugin-opener@2.5.2", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-ei/yRRoCklWHImwpCcDK3VhNXx+QXM9793aQ64YxpqVF0BDuuIlXhZgiAkc15wnPVav+IbkYhmDJIv5R326Mew=="], "@tauri-apps/plugin-opener": ["@tauri-apps/plugin-opener@2.5.2", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-ei/yRRoCklWHImwpCcDK3VhNXx+QXM9793aQ64YxpqVF0BDuuIlXhZgiAkc15wnPVav+IbkYhmDJIv5R326Mew=="],
"@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-os": ["@tauri-apps/plugin-os@2.3.2", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-n+nXWeuSeF9wcEsSPmRnBEGrRgOy6jjkSU+UVCOV8YUGKb2erhDOxis7IqRXiRVHhY8XMKks00BJ0OAdkpf6+A=="],
@@ -758,6 +761,8 @@
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"@tauri-apps/plugin-log/@tauri-apps/api": ["@tauri-apps/api@2.11.1", "", {}, "sha512-M2FPuYND2m+wh5hfW9ZpSdxMPdEJovPBWwoHJmwUpysTYNHaOkVFN419m/K0LIgjb/7KU2vBgsUepJWugQCvAA=="],
"@tauri-apps/plugin-updater/@tauri-apps/api": ["@tauri-apps/api@2.11.1", "", {}, "sha512-M2FPuYND2m+wh5hfW9ZpSdxMPdEJovPBWwoHJmwUpysTYNHaOkVFN419m/K0LIgjb/7KU2vBgsUepJWugQCvAA=="], "@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=="], "@testing-library/dom/aria-query": ["aria-query@5.3.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A=="],
+4
View File
@@ -87,6 +87,7 @@ For a narrative overview of the system design, see
| 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-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-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-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-078 | JellyTau keeps a record of what it did, and can hand it over. The app forgot everything the moment it exited: the backend logged to stdout only — which a user launching from a desktop icon never sees, and which on Android is not logcat, so the Rust half was invisible on the platform carrying the hardest bugs. A crash left nothing at all. Logs are now written to a size-capped rotating file, a panic is recorded before the process dies, the frontend's messages land in the same timeline as the backend's, and Settings exports the lot as one file to attach to a bug report. Nothing is transmitted anywhere — the user attaches it themselves, which is also what keeps this from being telemetry. Access tokens and passwords never reach the file | 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 | | 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 |
--- ---
@@ -408,6 +409,7 @@ Internal architecture, components, and application logic.
| 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-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-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-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-218 | Persistent, redacted logging and a diagnostics export. `tauri-plugin-log` replaces the `env_logger` stdout-only init, giving a rotating 5 MB file, a webview target in dev, and — the single largest gain — logcat on Android, where `env_logger`'s stdout went nowhere. **Redaction runs in the log formatter, not at export**: a credential in a file on the device is already a disclosure, so stripping it on the way out would be too late; the exporter redacts a second time to cover files written by older builds. `api_key`/`X-Emby-Token`/`Authorization`/`"AccessToken"`/`Token="…"` all reduce to `[REDACTED]` while host, item ids and filenames are deliberately kept — a bundle scrubbed of those is one nobody can debug from. The server URL is reduced to scheme and host, dropping any embedded `user:pass@`. The panic hook chains to the previous hook rather than replacing it, because `utils/lock.rs` installs a silencing hook around tests that provoke poisoned locks on purpose. The chosen level persists to disk and is re-applied at startup, since reproducing a bug usually means restarting into it. The frontend facade keeps its untouched `console.*` pass-through (DR-204) and additionally forwards a stringified copy at info and above, so one file holds both halves of the app in order — which is what makes a race between them legible after the fact | Tooling | UR-078 | 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 | | 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 |
--- ---
@@ -494,6 +496,7 @@ Internal architecture, components, and application logic.
| UR-075 | - | DR-174, DR-175 | | UR-075 | - | DR-174, DR-175 |
| UR-076 | - | DR-209 | | UR-076 | - | DR-209 |
| UR-077 | - | DR-217 | | UR-077 | - | DR-217 |
| UR-078 | - | DR-218 |
--- ---
@@ -705,6 +708,7 @@ Internal architecture, components, and application logic.
| 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-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-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 | | 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 |
| UT-209 | Redaction and forwarding. Rust: every credential shape reduces to `[REDACTED]` while the host, username and neighbouring parameters survive; redaction is idempotent, leaves ordinary lines alone, does not fire on the word "token" in prose, and does not panic on multi-byte input; a server URL keeps only scheme and host and drops an embedded `user:pass@`; an unparseable level falls back to info rather than failing at startup. Frontend: info and above forward while debug does not, a message the level filter suppressed is not forwarded, a throwing forwarder neither propagates nor prevents the console write, and an `Error` renders as name and message rather than the `{}` that `JSON.stringify` produces | DR-218 | Done |
### Integration Tests ### Integration Tests
+192
View File
@@ -0,0 +1,192 @@
# Spec: Diagnostics and persistent logging
**Status:** Proposed
**Requirements:** UR-078 → DR-218; tests UT-209
**UX spec:** n/a (one Settings section; no new flow)
**Destination on completion:** [09-security.md](../architecture/09-security.md)
for the redaction rules, and a new "Logging and diagnostics" section in
[01-rust-backend.md](../architecture/01-rust-backend.md) for the capture path.
## Summary
JellyTau records what it does, keeps it in a size-capped file on disk, survives a
crash, and can hand the whole thing to the user as one file to attach to a bug
report. Credentials never reach that file.
## Motivation
Today the app forgets everything the moment it exits.
The Rust half logs through `env_logger` to **stdout only**. A user who launched
from a desktop icon has no stdout. On Android it is worse than useless:
`env_logger` writes to stdout, which is not logcat, so **the Rust backend's
output is invisible on the platform where most of the hard bugs have been** — the
autoplay deadlock, the truncated-stream restart, the background-audio stall. The
frontend has a proper leveled facade (`logger.ts`, DR-204) but it only reaches
the webview console, which nobody can read on a phone.
The practical consequence is visible in this project's history: several bugs took
multiple rounds of "can you reproduce it under `adb logcat`" before anyone could
even see what happened. A user reporting "the episode randomly restarted" is
reporting the symptom of a race whose evidence was discarded microseconds later.
There is also no crash record at all. If the app panics, the user sees it vanish
and we learn nothing.
## Layer assignment
| Logic / responsibility | Layer | Why it belongs there |
|---|---|---|
| What is captured, at what level, and where it is written | Rust | Retention and capture policy is backend behaviour; it must work identically whether the UI is open, backgrounded, or gone |
| Log rotation and the size cap | Rust | Storage management, same class as the download and image caches |
| **Redaction of credentials** | Rust | Security-critical, and the values (tokens, `api_key`, keyring payloads) are domain vocabulary owned by the auth layer. A frontend that redacted its own messages would still not cover anything Rust wrote |
| Panic capture and persistence | Rust | Only Rust can install a panic hook |
| Assembling the export (archive + environment summary) | Rust | Touches the filesystem and the app's own paths; also the last point at which redaction can be enforced over everything |
| Which log level is active | Rust owns the *stored* setting and applies it; the frontend renders the picker | Same split as every other setting: the value is state the backend acts on, the control is presentation |
| Showing the export path / opening the folder | Frontend | Pure presentation |
| Formatting a log line for the webview console | Frontend | `logger.ts` already owns this; unchanged |
Borderline: **the frontend forwarding its own messages into the Rust sink.**
Arguably presentation "sending data down". Placed as: the frontend calls a
plugin, and the *decision of what to persist and how to redact it* stays in Rust
— which is the tie-breaker, because a bug report containing a token would be a
security defect regardless of which half wrote the line.
## Design
### Capture
Replace the `env_logger` init in `lib.rs` with `tauri-plugin-log`, which is the
official plugin and already does the three things we would otherwise hand-roll
(CLAUDE.md: prefer official plugins before writing native code):
| Target | Purpose |
|---|---|
| `Stdout` | unchanged behaviour for `bun run tauri dev` |
| `LogDir { file_name: "jellytau" }` | the persistent, rotating file |
| `Webview` (dev only) | Rust lines visible in the webview console while developing |
On Android the plugin routes to **logcat**, which is the single largest
improvement here and needs no code of ours.
Rotation: `RotationStrategy::KeepAll` is wrong for a phone. Use a size cap
(5 MB) with one retained previous file, so a long session cannot fill a device
and yesterday's evidence still exists.
Level: default `Info`, `RUST_LOG` still honoured, and a stored user preference
that survives restart (a user reproducing a bug needs debug logging *across* the
restart that reproduces it).
### Redaction
A pure function in a new `src-tauri/src/utils/diagnostics.rs`:
```rust
pub fn redact(line: &str) -> String
```
It replaces the value in each of these with `[REDACTED]`, case-insensitively:
- `api_key=…` and `ApiKey=…` in URLs and query strings
- `X-Emby-Token: …`, `X-MediaBrowser-Token: …`, `Authorization: …` headers
- `"AccessToken":"…"` in JSON bodies
- `MediaBrowser Token="…"` in the Emby auth header form
What it deliberately does **not** remove: the server host, item ids, and
filenames. Those are what make a log useful, they are not secrets, and stripping
them would produce a diagnostic bundle nobody can diagnose anything from.
Applied at two points: on every line the export copies, and — because the export
is not the only way a file leaves a device — inside the log formatter itself, so
the token never reaches disk in the first place. The export-time pass exists to
cover files written before an upgrade.
### Export
```rust
#[tauri::command]
pub async fn diagnostics_export(app: AppHandle) -> Result<DiagnosticsBundle, String>
```
Writes a single `.zip` and returns where it went:
```rust
#[derive(Serialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct DiagnosticsBundle {
pub path: String,
pub size_bytes: u64,
pub file_count: usize,
}
```
Contents: the current and previous log files (redacted), plus `environment.txt`
— app version, OS and arch, whether the build is debug, the active log level, and
the *scheme and host* of the configured server. No token, no username, no path
inside the user's home beyond the app's own directories.
### Frontend
`logger.ts` keeps its `console.*` pass-through untouched — live object references
in devtools are a stated design goal of DR-204 — and *additionally* forwards a
stringified copy at `info` and above to the plugin, so one timeline contains both
halves of the app. Forwarding is fire-and-forget and never throws into a caller:
a logging failure must not become an application failure.
A `Diagnostics` section in Settings shows the log location, a level picker, and
an **Export diagnostics** button that reports the resulting path and, on desktop,
offers to reveal it.
## Out of scope
- **An Android share sheet.** Export writes to the app's files directory and
reports the path; wiring a native `ACTION_SEND` intent is a Kotlin change that
belongs with the other native work, not here.
- **Uploading anywhere.** Nothing is transmitted. The user attaches the file
themselves, which is also what keeps this from becoming telemetry.
- **Frontend `debug` forwarding.** Only `info`+ crosses the IPC boundary; per-tick
player debug would be thousands of calls a minute.
## Acceptance criteria
- [ ] Rust logs reach a rotating file on Linux and **logcat** on Android.
- [ ] A panic is recorded and is present in the next export.
- [ ] Frontend `info`/`warn`/`error` appear in the same file as Rust's lines.
- [ ] An export containing a request URL with `api_key=` shows `[REDACTED]`, and
a test greps the produced bundle for the token to prove it.
- [ ] The log file cannot exceed the cap.
- [ ] `bun run check`, `bun run test`, `cargo fmt`, `cargo clippy -D warnings`,
`bun run test:rust`, `bun run check:boundary` all pass.
- [ ] `bindings.ts` regenerated (new command and struct).
- [ ] New code carries `TRACES:` comments.
## Testing
**Rust** (`cargo test`): `redact` over each credential shape, including one
already-redacted line (idempotent) and a line containing no secret (unchanged);
that the environment summary contains a host but no token; that rotation respects
the cap.
**Frontend** (`vitest`): that the forwarder is called for `info`+ and not for
`debug`; that a rejected forward does not propagate to the caller.
## TRACES
| Piece | Tag |
|---|---|
| `utils/diagnostics.rs` | `UR-078 \| DR-218` |
| `commands/diagnostics.rs` | `UR-078 \| DR-218` |
| logging init in `lib.rs` | `UR-078 \| DR-218` |
| `logger.ts` forwarding | `UR-078 \| DR-204, DR-218` |
| Settings section | `UR-078 \| DR-218` |
| tests | `\| DR-218 \| UT-209` |
## Notes for the implementer
- A parallel Claude session may be active — `git diff` before "repairing"
anything unexpected.
- `utils/lock.rs` already sets and restores a panic hook in its tests. The
diagnostics hook must chain to the previous hook rather than replace it, or
those tests start reporting panics they deliberately suppress.
- Do not call the exporter from an event callback that can re-enter the player:
it does blocking file I/O (see the deadlock note in CLAUDE.md).
+1
View File
@@ -56,6 +56,7 @@
}, },
"dependencies": { "dependencies": {
"@tauri-apps/api": "^2", "@tauri-apps/api": "^2",
"@tauri-apps/plugin-log": "^2.9.0",
"@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-opener": "^2",
"@tauri-apps/plugin-os": "^2.3.2", "@tauri-apps/plugin-os": "^2.3.2",
"@tauri-apps/plugin-process": "^2.3.1", "@tauri-apps/plugin-process": "^2.3.1",
+311 -2
View File
@@ -49,6 +49,17 @@ dependencies = [
"subtle", "subtle",
] ]
[[package]]
name = "ahash"
version = "0.7.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9"
dependencies = [
"getrandom 0.2.16",
"once_cell",
"version_check",
]
[[package]] [[package]]
name = "ahash" name = "ahash"
version = "0.8.12" version = "0.8.12"
@@ -85,6 +96,23 @@ dependencies = [
"alloc-no-stdlib", "alloc-no-stdlib",
] ]
[[package]]
name = "android_log-sys"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "84521a3cf562bc62942e294181d9eef17eb38ceb8c68677bc49f144e4c3d4f8d"
[[package]]
name = "android_logger"
version = "0.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dbb4e440d04be07da1f1bf44fb4495ebd58669372fe0cffa6e48595ac5bd88a3"
dependencies = [
"android_log-sys",
"env_filter",
"log",
]
[[package]] [[package]]
name = "android_system_properties" name = "android_system_properties"
version = "0.1.5" version = "0.1.5"
@@ -159,6 +187,12 @@ dependencies = [
"derive_arbitrary", "derive_arbitrary",
] ]
[[package]]
name = "arrayvec"
version = "0.7.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"
[[package]] [[package]]
name = "ascii" name = "ascii"
version = "1.1.0" version = "1.1.0"
@@ -358,6 +392,18 @@ dependencies = [
"serde_core", "serde_core",
] ]
[[package]]
name = "bitvec"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ddcec3d12c579d40898fe0a9a358a803c23e9c52ca3c425707f81c9436211837"
dependencies = [
"funty",
"radium",
"tap",
"wyz",
]
[[package]] [[package]]
name = "block-buffer" name = "block-buffer"
version = "0.10.4" version = "0.10.4"
@@ -389,6 +435,30 @@ dependencies = [
"piper", "piper",
] ]
[[package]]
name = "borsh"
version = "1.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a88b7ea17d208c4193f2c1e6de3c35fe71f98c96982d5ced308bdcc749ff6e1f"
dependencies = [
"borsh-derive",
"bytes",
"cfg_aliases",
]
[[package]]
name = "borsh-derive"
version = "1.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8f347189c62a579b8cd5f80714efa178f52e461dc2e6d701d264f5ff22e566c"
dependencies = [
"once_cell",
"proc-macro-crate 3.4.0",
"proc-macro2",
"quote",
"syn 2.0.112",
]
[[package]] [[package]]
name = "brotli" name = "brotli"
version = "8.0.2" version = "8.0.2"
@@ -416,6 +486,40 @@ version = "3.19.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510"
[[package]]
name = "byte-unit"
version = "5.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a813de7f2bbedb7dce265b64f1cf5908ebe4d56281ece8d847e98113788b9b0"
dependencies = [
"rust_decimal",
"schemars 1.2.0",
"serde",
"utf8-width",
]
[[package]]
name = "bytecheck"
version = "0.6.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23cdc57ce23ac53c931e88a43d06d070a6fd142f2617be5855eb75efc9beb1c2"
dependencies = [
"bytecheck_derive",
"ptr_meta",
"simdutf8",
]
[[package]]
name = "bytecheck_derive"
version = "0.6.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3db406d29fbcd95542e92559bed4d8ad92636d1ca8b3b72ede10b4bcc010e659"
dependencies = [
"proc-macro2",
"quote",
"syn 1.0.109",
]
[[package]] [[package]]
name = "bytemuck" name = "bytemuck"
version = "1.24.0" version = "1.24.0"
@@ -1105,6 +1209,15 @@ dependencies = [
"simd-adler32", "simd-adler32",
] ]
[[package]]
name = "fern"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4316185f709b23713e41e3195f90edef7fb00c3ed4adc79769cf09cc762a3b29"
dependencies = [
"log",
]
[[package]] [[package]]
name = "field-offset" name = "field-offset"
version = "0.3.6" version = "0.3.6"
@@ -1183,6 +1296,12 @@ dependencies = [
"percent-encoding", "percent-encoding",
] ]
[[package]]
name = "funty"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c"
[[package]] [[package]]
name = "futf" name = "futf"
version = "0.1.5" version = "0.1.5"
@@ -1607,6 +1726,9 @@ name = "hashbrown"
version = "0.12.3" version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
dependencies = [
"ahash 0.7.8",
]
[[package]] [[package]]
name = "hashbrown" name = "hashbrown"
@@ -1614,7 +1736,7 @@ version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
dependencies = [ dependencies = [
"ahash", "ahash 0.8.12",
] ]
[[package]] [[package]]
@@ -2074,6 +2196,7 @@ dependencies = [
"specta-typescript", "specta-typescript",
"tauri", "tauri",
"tauri-build", "tauri-build",
"tauri-plugin-log",
"tauri-plugin-opener", "tauri-plugin-opener",
"tauri-plugin-os", "tauri-plugin-os",
"tauri-plugin-process", "tauri-plugin-process",
@@ -2086,6 +2209,7 @@ dependencies = [
"tokio-util", "tokio-util",
"urlencoding", "urlencoding",
"uuid", "uuid",
"zip 2.4.2",
] ]
[[package]] [[package]]
@@ -2305,6 +2429,9 @@ name = "log"
version = "0.4.29" version = "0.4.29"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
dependencies = [
"value-bag",
]
[[package]] [[package]]
name = "lru-slab" name = "lru-slab"
@@ -2510,6 +2637,15 @@ dependencies = [
"syn 2.0.112", "syn 2.0.112",
] ]
[[package]]
name = "num_threads"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9"
dependencies = [
"libc",
]
[[package]] [[package]]
name = "objc2" name = "objc2"
version = "0.6.3" version = "0.6.3"
@@ -3239,6 +3375,26 @@ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[package]]
name = "ptr_meta"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0738ccf7ea06b608c10564b31debd4f5bc5e197fc8bfe088f68ae5ce81e7a4f1"
dependencies = [
"ptr_meta_derive",
]
[[package]]
name = "ptr_meta_derive"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "16b845dbfca988fa33db069c0e230574d15a3088f147a87b64c7589eb662c9ac"
dependencies = [
"proc-macro2",
"quote",
"syn 1.0.109",
]
[[package]] [[package]]
name = "quick-xml" name = "quick-xml"
version = "0.38.4" version = "0.38.4"
@@ -3318,6 +3474,12 @@ version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
[[package]]
name = "radium"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09"
[[package]] [[package]]
name = "rand" name = "rand"
version = "0.7.3" version = "0.7.3"
@@ -3514,6 +3676,15 @@ version = "0.8.8"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58"
[[package]]
name = "rend"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "71fe3824f5629716b1589be05dacd749f6aa084c87e00e016714a8cdfccc997c"
dependencies = [
"bytecheck",
]
[[package]] [[package]]
name = "reqwest" name = "reqwest"
version = "0.12.28" version = "0.12.28"
@@ -3569,6 +3740,35 @@ dependencies = [
"windows-sys 0.52.0", "windows-sys 0.52.0",
] ]
[[package]]
name = "rkyv"
version = "0.7.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2297bf9c81a3f0dc96bc9521370b88f054168c29826a75e89c55ff196e7ed6a1"
dependencies = [
"bitvec",
"bytecheck",
"bytes",
"hashbrown 0.12.3",
"ptr_meta",
"rend",
"rkyv_derive",
"seahash",
"tinyvec",
"uuid",
]
[[package]]
name = "rkyv_derive"
version = "0.7.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "84d7b42d4b8d06048d3ac8db0eb31bcb942cbeb709f0b5f2b2ebde398d3038f5"
dependencies = [
"proc-macro2",
"quote",
"syn 1.0.109",
]
[[package]] [[package]]
name = "rusqlite" name = "rusqlite"
version = "0.32.1" version = "0.32.1"
@@ -3583,6 +3783,23 @@ dependencies = [
"smallvec", "smallvec",
] ]
[[package]]
name = "rust_decimal"
version = "1.42.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be2a24f50780bc85f09cc6ac299bdf1424302742d77221106859c9d8b102126a"
dependencies = [
"arrayvec",
"borsh",
"bytes",
"num-traits",
"rand 0.8.7",
"rkyv",
"serde",
"serde_json",
"wasm-bindgen",
]
[[package]] [[package]]
name = "rustc-hash" name = "rustc-hash"
version = "2.1.1" version = "2.1.1"
@@ -3724,6 +3941,12 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "seahash"
version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b"
[[package]] [[package]]
name = "selectors" name = "selectors"
version = "0.24.0" version = "0.24.0"
@@ -3955,6 +4178,12 @@ version = "0.3.8"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2"
[[package]]
name = "simdutf8"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
[[package]] [[package]]
name = "siphasher" name = "siphasher"
version = "0.3.11" version = "0.3.11"
@@ -4257,6 +4486,12 @@ dependencies = [
"syn 2.0.112", "syn 2.0.112",
] ]
[[package]]
name = "tap"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
[[package]] [[package]]
name = "tar" name = "tar"
version = "0.4.46" version = "0.4.46"
@@ -4407,6 +4642,28 @@ dependencies = [
"walkdir", "walkdir",
] ]
[[package]]
name = "tauri-plugin-log"
version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7545bd67f070a4500432c826e2e0682146a1d6712aee22a2786490156b574d93"
dependencies = [
"android_logger",
"byte-unit",
"fern",
"log",
"objc2",
"objc2-foundation",
"serde",
"serde_json",
"serde_repr",
"swift-rs",
"tauri",
"tauri-plugin",
"thiserror 2.0.17",
"time",
]
[[package]] [[package]]
name = "tauri-plugin-opener" name = "tauri-plugin-opener"
version = "2.5.2" version = "2.5.2"
@@ -4486,7 +4743,7 @@ dependencies = [
"tokio", "tokio",
"url", "url",
"windows-sys 0.60.2", "windows-sys 0.60.2",
"zip", "zip 4.6.1",
] ]
[[package]] [[package]]
@@ -4689,7 +4946,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134"
dependencies = [ dependencies = [
"deranged", "deranged",
"libc",
"num-conv", "num-conv",
"num_threads",
"powerfmt", "powerfmt",
"serde_core", "serde_core",
"time-core", "time-core",
@@ -5137,6 +5396,12 @@ version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
[[package]]
name = "utf8-width"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "159a7cadce548703edd50d24069bc294c5415ecab0a480e0cd1ca06d112dc94a"
[[package]] [[package]]
name = "utf8_iter" name = "utf8_iter"
version = "1.0.4" version = "1.0.4"
@@ -5161,6 +5426,12 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "value-bag"
version = "1.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "068e763e8279de7ab94b6afebded2cb701678af094feb1c12ccb061b4783c1be"
[[package]] [[package]]
name = "vcpkg" name = "vcpkg"
version = "0.2.15" version = "0.2.15"
@@ -5973,6 +6244,15 @@ dependencies = [
"x11-dl", "x11-dl",
] ]
[[package]]
name = "wyz"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed"
dependencies = [
"tap",
]
[[package]] [[package]]
name = "x11" name = "x11"
version = "2.21.0" version = "2.21.0"
@@ -6168,6 +6448,23 @@ dependencies = [
"syn 2.0.112", "syn 2.0.112",
] ]
[[package]]
name = "zip"
version = "2.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50"
dependencies = [
"arbitrary",
"crc32fast",
"crossbeam-utils",
"displaydoc",
"flate2",
"indexmap 2.12.1",
"memchr",
"thiserror 2.0.17",
"zopfli",
]
[[package]] [[package]]
name = "zip" name = "zip"
version = "4.6.1" version = "4.6.1"
@@ -6186,6 +6483,18 @@ version = "1.0.8"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "317f17ff091ac4515f17cc7a190d2769a8c9a96d227de5d64b500b01cda8f2cd" checksum = "317f17ff091ac4515f17cc7a190d2769a8c9a96d227de5d64b500b01cda8f2cd"
[[package]]
name = "zopfli"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249"
dependencies = [
"bumpalo",
"crc32fast",
"log",
"simd-adler32",
]
[[package]] [[package]]
name = "zvariant" name = "zvariant"
version = "5.8.0" version = "5.8.0"
+11
View File
@@ -62,6 +62,17 @@ sha2 = "0.10"
getrandom = "0.2" getrandom = "0.2"
log = "0.4" log = "0.4"
env_logger = "0.11" env_logger = "0.11"
# Persistent, rotating, redacted logging on every platform -- and on Android the
# only thing that puts Rust output into logcat at all (env_logger writes to
# stdout, which Android discards, which is why the backend was invisible on the
# platform where the hardest bugs live).
#
# TRACES: UR-078 | DR-218
tauri-plugin-log = "2"
# Zip for the diagnostics export bundle.
zip = { version = "2", default-features = false, features = ["deflate"] }
tauri-specta = { version = "=2.0.0-rc.21", features = ["derive", "typescript"] } tauri-specta = { version = "=2.0.0-rc.21", features = ["derive", "typescript"] }
specta-typescript = "=0.0.9" specta-typescript = "=0.0.9"
specta = { version = "=2.0.0-rc.22", features = ["chrono", "derive"] } specta = { version = "=2.0.0-rc.22", features = ["chrono", "derive"] }
+5 -2
View File
@@ -2,10 +2,13 @@
"$schema": "../gen/schemas/desktop-schema.json", "$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default", "identifier": "default",
"description": "Capability for the main window", "description": "Capability for the main window",
"windows": ["main"], "windows": [
"main"
],
"permissions": [ "permissions": [
"core:default", "core:default",
"opener:default", "opener:default",
"core:path:default" "core:path:default",
"opener:allow-reveal-item-in-dir"
] ]
} }
+330
View File
@@ -0,0 +1,330 @@
//! Diagnostics: log level control and the exportable bug-report bundle.
//!
//! TRACES: UR-078 | DR-218
//!
//! The app used to forget everything it did the moment it exited. A user
//! reporting "the episode randomly restarted" was reporting the symptom of a
//! race whose evidence had been discarded microseconds later, and the only way
//! to recover it was to talk them through `adb logcat` — which is how several
//! bugs in this project's history actually got diagnosed.
//!
//! This module is the other half of that: the log is on disk, it survives a
//! crash, and the user can hand the whole thing over as one file.
//!
//! Everything written here has been through [`crate::utils::diagnostics::redact`]
//! twice — once in the log formatter, and again on the way into the archive, so
//! files written by a build that predates the formatter pass are covered too.
use std::fs;
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use log::{info, warn, LevelFilter};
use serde::{Deserialize, Serialize};
use tauri::{AppHandle, Manager};
use crate::utils::diagnostics::{redact, redact_server_url};
/// Where an export landed, so the UI can tell the user where to find it.
#[derive(Debug, Clone, Serialize, Deserialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct DiagnosticsBundle {
/// Absolute path to the written archive.
pub path: String,
pub size_bytes: u64,
/// How many log files went in, excluding the environment summary.
pub file_count: usize,
}
/// Where logs live and how verbose they currently are.
#[derive(Debug, Clone, Serialize, Deserialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct DiagnosticsInfo {
/// Directory holding the rotating log files.
pub log_dir: String,
/// Active level, lowercase: "error" | "warn" | "info" | "debug" | "trace".
pub level: String,
/// Total bytes currently held by log files.
pub total_size_bytes: u64,
}
/// Name of the file holding the user's chosen level, in the app config dir.
const LEVEL_FILE: &str = "log-level";
/// Parse a stored/user-supplied level name.
///
/// Unknown values fall back to Info rather than erroring: this is read at
/// startup, and a corrupt one-line file must not stop the app from launching.
pub fn parse_level(raw: &str) -> LevelFilter {
match raw.trim().to_ascii_lowercase().as_str() {
"error" => LevelFilter::Error,
"warn" => LevelFilter::Warn,
"debug" => LevelFilter::Debug,
"trace" => LevelFilter::Trace,
_ => LevelFilter::Info,
}
}
/// Read the persisted level, if the user has ever set one.
///
/// Persisted rather than session-only on purpose: somebody reproducing a bug
/// needs debug logging to survive *the restart that reproduces it*.
pub fn stored_level(config_dir: &Path) -> Option<LevelFilter> {
fs::read_to_string(config_dir.join(LEVEL_FILE))
.ok()
.map(|raw| parse_level(&raw))
}
fn level_name(level: LevelFilter) -> &'static str {
match level {
LevelFilter::Off => "off",
LevelFilter::Error => "error",
LevelFilter::Warn => "warn",
LevelFilter::Info => "info",
LevelFilter::Debug => "debug",
LevelFilter::Trace => "trace",
}
}
/// Current log level and where the files are.
///
/// TRACES: UR-078 | DR-218
#[tauri::command]
#[specta::specta]
pub async fn diagnostics_get_info(app: AppHandle) -> Result<DiagnosticsInfo, String> {
let log_dir = app
.path()
.app_log_dir()
.map_err(|e| format!("no log directory: {e}"))?;
let total_size_bytes = log_files(&log_dir)
.iter()
.filter_map(|p| fs::metadata(p).ok())
.map(|m| m.len())
.sum();
Ok(DiagnosticsInfo {
log_dir: log_dir.to_string_lossy().to_string(),
level: level_name(log::max_level()).to_string(),
total_size_bytes,
})
}
/// Set the log level, for this session and the next.
///
/// TRACES: UR-078 | DR-218
#[tauri::command]
#[specta::specta]
pub async fn diagnostics_set_level(app: AppHandle, level: String) -> Result<String, String> {
let parsed = parse_level(&level);
log::set_max_level(parsed);
let config_dir = app
.path()
.app_config_dir()
.map_err(|e| format!("no config directory: {e}"))?;
fs::create_dir_all(&config_dir).map_err(|e| e.to_string())?;
fs::write(config_dir.join(LEVEL_FILE), level_name(parsed)).map_err(|e| e.to_string())?;
info!("[DIAG] log level set to {}", level_name(parsed));
Ok(level_name(parsed).to_string())
}
/// Collect every log file in the log directory, newest first.
fn log_files(log_dir: &Path) -> Vec<PathBuf> {
let Ok(entries) = fs::read_dir(log_dir) else {
return Vec::new();
};
let mut files: Vec<PathBuf> = entries
.filter_map(|e| e.ok())
.map(|e| e.path())
.filter(|p| p.is_file())
.filter(|p| {
p.extension()
.is_some_and(|ext| ext == "log" || ext == "txt")
})
.collect();
files.sort();
files.reverse();
files
}
/// A short, non-identifying description of the environment.
///
/// Deliberately excludes the token, the username, and any path outside the
/// app's own directories. The server URL is reduced to scheme and host, which is
/// diagnostic (https? LAN address? reverse proxy?) without being a credential.
fn environment_summary(app: &AppHandle, server_url: Option<&str>) -> String {
let package = app.package_info();
let mut out = String::new();
out.push_str("JellyTau diagnostics\n");
out.push_str("====================\n\n");
out.push_str(&format!("app version: {}\n", package.version));
out.push_str(&format!("tauri version: {}\n", tauri::VERSION));
out.push_str(&format!("os: {}\n", std::env::consts::OS));
out.push_str(&format!("arch: {}\n", std::env::consts::ARCH));
out.push_str(&format!("debug build: {}\n", cfg!(debug_assertions)));
out.push_str(&format!(
"log level: {}\n",
level_name(log::max_level())
));
out.push_str(&format!(
"server: {}\n",
server_url.map_or("not configured".to_string(), redact_server_url)
));
out.push_str("\nNo access token, password or username is included in this file.\n");
out
}
/// Write a redacted diagnostics archive and return where it went.
///
/// # Blocking I/O
///
/// This reads and rewrites every log file. It is an `async` command so it does
/// not block the IPC thread, but it must never be called from a player event
/// callback — see the deadlock note in CLAUDE.md.
///
/// TRACES: UR-078 | DR-218
#[tauri::command]
#[specta::specta]
pub async fn diagnostics_export(
app: AppHandle,
server_url: Option<String>,
) -> Result<DiagnosticsBundle, String> {
let log_dir = app
.path()
.app_log_dir()
.map_err(|e| format!("no log directory: {e}"))?;
// Written into the app's own data directory. Choosing an arbitrary
// user-visible location would need a file dialog on desktop and a storage
// permission on Android; the UI reports the path and can reveal it.
let out_dir = app
.path()
.app_data_dir()
.map_err(|e| format!("no data directory: {e}"))?;
fs::create_dir_all(&out_dir).map_err(|e| e.to_string())?;
let archive_path = out_dir.join("jellytau-diagnostics.zip");
let file = fs::File::create(&archive_path).map_err(|e| e.to_string())?;
let mut zip = zip::ZipWriter::new(file);
let options: zip::write::FileOptions<'_, ()> =
zip::write::FileOptions::default().compression_method(zip::CompressionMethod::Deflated);
let files = log_files(&log_dir);
let mut written = 0usize;
for path in &files {
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
continue;
};
let mut contents = String::new();
match fs::File::open(path).and_then(|mut f| f.read_to_string(&mut contents)) {
Ok(_) => {}
Err(e) => {
// A log we cannot read is not a reason to produce no bundle.
warn!("[DIAG] skipping unreadable log {name}: {e}");
continue;
}
}
// Second redaction pass. The formatter already cleaned anything this
// build wrote; this covers files left by an older build.
let cleaned: String = contents.lines().map(redact).collect::<Vec<_>>().join("\n");
zip.start_file(name, options).map_err(|e| e.to_string())?;
zip.write_all(cleaned.as_bytes())
.map_err(|e| e.to_string())?;
written += 1;
}
zip.start_file("environment.txt", options)
.map_err(|e| e.to_string())?;
zip.write_all(environment_summary(&app, server_url.as_deref()).as_bytes())
.map_err(|e| e.to_string())?;
zip.finish().map_err(|e| e.to_string())?;
let size_bytes = fs::metadata(&archive_path)
.map_err(|e| e.to_string())?
.len();
info!(
"[DIAG] exported {written} log file(s), {size_bytes} bytes -> {}",
archive_path.display()
);
Ok(DiagnosticsBundle {
path: archive_path.to_string_lossy().to_string(),
size_bytes,
file_count: written,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_every_level_name_case_insensitively() {
assert_eq!(parse_level("debug"), LevelFilter::Debug);
assert_eq!(parse_level("DEBUG"), LevelFilter::Debug);
assert_eq!(parse_level(" warn\n"), LevelFilter::Warn);
assert_eq!(parse_level("error"), LevelFilter::Error);
assert_eq!(parse_level("trace"), LevelFilter::Trace);
}
#[test]
fn unknown_level_falls_back_to_info_rather_than_failing() {
// This is read at startup from a file on disk. A corrupt value must not
// stop the app launching.
assert_eq!(parse_level("banana"), LevelFilter::Info);
assert_eq!(parse_level(""), LevelFilter::Info);
}
#[test]
fn level_names_round_trip() {
for name in ["error", "warn", "info", "debug", "trace"] {
assert_eq!(level_name(parse_level(name)), name);
}
}
#[test]
fn stored_level_is_none_when_never_set() {
let dir = std::env::temp_dir().join("jellytau-diag-test-empty");
let _ = fs::create_dir_all(&dir);
let _ = fs::remove_file(dir.join(LEVEL_FILE));
assert!(stored_level(&dir).is_none());
}
#[test]
fn stored_level_reads_back_what_was_written() {
let dir = std::env::temp_dir().join("jellytau-diag-test-roundtrip");
fs::create_dir_all(&dir).unwrap();
fs::write(dir.join(LEVEL_FILE), "debug").unwrap();
assert_eq!(stored_level(&dir), Some(LevelFilter::Debug));
let _ = fs::remove_file(dir.join(LEVEL_FILE));
}
#[test]
fn log_files_ignores_non_log_files() {
let dir = std::env::temp_dir().join("jellytau-diag-test-listing");
fs::create_dir_all(&dir).unwrap();
fs::write(dir.join("jellytau.log"), "x").unwrap();
fs::write(dir.join("notes.md"), "x").unwrap();
fs::write(dir.join("jellytau.zip"), "x").unwrap();
let found = log_files(&dir);
let names: Vec<String> = found
.iter()
.filter_map(|p| p.file_name()?.to_str().map(String::from))
.collect();
assert!(names.contains(&"jellytau.log".to_string()));
// The export archive itself lives elsewhere, but never re-zip a zip.
assert!(!names.contains(&"jellytau.zip".to_string()));
assert!(!names.contains(&"notes.md".to_string()));
let _ = fs::remove_dir_all(&dir);
}
}
+2
View File
@@ -6,6 +6,7 @@ pub mod catalog;
pub mod connectivity; pub mod connectivity;
pub mod conversions; pub mod conversions;
pub mod device; pub mod device;
pub mod diagnostics;
pub mod download; pub mod download;
pub mod favorites; pub mod favorites;
pub mod library; pub mod library;
@@ -25,6 +26,7 @@ pub use catalog::*;
pub use connectivity::*; pub use connectivity::*;
pub use conversions::*; pub use conversions::*;
pub use device::*; pub use device::*;
pub use diagnostics::*;
pub use download::*; pub use download::*;
pub use library::*; pub use library::*;
pub use offline::*; pub use offline::*;
+82 -4
View File
@@ -58,6 +58,10 @@ use commands::{
// Device commands // Device commands
device_get_id, device_get_id,
device_set_id, device_set_id,
// Diagnostics commands
diagnostics_export,
diagnostics_get_info,
diagnostics_set_level,
download_album, download_album,
download_item, download_item,
download_item_and_start, download_item_and_start,
@@ -993,6 +997,11 @@ fn specta_builder() -> Builder<tauri::Wry> {
playlist_add_items, playlist_add_items,
playlist_remove_items, playlist_remove_items,
playlist_move_item, playlist_move_item,
// Diagnostics commands
// TRACES: UR-078 | DR-218
diagnostics_get_info,
diagnostics_set_level,
diagnostics_export,
// Conversion commands // Conversion commands
format_time_seconds, format_time_seconds,
format_time_seconds_long, format_time_seconds_long,
@@ -1099,12 +1108,67 @@ fn set_env_if_unset(key: &str, value: &str) {
/// through `convertFileSrc` again. /// through `convertFileSrc` again.
/// ///
/// TRACES: UR-012, UR-071 | DR-134, DR-137, DR-198 /// TRACES: UR-012, UR-071 | DR-134, DR-137, DR-198
/// Build the logging plugin.
///
/// Replaces the previous `env_logger` init, which wrote to **stdout only**. That
/// was invisible to anyone who launched from a desktop icon, and worse than
/// useless on Android: stdout is not logcat, so the Rust backend produced no
/// visible output at all on the platform carrying the hardest bugs in this
/// project's history (the autoplay deadlock, the truncated-stream restart, the
/// background-audio stall). tauri-plugin-log routes to logcat there for free.
///
/// Three decisions worth keeping:
///
/// * **Every line goes through `redact` first.** A credential must never reach
/// disk, not merely be stripped later when a bundle is exported — a file on
/// the device is already the disclosure.
/// * **The size cap is deliberate.** `RotationStrategy::KeepAll` would let a
/// long-running session fill a phone. One rotation keeps yesterday's evidence
/// without unbounded growth.
/// * **The level is read from disk.** Someone reproducing a bug needs debug
/// logging to survive the restart that reproduces it.
///
/// TRACES: UR-078 | DR-218
fn build_log_plugin() -> tauri::plugin::TauriPlugin<tauri::Wry> {
use tauri_plugin_log::{Target, TargetKind};
let mut targets = vec![
Target::new(TargetKind::Stdout),
Target::new(TargetKind::LogDir {
file_name: Some("jellytau".to_string()),
}),
];
// Rust lines in the webview console, so a developer sees both halves of the
// app in one place. Dev only -- in a release build this would ship backend
// logging into a console the user can open.
if cfg!(debug_assertions) {
targets.push(Target::new(TargetKind::Webview));
}
tauri_plugin_log::Builder::new()
.targets(targets)
.level(log::LevelFilter::Info)
.max_file_size(5 * 1024 * 1024)
.rotation_strategy(tauri_plugin_log::RotationStrategy::KeepOne)
.format(|out, message, record| {
out.finish(format_args!(
"[{}][{}] {}",
record.level(),
record.target(),
crate::utils::diagnostics::redact(&message.to_string())
))
})
.build()
}
#[cfg_attr(mobile, tauri::mobile_entry_point)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() { pub fn run() {
// Initialize logger // Crash capture before anything else, so a panic during startup is recorded
env_logger::Builder::from_default_env() // rather than vanishing with the process.
.filter_level(log::LevelFilter::Info) //
.init(); // TRACES: UR-078 | DR-218
crate::utils::diagnostics::install_panic_hook();
// On Linux, video plays through WebKitGTK's HTML5 <video> element, which uses // On Linux, video plays through WebKitGTK's HTML5 <video> element, which uses
// GStreamer as its media backend. Enable hardware-accelerated (VAAPI) decoding // GStreamer as its media backend. Enable hardware-accelerated (VAAPI) decoding
@@ -1121,6 +1185,7 @@ pub fn run() {
let invoke_handler = builder.invoke_handler(); let invoke_handler = builder.invoke_handler();
tauri::Builder::default() tauri::Builder::default()
.plugin(build_log_plugin())
.plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_os::init()) .plugin(tauri_plugin_os::init())
.invoke_handler(invoke_handler) .invoke_handler(invoke_handler)
@@ -1139,6 +1204,19 @@ pub fn run() {
// replace an installed APK, and the frontend offers the releases // replace an installed APK, and the frontend offers the releases
// page there instead. // page there instead.
// //
// Re-apply the log level the user last chose. Without this the
// picker would only affect the running session -- and the whole
// point of a persisted level is that somebody reproducing a bug
// keeps debug logging across the restart that reproduces it.
//
// TRACES: UR-078 | DR-218
if let Ok(config_dir) = app.path().app_config_dir() {
if let Some(level) = crate::commands::diagnostics::stored_level(&config_dir) {
log::set_max_level(level);
log::info!("[DIAG] restored log level from settings: {level}");
}
}
// TRACES: UR-077 | DR-217 // TRACES: UR-077 | DR-217
#[cfg(desktop)] #[cfg(desktop)]
{ {
+399
View File
@@ -0,0 +1,399 @@
//! Credential redaction and crash capture for diagnostic logs.
//!
//! TRACES: UR-078 | DR-218
//!
//! ## Why redaction lives here and not at the export
//!
//! A diagnostic bundle is something a user attaches to a public bug report. If a
//! Jellyfin access token can reach it, this feature is a credential-disclosure
//! bug with a friendly button on it.
//!
//! So [`redact`] runs in the log *formatter* — the token never reaches disk —
//! and again over every line the exporter copies, which covers files written by
//! an older build that lacked the formatter pass. Redacting only at export would
//! leave the secret sitting in a file on the device, which is exactly the thing
//! we are trying not to do.
//!
//! ## What is deliberately NOT redacted
//!
//! Server host, item ids, filenames and paths inside the app's own directories
//! all stay. They are not secrets and they are the entire diagnostic value of a
//! log: a bundle scrubbed of them is one nobody can debug anything from.
use std::borrow::Cow;
/// Replacement for a redacted value.
pub const REDACTED: &str = "[REDACTED]";
/// Query-string parameters whose value is a credential.
///
/// Jellyfin accepts the API key under several spellings depending on the
/// endpoint and client generation, and this codebase has emitted more than one
/// of them over time.
const SECRET_QUERY_KEYS: &[&str] = &["api_key", "apikey", "x-emby-token", "accesstoken"];
/// Header names whose value is a credential.
const SECRET_HEADERS: &[&str] = &[
"x-emby-token",
"x-mediabrowser-token",
"authorization",
"x-emby-authorization",
];
/// JSON keys whose value is a credential.
const SECRET_JSON_KEYS: &[&str] = &["accesstoken", "password", "token"];
/// Strip credentials from one log line.
///
/// Idempotent: redacting an already-redacted line changes nothing, which matters
/// because the exporter may re-process a file the formatter already cleaned.
pub fn redact(line: &str) -> String {
let mut out = redact_query_params(line);
out = redact_headers(&out);
out = redact_json_values(&out);
out = redact_emby_auth(&out);
out
}
/// `?api_key=abc&x=1` -> `?api_key=[REDACTED]&x=1`
///
/// The value ends at the first character that cannot be part of one: `&`
/// separates parameters, and whitespace/quotes mean the URL itself ended.
fn redact_query_params(line: &str) -> String {
let mut result = String::with_capacity(line.len());
let lower = line.to_ascii_lowercase();
let bytes = line.as_bytes();
let mut i = 0;
while i < bytes.len() {
let mut matched = None;
for key in SECRET_QUERY_KEYS {
// A key only counts when it is preceded by ? or & (or starts the
// line), so a *word* like "token" inside prose is left alone.
if lower[i..].starts_with(key) {
let prev = if i == 0 { None } else { Some(bytes[i - 1]) };
let is_param_start = matches!(prev, None | Some(b'?') | Some(b'&'));
let after = i + key.len();
if is_param_start && after < bytes.len() && bytes[after] == b'=' {
matched = Some((*key, after + 1));
break;
}
}
}
match matched {
Some((key, value_start)) => {
result.push_str(&line[i..i + key.len()]);
result.push('=');
result.push_str(REDACTED);
let mut end = value_start;
while end < bytes.len()
&& !matches!(bytes[end], b'&' | b' ' | b'"' | b'\'' | b'\t' | b')')
{
end += 1;
}
i = end;
}
None => {
// Advance one whole char, not one byte: a UTF-8 boundary split
// would panic on the slice above.
let ch = line[i..].chars().next().unwrap_or('\0');
result.push(ch);
i += ch.len_utf8();
}
}
}
result
}
/// `X-Emby-Token: abc` -> `X-Emby-Token: [REDACTED]`
///
/// Scans forward from a cursor rather than recursing on the rewritten string.
/// The obvious recursive version does not terminate: the replacement keeps the
/// header *name*, so the next call finds the same header again and recurses
/// until the stack is gone. A test provoked exactly that.
fn redact_headers(line: &str) -> String {
let mut out = String::with_capacity(line.len());
let mut rest = line;
'outer: loop {
let lower = rest.to_ascii_lowercase();
// Earliest header match in what remains, so several headers on one line
// are handled left to right.
let mut best: Option<(usize, usize)> = None;
for header in SECRET_HEADERS {
let needle = format!("{header}:");
if let Some(pos) = lower.find(&needle) {
let candidate = (pos, needle.len());
if best.is_none_or(|(best_pos, _)| pos < best_pos) {
best = Some(candidate);
}
}
}
let Some((pos, needle_len)) = best else {
break 'outer;
};
let value_start = pos + needle_len;
// The value runs to the next comma or the end of the line: reqwest's
// debug output prints several headers comma-separated on one line.
let value_end = rest[value_start..]
.find(',')
.map_or(rest.len(), |c| value_start + c);
out.push_str(&rest[..value_start]);
out.push(' ');
out.push_str(REDACTED);
// Continue strictly *after* the value just handled -- this is what makes
// the loop terminate.
rest = &rest[value_end..];
}
out.push_str(rest);
out
}
/// `"AccessToken":"abc"` -> `"AccessToken":"[REDACTED]"`
fn redact_json_values(line: &str) -> String {
let mut out = Cow::Borrowed(line);
for key in SECRET_JSON_KEYS {
loop {
let lower = out.to_ascii_lowercase();
let pattern = format!("\"{key}\"");
let Some(key_pos) = lower.find(&pattern) else {
break;
};
// Find the opening quote of the value after the colon.
let after_key = key_pos + pattern.len();
let Some(colon_rel) = out[after_key..].find(':') else {
break;
};
let value_region = after_key + colon_rel + 1;
let Some(open_rel) = out[value_region..].find('"') else {
break;
};
let open = value_region + open_rel;
let Some(close_rel) = out[open + 1..].find('"') else {
break;
};
let close = open + 1 + close_rel;
// Already redacted: stop, or this loops forever.
if &out[open + 1..close] == REDACTED {
break;
}
let mut replaced = String::with_capacity(out.len());
replaced.push_str(&out[..open + 1]);
replaced.push_str(REDACTED);
replaced.push_str(&out[close..]);
out = Cow::Owned(replaced);
}
}
out.into_owned()
}
/// `MediaBrowser Token="abc"` -> `MediaBrowser Token="[REDACTED]"`
///
/// Jellyfin's own auth header format, which is not JSON and not a query param.
fn redact_emby_auth(line: &str) -> String {
let lower = line.to_ascii_lowercase();
let Some(pos) = lower.find("token=\"") else {
return line.to_string();
};
let open = pos + "token=\"".len();
let Some(close_rel) = line[open..].find('"') else {
return line.to_string();
};
let close = open + close_rel;
if &line[open..close] == REDACTED {
return line.to_string();
}
let mut out = String::with_capacity(line.len());
out.push_str(&line[..open]);
out.push_str(REDACTED);
out.push_str(&line[close..]);
out
}
/// Reduce a server URL to scheme and host.
///
/// The host is diagnostic (is it https? a LAN address? a reverse proxy?); the
/// path and any query on it are not, and a configured URL has been seen to carry
/// a token.
pub fn redact_server_url(url: &str) -> String {
let Some(scheme_end) = url.find("://") else {
return REDACTED.to_string();
};
let after_scheme = scheme_end + 3;
let host_end = url[after_scheme..]
.find('/')
.map_or(url.len(), |slash| after_scheme + slash);
// Credentials embedded as user:pass@host must not survive.
let host = &url[after_scheme..host_end];
let host = host.rsplit('@').next().unwrap_or(host);
format!("{}://{}", &url[..scheme_end], host)
}
/// Install a panic hook that records the panic through `log::error!` before the
/// default hook runs.
///
/// # Why it chains rather than replaces
///
/// `utils::lock` installs a silencing hook around its own tests, which
/// deliberately provoke poisoned locks. Replacing the current hook here would
/// make that test output scream about panics it is intentionally causing — and,
/// more importantly, replacing whatever hook is present is how you lose the
/// backtrace the runtime would otherwise print.
pub fn install_panic_hook() {
let previous = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
// The payload is very often the formatted message of a `panic!`, so it
// goes through redaction like any other line: a panic inside the HTTP
// layer can carry a URL.
let payload = panic_payload_string(info);
let location = info
.location()
.map(|l| format!("{}:{}", l.file(), l.line()))
.unwrap_or_else(|| "unknown location".to_string());
log::error!("PANIC at {location}: {}", redact(&payload));
log::error!("backtrace:\n{}", std::backtrace::Backtrace::force_capture());
previous(info);
}));
}
/// Extract a printable message from a panic payload.
fn panic_payload_string(info: &std::panic::PanicHookInfo<'_>) -> String {
let payload = info.payload();
if let Some(s) = payload.downcast_ref::<&str>() {
(*s).to_string()
} else if let Some(s) = payload.downcast_ref::<String>() {
s.clone()
} else {
"non-string panic payload".to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn redacts_api_key_query_parameter() {
let line = "GET https://media.example.com/Items?api_key=abc123def&Limit=50";
let out = redact(line);
assert!(!out.contains("abc123def"), "token survived: {out}");
assert!(out.contains("api_key=[REDACTED]"));
// The rest of the URL is what makes the line worth keeping.
assert!(out.contains("media.example.com"));
assert!(out.contains("Limit=50"));
}
#[test]
fn redacts_every_spelling_of_the_key_parameter() {
for key in ["api_key", "ApiKey", "X-Emby-Token", "AccessToken"] {
let line = format!("https://h/Items?{key}=SECRETVALUE&x=1");
let out = redact(&line);
assert!(!out.contains("SECRETVALUE"), "{key} survived: {out}");
assert!(out.contains("x=1"), "{key} ate the next parameter: {out}");
}
}
#[test]
fn redacts_auth_headers() {
let out = redact("request headers: X-Emby-Token: abc123, Accept: application/json");
assert!(!out.contains("abc123"), "{out}");
// A following header must survive -- the value stops at the comma.
assert!(out.contains("Accept: application/json"), "{out}");
}
#[test]
fn redacts_authorization_header() {
let out = redact("Authorization: Bearer verysecrettoken");
assert!(!out.contains("verysecrettoken"), "{out}");
}
#[test]
fn redacts_json_access_token() {
let out = redact(r#"login response {"User":{"Name":"duncan"},"AccessToken":"abc123"}"#);
assert!(!out.contains("abc123"), "{out}");
// The username is not a credential and is diagnostic.
assert!(out.contains("duncan"), "{out}");
}
#[test]
fn redacts_the_emby_auth_header_form() {
let line = r#"MediaBrowser Client="JellyTau", Token="abc123xyz""#;
let out = redact(line);
assert!(!out.contains("abc123xyz"), "{out}");
assert!(out.contains("JellyTau"), "{out}");
}
#[test]
fn is_idempotent() {
// The exporter re-processes files the formatter already cleaned; a
// second pass must not corrupt them or loop.
let once = redact("https://h/Items?api_key=abc&z=1");
let twice = redact(&once);
assert_eq!(once, twice);
}
#[test]
fn leaves_ordinary_lines_untouched() {
let line = "player: advancing to next episode (item 4f2a, position 0)";
assert_eq!(redact(line), line);
}
#[test]
fn does_not_redact_the_word_token_in_prose() {
// "token" appears in comments and messages constantly. Only a real
// parameter or header assignment should trigger.
let line = "refreshing the access token because the session expired";
assert_eq!(redact(line), line);
}
#[test]
fn handles_multibyte_characters_without_panicking() {
// The scanner walks bytes; a naive implementation slices mid-character.
let line = "playing “Où est le café” from https://h/Items?api_key=abc";
let out = redact(line);
assert!(!out.contains("abc"), "{out}");
assert!(out.contains("café"), "{out}");
}
#[test]
fn server_url_keeps_scheme_and_host_only() {
assert_eq!(
redact_server_url("https://media.example.com/jellyfin?api_key=abc"),
"https://media.example.com"
);
assert_eq!(
redact_server_url("http://192.168.1.10:8096/"),
"http://192.168.1.10:8096"
);
}
#[test]
fn server_url_drops_embedded_credentials() {
// http://user:password@host is a valid URL and has been pasted into
// server-address fields before.
assert_eq!(
redact_server_url("https://duncan:hunter2@media.example.com/"),
"https://media.example.com"
);
}
#[test]
fn server_url_without_a_scheme_is_refused_rather_than_guessed() {
assert_eq!(redact_server_url("media.example.com"), REDACTED);
}
}
+1
View File
@@ -1,2 +1,3 @@
pub mod conversions; pub mod conversions;
pub mod diagnostics;
pub mod lock; pub mod lock;
+58
View File
@@ -1763,6 +1763,36 @@ async playlistRemoveItems(handle: string, playlistId: string, entryIds: string[]
async playlistMoveItem(handle: string, playlistId: string, itemId: string, newIndex: number) : Promise<null> { async playlistMoveItem(handle: string, playlistId: string, itemId: string, newIndex: number) : Promise<null> {
return await TAURI_INVOKE("playlist_move_item", { handle, playlistId, itemId, newIndex }); return await TAURI_INVOKE("playlist_move_item", { handle, playlistId, itemId, newIndex });
}, },
/**
* Current log level and where the files are.
*
* TRACES: UR-078 | DR-218
*/
async diagnosticsGetInfo() : Promise<DiagnosticsInfo> {
return await TAURI_INVOKE("diagnostics_get_info");
},
/**
* Set the log level, for this session and the next.
*
* TRACES: UR-078 | DR-218
*/
async diagnosticsSetLevel(level: string) : Promise<string> {
return await TAURI_INVOKE("diagnostics_set_level", { level });
},
/**
* Write a redacted diagnostics archive and return where it went.
*
* # Blocking I/O
*
* This reads and rewrites every log file. It is an `async` command so it does
* not block the IPC thread, but it must never be called from a player event
* callback see the deadlock note in CLAUDE.md.
*
* TRACES: UR-078 | DR-218
*/
async diagnosticsExport(serverUrl: string | null) : Promise<DiagnosticsBundle> {
return await TAURI_INVOKE("diagnostics_export", { serverUrl });
},
/** /**
* Format time in seconds to MM:SS display string * Format time in seconds to MM:SS display string
* *
@@ -2037,6 +2067,34 @@ connectionError: string | null;
* Whether we're currently checking connectivity * Whether we're currently checking connectivity
*/ */
isChecking: boolean } isChecking: boolean }
/**
* Where an export landed, so the UI can tell the user where to find it.
*/
export type DiagnosticsBundle = {
/**
* Absolute path to the written archive.
*/
path: string; sizeBytes: number;
/**
* How many log files went in, excluding the environment summary.
*/
fileCount: number }
/**
* Where logs live and how verbose they currently are.
*/
export type DiagnosticsInfo = {
/**
* Directory holding the rotating log files.
*/
logDir: string;
/**
* Active level, lowercase: "error" | "warn" | "info" | "debug" | "trace".
*/
level: string;
/**
* Total bytes currently held by log files.
*/
totalSizeBytes: number }
/** /**
* On-disk usage of downloaded content, for the Downloads surface. * On-disk usage of downloaded content, for the Downloads surface.
* *
+106
View File
@@ -10,6 +10,9 @@ import {
resetLogLevel, resetLogLevel,
resolveDefaultLogLevel, resolveDefaultLogLevel,
setLogLevel, setLogLevel,
setLogForwarder,
shouldForward,
formatForForwarding,
type LogLevel, type LogLevel,
} from "./logger"; } from "./logger";
@@ -342,3 +345,106 @@ describe("resolveDefaultLogLevel", () => {
expect(resolveDefaultLogLevel(false, false)).toBe("warn"); expect(resolveDefaultLogLevel(false, false)).toBe("warn");
}); });
}); });
/**
* Forwarding a copy of each message into the Rust log sink.
*
* TRACES: | DR-218 | UT-209
*/
describe("log forwarding", () => {
afterEach(() => {
setLogForwarder(null);
resetLogLevel();
});
it("persists info and above, but not debug", () => {
// debug is per-tick player state; forwarding it would be thousands of IPC
// calls a minute for output nobody reads.
expect(shouldForward("debug")).toBe(false);
expect(shouldForward("info")).toBe(true);
expect(shouldForward("warn")).toBe(true);
expect(shouldForward("error")).toBe(true);
});
it("hands the forwarder a tagged, stringified line", () => {
const seen: Array<[string, string]> = [];
setLogForwarder((level, message) => seen.push([level, message]));
setLogLevel("debug");
const log = createLogger("Player");
log.info("advancing to", { itemId: "4f2a" });
expect(seen).toHaveLength(1);
expect(seen[0][0]).toBe("info");
expect(seen[0][1]).toContain("[Player]");
expect(seen[0][1]).toContain("advancing to");
expect(seen[0][1]).toContain("4f2a");
});
it("does not forward a message the level filter already suppressed", () => {
const seen: string[] = [];
setLogForwarder((_level, message) => seen.push(message));
setLogLevel("error");
createLogger("Player").info("this should not be recorded anywhere");
expect(seen).toEqual([]);
});
it("does not forward debug even when debug is being displayed", () => {
const seen: string[] = [];
setLogForwarder((_level, message) => seen.push(message));
setLogLevel("debug");
const log = createLogger("Player");
log.debug("per-tick position 12.4");
log.info("kept");
expect(seen).toHaveLength(1);
expect(seen[0]).toContain("kept");
});
it("does not let a failing forwarder break the caller", () => {
// A failure to log must never become an application failure.
setLogForwarder(() => {
throw new Error("sink is on fire");
});
setLogLevel("debug");
const log = createLogger("Player");
expect(() => log.error("something went wrong")).not.toThrow();
});
it("still writes to the console when forwarding throws", () => {
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
setLogForwarder(() => {
throw new Error("sink is on fire");
});
setLogLevel("debug");
createLogger("Player").error("visible anyway");
expect(spy).toHaveBeenCalled();
spy.mockRestore();
});
});
describe("formatForForwarding", () => {
it("renders an Error as name and message, not as {}", () => {
// JSON.stringify(new Error("x")) is "{}" -- the single most common way a
// log line ends up saying nothing at all.
const line = formatForForwarding("[Api]", [new Error("network unreachable")]);
expect(line).toContain("Error: network unreachable");
});
it("survives a value that cannot be stringified", () => {
const cyclic: Record<string, unknown> = {};
cyclic.self = cyclic;
expect(() => formatForForwarding("[X]", [cyclic])).not.toThrow();
expect(formatForForwarding("[X]", [cyclic])).toContain("unserialisable");
});
it("passes strings through untouched", () => {
expect(formatForForwarding("[X]", ["plain message"])).toBe("[X] plain message");
});
});
+115
View File
@@ -196,6 +196,110 @@ export function isLevelEnabled(level: LogLevel): boolean {
return LEVEL_RANK[level] >= LEVEL_RANK[activeLevel]; return LEVEL_RANK[level] >= LEVEL_RANK[activeLevel];
} }
/**
* Persisting a copy: forwarding to the Rust log sink.
*
* TRACES: UR-078 | DR-204, DR-218
*
* The console pass-through above is unchanged and stays the primary path live,
* expandable object references in devtools are the whole reason `emit` hands
* `console` its arguments untouched. But a console nobody can read is worth
* nothing in a bug report, and on Android nobody can read it at all.
*
* So messages are *also* stringified and handed to `tauri-plugin-log`, which
* writes them to the same rotating file the Rust half writes to. One file, one
* timeline, both halves of the app in order which is what makes a race between
* them (this project's most expensive bug class) legible after the fact.
*
* Two deliberate limits:
*
* - **`info` and above only.** `debug` is per-tick player state; forwarding it
* would mean thousands of IPC calls a minute for output nobody reads.
* - **Never throws into the caller.** A logging failure must not become an
* application failure, so the forward is fire-and-forget with the rejection
* swallowed. There is nowhere useful to report a failure to log, anyway.
*/
export type LogForwarder = (level: LogLevel, message: string) => void;
/** Levels that cross the IPC boundary. */
const FORWARDED_RANK = LEVEL_RANK.info;
/** Should a message at this level be persisted, as opposed to only shown? */
export function shouldForward(level: LogLevel): boolean {
return LEVEL_RANK[level] >= FORWARDED_RANK;
}
let forwarder: LogForwarder | null = null;
let forwarderLoading = false;
/**
* Replace the sink messages are persisted to.
*
* Exists for tests, and for any host that wants to capture instead of persist.
* Passing `null` restores the default (lazy-loaded plugin) behaviour.
*/
export function setLogForwarder(next: LogForwarder | null): void {
forwarder = next;
}
/**
* Resolve the plugin the first time something needs persisting.
*
* Lazy because importing it eagerly would pull a Tauri module into every unit
* test and into SSR, neither of which has a backend to talk to. The load is
* attempted once; if it fails (a browser, a test, a webview without the plugin)
* the app keeps logging to the console and never retries.
*/
function ensureForwarder(): void {
if (forwarder || forwarderLoading) return;
forwarderLoading = true;
import("@tauri-apps/plugin-log")
.then((plugin) => {
forwarder = (level, message) => {
// `.catch(swallow)`, never a bare `void`. These return promises, and a
// `void promise` discards the *value* while leaving a rejection
// unhandled — which in a webview with no IPC (a unit test, SSR, a
// browser preview) turns every log line into an unhandled rejection.
// A logging failure must stay invisible to the application.
const swallow = () => {};
switch (level) {
case "error":
plugin.error(message).catch(swallow);
break;
case "warn":
plugin.warn(message).catch(swallow);
break;
default:
plugin.info(message).catch(swallow);
}
};
})
.catch(() => {
// No backend here. The console path above still works.
});
}
/**
* Render arguments to a single line for the persistent log.
*
* The console gets the live values; the file can only hold text. An object that
* cannot be stringified (a cycle, a DOM node) must not break logging, so it
* degrades to its type rather than throwing.
*/
export function formatForForwarding(tag: string, args: unknown[]): string {
const rendered = args.map((arg) => {
if (typeof arg === "string") return arg;
if (arg instanceof Error) return `${arg.name}: ${arg.message}`;
try {
return JSON.stringify(arg) ?? String(arg);
} catch {
return `[unserialisable ${typeof arg}]`;
}
});
return `${tag} ${rendered.join(" ")}`;
}
/** /**
* Create a logger tagged with `scope`. * Create a logger tagged with `scope`.
* *
@@ -220,6 +324,17 @@ export function createLogger(scope: string): Logger {
} else { } else {
console[method](tag, ...args); console[method](tag, ...args);
} }
// ...and a stringified copy into the persistent log. After the console call
// on purpose: whatever happens here, the developer-facing output has already
// happened.
if (!shouldForward(level)) return;
ensureForwarder();
try {
forwarder?.(level, formatForForwarding(tag, args));
} catch {
// A failure to log is not a failure worth propagating.
}
}; };
return { return {
+136 -1
View File
@@ -11,6 +11,8 @@
StreamingQuality, StreamingQuality,
VideoSettings, VideoSettings,
VolumeLevel, VolumeLevel,
DiagnosticsInfo,
DiagnosticsBundle,
} from "$lib/api/bindings"; } from "$lib/api/bindings";
import { import {
getCacheStats, getCacheStats,
@@ -30,7 +32,7 @@
import { experimentalNativeVideo } from "$lib/stores/nativeVideo"; import { experimentalNativeVideo } from "$lib/stores/nativeVideo";
import { getPlaybackCapabilities } from "$lib/services/playbackCapabilities"; import { getPlaybackCapabilities } from "$lib/services/playbackCapabilities";
import { createLogger } from "$lib/utils/logger"; import { createLogger } from "$lib/utils/logger";
import { openUrl } from "@tauri-apps/plugin-opener"; import { openUrl, revealItemInDir } from "@tauri-apps/plugin-opener";
import { import {
checkForUpdate, checkForUpdate,
installUpdate, installUpdate,
@@ -153,6 +155,7 @@
// APK, so it is offered the releases page instead of an install button. // APK, so it is offered the releases page instead of an install button.
const { platform } = await import("@tauri-apps/plugin-os"); const { platform } = await import("@tauri-apps/plugin-os");
canInstallUpdates = updateCapability(platform()) === "install"; canInstallUpdates = updateCapability(platform()) === "install";
await loadDiagnostics();
}); });
async function loadSettings() { async function loadSettings() {
@@ -480,6 +483,59 @@
updateState = "failed"; updateState = "failed";
} }
} }
// ---------------------------------------------------------------------
// Diagnostics
//
// The app used to forget everything it did on exit, which is why several
// playback bugs here needed multiple rounds of "can you reproduce it under
// adb logcat". Logs are now on disk, redacted, and exportable as one file to
// attach to a bug report. Nothing is transmitted anywhere.
//
// TRACES: UR-078 | DR-218
let diagnosticsInfo = $state<DiagnosticsInfo | null>(null);
let exportedBundle = $state<DiagnosticsBundle | null>(null);
let exporting = $state(false);
let exportError = $state<string | null>(null);
const LOG_LEVELS = ["error", "warn", "info", "debug", "trace"] as const;
async function loadDiagnostics() {
try {
diagnosticsInfo = await commands.diagnosticsGetInfo();
} catch (e) {
// A settings page that cannot read the log level is still a usable
// settings page.
log.warn("Failed to read diagnostics info:", e);
}
}
async function handleLogLevelChange(level: string) {
try {
await commands.diagnosticsSetLevel(level);
await loadDiagnostics();
} catch (e) {
log.error("Failed to set log level:", e);
}
}
async function handleExportDiagnostics() {
exporting = true;
exportError = null;
exportedBundle = null;
try {
// The server URL is passed so the backend can record its scheme and host
// in the bundle. It reduces it to those two parts — the token that may be
// on the URL never reaches the file.
const serverUrl = auth.getServerUrl();
exportedBundle = await commands.diagnosticsExport(serverUrl);
await loadDiagnostics();
} catch (e) {
log.error("Diagnostics export failed:", e);
exportError = String(e);
} finally {
exporting = false;
}
}
</script> </script>
<div class="max-w-2xl mx-auto space-y-8 p-6"> <div class="max-w-2xl mx-auto space-y-8 p-6">
@@ -1157,6 +1213,85 @@
</div> </div>
</div> </div>
<!-- Diagnostics.
Logs live on disk, redacted, and export as one file for a bug
report. Nothing is transmitted; the user attaches it themselves.
TRACES: UR-078 | DR-218 -->
<div class="border-t border-gray-700 pt-6">
<h2 class="text-2xl font-bold text-white mb-4">Diagnostics</h2>
<div class="bg-[var(--color-surface)] rounded-lg p-6 space-y-6">
<div>
<h3 class="text-lg font-semibold text-white mb-1">Detail level</h3>
<p class="text-sm text-gray-400 mb-3">
Higher detail helps diagnose a problem but writes more to disk. The setting survives a
restart, so you can turn it up and then reproduce the bug.
</p>
<div class="flex flex-wrap gap-2">
{#each LOG_LEVELS as level (level)}
<button
class="px-3 py-1.5 rounded-lg text-sm font-medium capitalize {diagnosticsInfo?.level ===
level
? 'bg-[var(--color-jellyfin)] text-white'
: 'bg-gray-700 text-gray-300'}"
onclick={() => handleLogLevelChange(level)}
>
{level}
</button>
{/each}
</div>
</div>
<div>
<div class="flex items-center justify-between gap-4">
<div>
<h3 class="text-lg font-semibold text-white">Export diagnostics</h3>
<p class="text-sm text-gray-400 mt-1">
Bundles the logs and your app/OS versions into one file to attach to a bug report.
Access tokens and passwords are removed; nothing is uploaded anywhere.
</p>
</div>
<button
class="px-4 py-2 rounded-lg bg-[var(--color-jellyfin)] text-white font-medium disabled:opacity-50 whitespace-nowrap"
onclick={handleExportDiagnostics}
disabled={exporting}
>
{exporting ? "Exporting…" : "Export"}
</button>
</div>
{#if exportedBundle}
<div class="mt-3 border border-gray-700 rounded-lg p-3 space-y-2">
<p class="text-sm text-green-400">
Saved {formatBytes(exportedBundle.sizeBytes)} from {exportedBundle.fileCount} log file{exportedBundle.fileCount ===
1
? ""
: "s"}.
</p>
<p class="text-xs text-gray-400 break-all font-mono">{exportedBundle.path}</p>
{#if canInstallUpdates}
<button
class="text-sm text-[var(--color-jellyfin)] underline"
onclick={() => revealItemInDir(exportedBundle!.path)}
>
Show in folder
</button>
{/if}
</div>
{:else if exportError}
<p class="mt-3 text-sm text-yellow-400">Couldn't export: {exportError}</p>
{/if}
</div>
{#if diagnosticsInfo}
<div class="text-xs text-gray-500 space-y-1">
<p class="font-mono break-all">{diagnosticsInfo.logDir}</p>
<p>{formatBytes(diagnosticsInfo.totalSizeBytes)} of logs held</p>
</div>
{/if}
</div>
</div>
<!-- Updates. <!-- Updates.
Desktop installs in place; Android can only be pointed at the Desktop installs in place; Android can only be pointed at the
releases page, because an app may not replace its own APK. The releases page, because an app may not replace its own APK. The