From f3fa45f742c086a662a319be29a1c5bd537a72c8 Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Fri, 21 Aug 2026 18:58:57 +0200 Subject: [PATCH] feat(diagnostics): persistent redacted logging and an exportable bundle 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. --- bun.lock | 5 + docs/requirements.md | 4 + docs/specs/diagnostics-and-logging.md | 192 +++++++++++++ package.json | 1 + src-tauri/Cargo.lock | 313 +++++++++++++++++++- src-tauri/Cargo.toml | 11 + src-tauri/capabilities/default.json | 7 +- src-tauri/src/commands/diagnostics.rs | 330 +++++++++++++++++++++ src-tauri/src/commands/mod.rs | 2 + src-tauri/src/lib.rs | 86 +++++- src-tauri/src/utils/diagnostics.rs | 399 ++++++++++++++++++++++++++ src-tauri/src/utils/mod.rs | 1 + src/lib/api/bindings.ts | 58 ++++ src/lib/utils/logger.test.ts | 106 +++++++ src/lib/utils/logger.ts | 115 ++++++++ src/routes/settings/+page.svelte | 137 ++++++++- 16 files changed, 1758 insertions(+), 9 deletions(-) create mode 100644 docs/specs/diagnostics-and-logging.md create mode 100644 src-tauri/src/commands/diagnostics.rs create mode 100644 src-tauri/src/utils/diagnostics.rs diff --git a/bun.lock b/bun.lock index 3fee1ecd..661e1e14 100644 --- a/bun.lock +++ b/bun.lock @@ -6,6 +6,7 @@ "name": "jellytau", "dependencies": { "@tauri-apps/api": "^2", + "@tauri-apps/plugin-log": "^2.9.0", "@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-os": "^2.3.2", "@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/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-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=="], + "@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=="], "@testing-library/dom/aria-query": ["aria-query@5.3.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A=="], diff --git a/docs/requirements.md b/docs/requirements.md index 2471fd4d..84b1e044 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -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-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-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 | --- @@ -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-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//` but returns 404 for `/releases/latest/download/`, 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 `