diff --git a/.gitea/ISSUE_TEMPLATE/bug.yaml b/.gitea/ISSUE_TEMPLATE/bug.yaml new file mode 100644 index 00000000..646dd4bc --- /dev/null +++ b/.gitea/ISSUE_TEMPLATE/bug.yaml @@ -0,0 +1,103 @@ +name: Bug report +about: Something behaves incorrectly +title: "" +labels: ["bug"] +body: + - type: markdown + attributes: + value: | + Security vulnerabilities do **not** go here — see + [SECURITY.md](../../SECURITY.md). + + - type: textarea + id: what-happened + attributes: + label: What happened + description: What you did, what you expected, and what you got instead. + placeholder: | + 1. Opened an album from the Music library + 2. Tapped the third track + 3. Playback started from the first track instead + validations: + required: true + + - type: input + id: version + attributes: + label: JellyTau version + description: Settings scrolls to the bottom, or the filename you installed. + placeholder: "0.9.1" + validations: + required: true + + - type: dropdown + id: platform + attributes: + label: Platform + options: + - Linux (AppImage) + - Linux (deb) + - Linux (rpm) + - Linux (Arch package) + - Windows + - Android + validations: + required: true + + - type: markdown + attributes: + value: | + ### Playback questions + + If this involves playback, these three answers decide which of several + very different code paths you were on. "I don't know" is a fine answer. + + - type: dropdown + id: source + attributes: + label: Was the media streaming or downloaded? + options: + - Streaming from the server + - Downloaded for offline use + - Not playback-related + validations: + required: true + + - type: dropdown + id: transcode + attributes: + label: Was the server transcoding? + description: Jellyfin's dashboard shows this while something is playing. + options: + - Direct play + - Transcoding + - Don't know + - Not playback-related + + - type: dropdown + id: kind + attributes: + label: Music or video? + options: + - Music + - Video (movie) + - Video (TV episode) + - Not playback-related + + - type: textarea + id: logs + attributes: + label: Logs + description: | + Android: `adb logcat | grep -i jellytau`. + Linux: run from a terminal, or `RUST_LOG=debug jellytau` for more. + In the app, `localStorage.setItem("jellytau:logLevel","debug")` in the + webview console turns the frontend up too. + render: shell + + - type: textarea + id: server + attributes: + label: Jellyfin server + description: Version, and anything unusual about the library layout. + placeholder: "10.9.11, series stored without season folders" diff --git a/.gitea/ISSUE_TEMPLATE/feature.yaml b/.gitea/ISSUE_TEMPLATE/feature.yaml new file mode 100644 index 00000000..cb0be786 --- /dev/null +++ b/.gitea/ISSUE_TEMPLATE/feature.yaml @@ -0,0 +1,37 @@ +name: Feature request +about: Suggest something JellyTau should do +title: "" +labels: ["enhancement"] +body: + - type: textarea + id: problem + attributes: + label: What are you trying to do? + description: | + The situation, not the solution. "I listen to albums in a fixed order and + lose my place when I switch devices" tells us more than "add a sync + button", and often has a better answer than the one you had in mind. + validations: + required: true + + - type: textarea + id: proposal + attributes: + label: What would you like it to do? + validations: + required: true + + - type: dropdown + id: platform + attributes: + label: Which platforms does this matter on? + multiple: true + options: + - Linux + - Windows + - Android + + - type: textarea + id: alternatives + attributes: + label: Anything you have tried, or how other clients handle it diff --git a/.gitea/PULL_REQUEST_TEMPLATE.md b/.gitea/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 00000000..9851d64c --- /dev/null +++ b/.gitea/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,22 @@ +## What and why + + + +## How it was verified + + + +## Checklist + +- [ ] `bun run check`, `bun run test`, `bun run format:check`, `bun run lint` +- [ ] `cargo fmt`, `cargo clippy --all-targets -- -D warnings`, `cargo test` +- [ ] `bun run check:boundary` — no Jellyfin taxonomy in the frontend +- [ ] New requirement-implementing code carries a `TRACES:` comment, and every + ID it names exists in `docs/requirements.md` (`bun run traces:validate`) +- [ ] **Bug fix:** a test that reproduces it was written *first* and observed + failing before the fix +- [ ] Android source edits were made in `src-tauri/android/src` and synced with + `scripts/sync-android-sources.sh` (never edit `gen/` directly) diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 00000000..ea3cca5d --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,47 @@ +# Code of Conduct + +## The short version + +Be decent to people. Assume the person you are talking to is acting in good +faith and knows things you do not. + +## What that means here + +**Expected:** + +- Criticise code, decisions and ideas — not the people who wrote them. +- Accept that "no" is a complete answer. This is a small project with a + maintainer who has finite time; a declined feature request is not a slight. +- Give people room to be new. Everyone was once confused by Tauri's IPC. +- Assume a bug report is someone trying to help, even when it arrives terse or + frustrated. + +**Not accepted:** + +- Harassment, personal attacks, or demeaning remarks — including about someone's + identity, background, or level of experience. +- Sexualised language or imagery, and unwelcome attention of any kind. +- Publishing someone's private information without their permission. +- Persistently derailing discussions, or badgering people who have already + answered you. + +## Scope + +This applies in the issue tracker, pull requests, commit messages and any other +project space, and to anyone taking part — maintainer included. + +## Reporting + +Email **duncan@tourolle.paris**. Reports are read by the maintainer and handled +privately. + +Responses range from a quiet word through to removing comments or blocking an +account, depending on what happened. If a report concerns the maintainer, and +that makes reporting to them pointless, you are free to say so publicly — a +project this size has no separate committee to appeal to, and pretending +otherwise would be dishonest. + +## Attribution + +Adapted in spirit from the [Contributor Covenant](https://www.contributor-covenant.org), +shortened to what a single-maintainer project can actually honour. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..9d366c8c --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,123 @@ +# Contributing to JellyTau + +Thanks for looking. This file is the short version of how the project is built +and what has to be true before a change lands. The long version lives in +[CLAUDE.md](CLAUDE.md) and [docs/architecture/](docs/architecture/README.md), +which are maintained rather than decorative — read them before a structural +change. + +## Getting set up + +Package manager is **bun**. You will also need a Rust toolchain (the exact +version is pinned in [src-tauri/rust-toolchain.toml](src-tauri/rust-toolchain.toml) +— rustup honours it automatically) and the Tauri Linux dependencies. + +```bash +bun install +bun run hooks:install # do this once: it enables the pre-commit gates +bun run tauri dev +``` + +`hooks:install` points `core.hooksPath` at [scripts/hooks/](scripts/hooks/), so +hook updates arrive with a `git pull` instead of needing a re-install. + +## What has to pass + +Everything below runs in CI, and the fast half runs in the pre-commit hook. None +of it is advisory: + +```bash +bun run check # svelte-check — 0 errors +bun run test # vitest +bun run format:check # prettier +bun run lint # eslint — 0 errors; the warning count is a ratchet +bun run check:boundary # no Jellyfin taxonomy in the frontend +bun run test:rust # cargo test +cd src-tauri && cargo fmt --all && cargo clippy --all-targets -- -D warnings +cd src-tauri && cargo deny check # advisories, licences, bans, sources +``` + +`bun run test:all` runs the whole set. + +Several of these are **ratchets** — a number that only ever moves in the +improving direction: + +| Ratchet | Where | Rule | +|---|---|---| +| eslint `--max-warnings` | [.gitea/workflows/build-and-test.yml](.gitea/workflows/build-and-test.yml) | only goes down | +| Coverage thresholds | [vitest.config.ts](vitest.config.ts) | only go up | +| Traceability coverage | [.gitea/workflows/traceability-check.yml](.gitea/workflows/traceability-check.yml) | only goes up | + +Never relax one to make a build pass. Fix the thing it caught. + +## The two rules that surprise people + +**1. Bug fixes start with a failing test.** Write a test that reproduces the bug +and *watch it fail* before you touch the fix. A test written against +already-fixed code can pass for the wrong reason and guards nothing. If the logic +is trapped in a component, extract the pure part into a plain `.ts` module and +test that — see `episodeStrip.ts` or `TrackList.logic.ts` for the pattern. + +**2. Domain vocabulary lives in Rust.** The frontend is presentation-only. It +must not encode Jellyfin's *taxonomy* — for example, the set of item types that +makes up a category like "Music". Send an opaque scope across the IPC boundary +and let the backend expand it. `bun run check:boundary` is a tripwire, not a +proof: it only flags item-type array literals, so a green run does not mean you +are clear. [docs/specs/scoped-search-boundary.md](docs/specs/scoped-search-boundary.md) +describes the leak that made this a rule. + +## Traceability + +Code that implements a requirement carries a `TRACES:` comment naming the +requirement IDs, and a tool builds the matrix from those comments: + +```rust +/// TRACES: UR-005 | DR-001 +``` + +Every ID must exist as a row in [docs/requirements.md](docs/requirements.md) — +`bun run traces:validate` fails on a typo or a stale rename. Internal helpers and +requirement-less code stay untraced; do not sprinkle IDs to raise the number. + +If you add a requirement, add its row. If you implement one, tag the code. + +## Commits and pull requests + +- Conventional-commit subjects: `fix(player): …`, `feat(updater): …`, `ci: …`. +- Explain **why** in the body, not what the diff already shows. The commit log + is the main record of why things are the way they are here, and it is used to + draft release notes. +- One concern per commit. A formatting sweep and a behaviour change in the same + commit is unreviewable. +- Rebase rather than merge-commit onto `master`. + +## Specs + +New features start from [docs/specs/SPEC-TEMPLATE.md](docs/specs/SPEC-TEMPLATE.md). +Its "Layer assignment" section is the point: each piece of logic gets placed in +Rust or the frontend *with a reason*. Review against +[docs/specs/SPEC-REVIEW-CHECKLIST.md](docs/specs/SPEC-REVIEW-CHECKLIST.md). Do +not frame a spec around "no Rust changes required" — correct placement is the +goal, not minimal backend churn. + +## CI + +CI is **Gitea Actions** (`.gitea/workflows/`), not GitHub. + +🔴 **CI installs no system tools.** Every build, test and packaging tool must +already be in the Docker builder image. If a job needs a tool the image lacks, +add it to [Dockerfile.builder](Dockerfile.builder), rebuild and push the image, +and pin the new tag — do not `apt-get` it at job time. Details in +[docs/build/ci-operations.md](docs/build/ci-operations.md). + +Fetching the project's own declared dependencies (`bun install`, cargo crates, +an advisory database) is not a toolchain install and is fine. + +## Reporting bugs + +Use the issue templates. For anything involving playback, include what the +platform was, whether the media was streaming or downloaded, and whether it was +transcoding — those three answers determine which of several code paths you were +actually on. + +Security issues go to [SECURITY.md](SECURITY.md), not the tracker. diff --git a/README.md b/README.md index 5d04954b..f14850f6 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,29 @@ For the full set of build, test, and Android helper scripts, see | Traceability tooling & CI | [docs/traceability.md](docs/traceability.md), [docs/traceability-ci.md](docs/traceability-ci.md) | | Release checklist | [docs/release-checklist.md](docs/release-checklist.md) | | UX flows | [docs/ux-flows.md](docs/ux-flows.md) | +| CI operations (builder image, secrets, runner) | [docs/build/ci-operations.md](docs/build/ci-operations.md) | + +## Contributing + +[CONTRIBUTING.md](CONTRIBUTING.md) covers the setup, the gates a change has to +pass, and the two rules that catch people out (bug fixes start with a failing +test; Jellyfin's taxonomy stays in Rust). Please also read the +[Code of Conduct](CODE_OF_CONDUCT.md). + +Found a security problem? Do not open an issue — see [SECURITY.md](SECURITY.md). + +## Verifying a download + +Every release publishes `SHA256SUMS` covering all of its artifacts, plus an SBOM +of what went into the build: + +```bash +sha256sum -c SHA256SUMS +``` + +Desktop builds update themselves from Settings → Updates, verifying each payload +against JellyTau's signing key before installing. Android installs are handled by +the system installer, so the app links to the releases page instead. ## Recommended IDE Setup diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000..30462b0c --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,60 @@ +# Security Policy + +## Reporting a vulnerability + +Email **duncan@tourolle.paris** with `[JellyTau security]` in the subject. +Please do **not** open a public issue for a vulnerability — JellyTau handles +Jellyfin credentials and media, and an unfixed issue in a public tracker is an +advisory for everyone running it. + +Include what you have: what the problem is, how to reproduce it, the version and +platform, and what you think an attacker could do with it. A rough report is +worth more than a polished one that never gets sent. + +You can expect an acknowledgement within a week. If a fix is warranted it will +ship in the next release, and you will be credited in the release notes unless +you would rather not be. + +## Supported versions + +JellyTau is a single-maintainer project without long-term support branches. +**Only the latest release receives fixes.** Desktop builds can update themselves +(Settings → Updates); on Android, install the latest APK from the releases page. + +## What is in scope + +The application and its build pipeline: + +- The Tauri backend (`src-tauri/`) and the Svelte frontend (`src/`) +- Credential storage — the system keyring and its encrypted-file fallback +- The Android player service and its JNI bridge +- The loopback media server used for downloaded playback +- The release pipeline: artifact signing, the update manifest, the builder image + +**Out of scope:** vulnerabilities in Jellyfin itself (report those to the +Jellyfin project), and issues that require an already-compromised device or a +malicious server the user deliberately configured and trusted. + +## What the project already does + +Not a guarantee, but so you know what has been considered: + +- **Credentials** never go in plaintext config: the system keyring is used where + available, with an AES-GCM encrypted file as fallback (see + [docs/architecture/09-security.md](docs/architecture/09-security.md)). +- **The webview runs under a restrictive CSP**, and the asset protocol is scoped + to the thumbnail cache directory only. +- **Path confinement** is enforced on the cache and download roots — a + server-supplied id cannot decide where a file lands (DR-210, DR-211). +- **Queries and URLs bind or encode their inputs** rather than interpolating + them (DR-212). +- **Dependencies are scanned on every build** by `cargo deny` against the RustSec + advisory database, and licence-checked against an allow-list (DR-216). +- **Releases carry `SHA256SUMS` and an SBOM**, so you can verify a download and + find out what went into it. +- **Desktop updates are signature-verified** against a key held only in CI before + anything is installed (DR-217). + +Windows installers are **not** Authenticode-signed — SmartScreen will warn on +first run. That is a cost and identity problem, not an oversight; verify the +download against `SHA256SUMS` instead. diff --git a/docs/build/ci-operations.md b/docs/build/ci-operations.md new file mode 100644 index 00000000..986f916b --- /dev/null +++ b/docs/build/ci-operations.md @@ -0,0 +1,153 @@ +# CI operations + +How the pipeline is kept working: the builder image, the secrets it needs, the +gates that must stay required, and the things that only a human with access to +the Gitea instance can do. + +CI is **Gitea Actions** (`.gitea/workflows/`) on `gitea.tourolle.paris`, not +GitHub. + +## The workflows + +| Workflow | Trigger | What it protects | +|---|---|---| +| [build-and-test.yml](../../.gitea/workflows/build-and-test.yml) | push/PR to `master` | Frontend + Rust gates, Android compile check, supply chain | +| [traceability-check.yml](../../.gitea/workflows/traceability-check.yml) | push/PR | Requirement coverage ratchet, dangling IDs | +| [build-release.yml](../../.gitea/workflows/build-release.yml) | tag `v*` | Builds, signs, publishes, and writes the update manifest | +| [publish-docs.yml](../../.gitea/workflows/publish-docs.yml) | push to `master` | Docs site on the `gitea-pages` branch | +| [runner-health.yml](../../.gitea/workflows/runner-health.yml) | daily 07:00 UTC | Runner disk before it fills | + +## 🔴 CI installs no system tools + +Every build, test and packaging **tool** lives in the Docker image the job runs +in. Never add `apt-get`, `rustup`, `sdkmanager`, or a `curl | tar -xz` of a +binary to a workflow step. + +Fetching the project's *own declared dependencies* is not a toolchain install and +is fine: `bun install`, cargo pulling crates from the lockfile, `cargo deny` +fetching the RustSec advisory database. The distinction is tool versus data. + +This rule has been broken twice, both times invisibly until something else +failed. `publish-docs.yml` downloaded mdBook from GitHub releases into +`/usr/local/bin` at job time — a hard dependency on GitHub's CDN being up +whenever docs were published. Both mdBook and the supply-chain tools are in the +image now. + +## The builder image + +`Dockerfile.builder` → `gitea.tourolle.paris/dtourolle/jellytau-builder`. +It carries: the pinned Rust toolchain plus rustfmt/clippy and the Android, +Windows-MSVC targets; bun and Node; the Android SDK/NDK and a local Gradle +distribution; Linux desktop and packaging deps (WebKitGTK, libmpv, rpm, NSIS, +cargo-xwin); and the tooling — `cargo-deny`, `cargo-cyclonedx`, `mdbook`. + +Arch packages build in a separate `Dockerfile.arch`, because `makepkg` is +Arch-specific. + +### Tags are pinned, and why + +Workflows name an **immutable dated tag** (`:2026.08`), never `:latest`. While +every job said `:latest`, rebuilding the image silently changed what every build +compiled against — including a rebuild of an old release tag, which is the +opposite of reproducible. + +`:latest` is still pushed alongside, for local `docker compose` runs and manual +pulls. + +Date tags rather than per-commit SHA tags on purpose: the runner shares a 74 GB +disk with two other projects, and SHA-tagged images accumulated there until it +filled. Keep a couple of dated tags live and prune the rest. + +### Changing the image + +The order matters — CI breaks if the workflow lands before the image exists. + +```bash +# 1. Edit Dockerfile.builder. Put new tools in the TRAILING layer: it exists so +# a tool change is a ~2 min rebuild instead of ~15. +# 2. Build and push, tagged with the new month: +./scripts/build-builder-image.sh 2026.09 +# 3. Repoint every workflow at the new tag, in the same commit as whatever +# needed the new tool: +sed -i 's|jellytau-builder:2026.08|jellytau-builder:2026.09|g' .gitea/workflows/*.yml +# 4. Verify the tools are actually in it: +docker run --rm gitea.tourolle.paris/dtourolle/jellytau-builder:2026.09 \ + -c "cargo deny --version; mdbook --version" +``` + +🔴 The Rust version is pinned in **two** places that must agree: +`RUST_VERSION` in `Dockerfile.builder` and `channel` in +`src-tauri/rust-toolchain.toml`. If they drift, rustup downloads the pinned +toolchain inside the job — a toolchain install in CI. Bump both, rebuild, push, +then merge. + +## Secrets + +Managed with the `tea` CLI (`tea actions secrets list`) or the repo settings UI. + +| Secret | Used by | Notes | +|---|---|---| +| `ANDROID_KEYSTORE_BASE64` | release | Base64 of the release keystore | +| `ANDROID_KEYSTORE_PASSWORD` | release | | +| `ANDROID_KEY_ALIAS` | release | | +| `ANDROID_KEY_PASSWORD` | release | | +| `TAURI_SIGNING_PRIVATE_KEY` | release | minisign key for the desktop updater | +| `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` | release | | +| `GITEA_TOKEN` | release, docs | PAT; falls back to the auto-provided token | + +The updater keypair's public half is committed in `src-tauri/tauri.conf.json` — +that one is meant to be public; it is what clients verify against. The private +half exists in the Gitea secret and in the maintainer's local `.env` (which is +gitignored) and at `~/.tauri/jellytau.key`. + +**Losing the private key means losing the ability to ship updates to installed +desktop clients**, because they will only accept payloads signed by the key +matching the public key they were built with. Recovering means generating a new +pair, shipping a build carrying the new public key, and telling everyone on an +older build to reinstall by hand. Back it up. + +## Required status checks + +Gitea → repo Settings → Branches → protect `master`, requiring: + +- `Run Tests` +- `Android Compile Check` +- `Supply Chain` +- the traceability job + +Without branch protection, every gate in this document is advisory: a push +straight to `master` lands whether or not CI is red. That is the state the repo +was in for its whole history before this was set up. + +## The runner + +One self-hosted runner, one ~74 GB disk shared with two other projects. It fills. +`runner-health.yml` reports usage daily and fails above 90%. + +When it does fill: + +```bash +docker image prune -a +docker volume prune -a # the -a matters: without it, NAMED volumes are kept, + # which is exactly how this filled up unnoticed +``` + +Never cache `src-tauri/target` — it is ~16 GB, and caching it under several keys +is what filled the disk at ~1.15 GB/day. The workflows cache only the cargo +registry index and `.crate` tarballs; cargo re-extracts `registry/src` for free. + +## Release verification + +The steps that catch a broken release before users do are in +[release-checklist.md](../release-checklist.md) — in particular the update path: +`latest.json` must be live on the `updater` branch, both platform entries must +carry a non-empty signature, and the previous release should be installed and +asked to update to the new one. + +## Bus factor + +The Gitea instance holds the canonical remote, the signing secrets, the container +registry and the CI runner. **It is not backed up as part of this repository, and +nothing in this repository can restore it.** That is the largest single risk to +the project — larger than any gate in this document — and the backup lives +outside it.