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",
"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=="],
+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-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/<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 |
---
@@ -494,6 +496,7 @@ Internal architecture, components, and application logic.
| UR-075 | - | DR-174, DR-175 |
| UR-076 | - | DR-209 |
| 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-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-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
+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": {
"@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",
+311 -2
View File
@@ -49,6 +49,17 @@ dependencies = [
"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]]
name = "ahash"
version = "0.8.12"
@@ -85,6 +96,23 @@ dependencies = [
"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]]
name = "android_system_properties"
version = "0.1.5"
@@ -159,6 +187,12 @@ dependencies = [
"derive_arbitrary",
]
[[package]]
name = "arrayvec"
version = "0.7.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"
[[package]]
name = "ascii"
version = "1.1.0"
@@ -358,6 +392,18 @@ dependencies = [
"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]]
name = "block-buffer"
version = "0.10.4"
@@ -389,6 +435,30 @@ dependencies = [
"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]]
name = "brotli"
version = "8.0.2"
@@ -416,6 +486,40 @@ version = "3.19.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "bytemuck"
version = "1.24.0"
@@ -1105,6 +1209,15 @@ dependencies = [
"simd-adler32",
]
[[package]]
name = "fern"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4316185f709b23713e41e3195f90edef7fb00c3ed4adc79769cf09cc762a3b29"
dependencies = [
"log",
]
[[package]]
name = "field-offset"
version = "0.3.6"
@@ -1183,6 +1296,12 @@ dependencies = [
"percent-encoding",
]
[[package]]
name = "funty"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c"
[[package]]
name = "futf"
version = "0.1.5"
@@ -1607,6 +1726,9 @@ name = "hashbrown"
version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
dependencies = [
"ahash 0.7.8",
]
[[package]]
name = "hashbrown"
@@ -1614,7 +1736,7 @@ version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
dependencies = [
"ahash",
"ahash 0.8.12",
]
[[package]]
@@ -2074,6 +2196,7 @@ dependencies = [
"specta-typescript",
"tauri",
"tauri-build",
"tauri-plugin-log",
"tauri-plugin-opener",
"tauri-plugin-os",
"tauri-plugin-process",
@@ -2086,6 +2209,7 @@ dependencies = [
"tokio-util",
"urlencoding",
"uuid",
"zip 2.4.2",
]
[[package]]
@@ -2305,6 +2429,9 @@ name = "log"
version = "0.4.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
dependencies = [
"value-bag",
]
[[package]]
name = "lru-slab"
@@ -2510,6 +2637,15 @@ dependencies = [
"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]]
name = "objc2"
version = "0.6.3"
@@ -3239,6 +3375,26 @@ dependencies = [
"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]]
name = "quick-xml"
version = "0.38.4"
@@ -3318,6 +3474,12 @@ version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
[[package]]
name = "radium"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09"
[[package]]
name = "rand"
version = "0.7.3"
@@ -3514,6 +3676,15 @@ version = "0.8.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58"
[[package]]
name = "rend"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "71fe3824f5629716b1589be05dacd749f6aa084c87e00e016714a8cdfccc997c"
dependencies = [
"bytecheck",
]
[[package]]
name = "reqwest"
version = "0.12.28"
@@ -3569,6 +3740,35 @@ dependencies = [
"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]]
name = "rusqlite"
version = "0.32.1"
@@ -3583,6 +3783,23 @@ dependencies = [
"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]]
name = "rustc-hash"
version = "2.1.1"
@@ -3724,6 +3941,12 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "seahash"
version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b"
[[package]]
name = "selectors"
version = "0.24.0"
@@ -3955,6 +4178,12 @@ version = "0.3.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2"
[[package]]
name = "simdutf8"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
[[package]]
name = "siphasher"
version = "0.3.11"
@@ -4257,6 +4486,12 @@ dependencies = [
"syn 2.0.112",
]
[[package]]
name = "tap"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
[[package]]
name = "tar"
version = "0.4.46"
@@ -4407,6 +4642,28 @@ dependencies = [
"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]]
name = "tauri-plugin-opener"
version = "2.5.2"
@@ -4486,7 +4743,7 @@ dependencies = [
"tokio",
"url",
"windows-sys 0.60.2",
"zip",
"zip 4.6.1",
]
[[package]]
@@ -4689,7 +4946,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134"
dependencies = [
"deranged",
"libc",
"num-conv",
"num_threads",
"powerfmt",
"serde_core",
"time-core",
@@ -5137,6 +5396,12 @@ version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
[[package]]
name = "utf8-width"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "159a7cadce548703edd50d24069bc294c5415ecab0a480e0cd1ca06d112dc94a"
[[package]]
name = "utf8_iter"
version = "1.0.4"
@@ -5161,6 +5426,12 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "value-bag"
version = "1.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "068e763e8279de7ab94b6afebded2cb701678af094feb1c12ccb061b4783c1be"
[[package]]
name = "vcpkg"
version = "0.2.15"
@@ -5973,6 +6244,15 @@ dependencies = [
"x11-dl",
]
[[package]]
name = "wyz"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed"
dependencies = [
"tap",
]
[[package]]
name = "x11"
version = "2.21.0"
@@ -6168,6 +6448,23 @@ dependencies = [
"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]]
name = "zip"
version = "4.6.1"
@@ -6186,6 +6483,18 @@ version = "1.0.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "zvariant"
version = "5.8.0"
+11
View File
@@ -62,6 +62,17 @@ sha2 = "0.10"
getrandom = "0.2"
log = "0.4"
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"] }
specta-typescript = "=0.0.9"
specta = { version = "=2.0.0-rc.22", features = ["chrono", "derive"] }
+5 -2
View File
@@ -2,10 +2,13 @@
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Capability for the main window",
"windows": ["main"],
"windows": [
"main"
],
"permissions": [
"core: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 conversions;
pub mod device;
pub mod diagnostics;
pub mod download;
pub mod favorites;
pub mod library;
@@ -25,6 +26,7 @@ pub use catalog::*;
pub use connectivity::*;
pub use conversions::*;
pub use device::*;
pub use diagnostics::*;
pub use download::*;
pub use library::*;
pub use offline::*;
+82 -4
View File
@@ -58,6 +58,10 @@ use commands::{
// Device commands
device_get_id,
device_set_id,
// Diagnostics commands
diagnostics_export,
diagnostics_get_info,
diagnostics_set_level,
download_album,
download_item,
download_item_and_start,
@@ -993,6 +997,11 @@ fn specta_builder() -> Builder<tauri::Wry> {
playlist_add_items,
playlist_remove_items,
playlist_move_item,
// Diagnostics commands
// TRACES: UR-078 | DR-218
diagnostics_get_info,
diagnostics_set_level,
diagnostics_export,
// Conversion commands
format_time_seconds,
format_time_seconds_long,
@@ -1099,12 +1108,67 @@ fn set_env_if_unset(key: &str, value: &str) {
/// through `convertFileSrc` again.
///
/// 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)]
pub fn run() {
// Initialize logger
env_logger::Builder::from_default_env()
.filter_level(log::LevelFilter::Info)
.init();
// Crash capture before anything else, so a panic during startup is recorded
// rather than vanishing with the process.
//
// TRACES: UR-078 | DR-218
crate::utils::diagnostics::install_panic_hook();
// On Linux, video plays through WebKitGTK's HTML5 <video> element, which uses
// GStreamer as its media backend. Enable hardware-accelerated (VAAPI) decoding
@@ -1121,6 +1185,7 @@ pub fn run() {
let invoke_handler = builder.invoke_handler();
tauri::Builder::default()
.plugin(build_log_plugin())
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_os::init())
.invoke_handler(invoke_handler)
@@ -1139,6 +1204,19 @@ pub fn run() {
// replace an installed APK, and the frontend offers the releases
// 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
#[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 diagnostics;
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> {
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
*
@@ -2037,6 +2067,34 @@ connectionError: string | null;
* Whether we're currently checking connectivity
*/
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.
*
+106
View File
@@ -10,6 +10,9 @@ import {
resetLogLevel,
resolveDefaultLogLevel,
setLogLevel,
setLogForwarder,
shouldForward,
formatForForwarding,
type LogLevel,
} from "./logger";
@@ -342,3 +345,106 @@ describe("resolveDefaultLogLevel", () => {
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];
}
/**
* 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`.
*
@@ -220,6 +324,17 @@ export function createLogger(scope: string): Logger {
} else {
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 {
+136 -1
View File
@@ -11,6 +11,8 @@
StreamingQuality,
VideoSettings,
VolumeLevel,
DiagnosticsInfo,
DiagnosticsBundle,
} from "$lib/api/bindings";
import {
getCacheStats,
@@ -30,7 +32,7 @@
import { experimentalNativeVideo } from "$lib/stores/nativeVideo";
import { getPlaybackCapabilities } from "$lib/services/playbackCapabilities";
import { createLogger } from "$lib/utils/logger";
import { openUrl } from "@tauri-apps/plugin-opener";
import { openUrl, revealItemInDir } from "@tauri-apps/plugin-opener";
import {
checkForUpdate,
installUpdate,
@@ -153,6 +155,7 @@
// APK, so it is offered the releases page instead of an install button.
const { platform } = await import("@tauri-apps/plugin-os");
canInstallUpdates = updateCapability(platform()) === "install";
await loadDiagnostics();
});
async function loadSettings() {
@@ -480,6 +483,59 @@
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>
<div class="max-w-2xl mx-auto space-y-8 p-6">
@@ -1157,6 +1213,85 @@
</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.
Desktop installs in place; Android can only be pointed at the
releases page, because an app may not replace its own APK. The