Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
10d77f1380 | ||
|
|
03c0b5cd17 | ||
|
|
27a995f877 | ||
|
|
f902caa07f | ||
|
|
fb3d0014ef | ||
|
|
da762da55d |
@@ -0,0 +1,266 @@
|
||||
name: '📱 Test APK'
|
||||
|
||||
# An installable APK from any branch, on demand, without cutting a release.
|
||||
#
|
||||
# Why this exists separately from build-release.yml: that workflow is tag-driven,
|
||||
# builds Linux + Windows + Android and then *creates a release*, which is not
|
||||
# what you want from a feature branch. This builds one Android APK from whatever
|
||||
# ref you dispatch it on and hands it back as an artifact.
|
||||
#
|
||||
# Deliberately `workflow_dispatch` only — no push trigger. The runner has a
|
||||
# single slot shared with two other projects, so a build on every feature-branch
|
||||
# commit would starve everything else. Dispatch it when you actually want to
|
||||
# install something.
|
||||
#
|
||||
# Both variants install as com.dtourolle.jellytau.debug ("JellyTau Debug"),
|
||||
# side by side with a real install and with their own data directory. Neither
|
||||
# needs the release signing key.
|
||||
#
|
||||
# Getting the APK to somebody else: Gitea artifacts need an account with read
|
||||
# access to download, so `publish: true` also attaches the APK to a pre-release
|
||||
# whose assets are a plain public URL. That is the only way an outside tester
|
||||
# gets the file without being given an account.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
variant:
|
||||
description: 'Which build to produce'
|
||||
required: true
|
||||
default: 'side-by-side-release'
|
||||
type: choice
|
||||
options:
|
||||
# R8-minified, exactly what ships, in the debug slot. Use this unless
|
||||
# you need stack traces: R8 stripping JNI-loaded classes has broken
|
||||
# release APKs here before, and a plain debug build cannot catch it.
|
||||
- side-by-side-release
|
||||
# Unminified. Faster, readable stack traces, but does not exercise
|
||||
# minification at all.
|
||||
- debug
|
||||
abi:
|
||||
description: 'Target ABI'
|
||||
required: true
|
||||
default: 'aarch64'
|
||||
type: choice
|
||||
options:
|
||||
- aarch64
|
||||
- armv7
|
||||
- x86_64
|
||||
publish:
|
||||
description: 'Also publish as a pre-release, for testers with no Gitea account'
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
concurrency:
|
||||
# One test build at a time; a newer dispatch supersedes an in-flight one.
|
||||
group: build-test-apk
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
# Incremental state is never reused between CI runs -- pure disk cost.
|
||||
CARGO_INCREMENTAL: 0
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build test APK (${{ inputs.variant }}, ${{ inputs.abi }})
|
||||
runs-on: linux/amd64
|
||||
container:
|
||||
image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.08.1
|
||||
env:
|
||||
ANDROID_HOME: /opt/android-sdk
|
||||
ANDROID_SDK_ROOT: /opt/android-sdk
|
||||
ANDROID_NDK_HOME: /opt/android-sdk/ndk/27.0.11902837
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
# set-version.sh derives a dev version from `git describe --tags`, so
|
||||
# the tags have to be here. A shallow checkout yields 0.0.0.
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Cache Rust dependencies
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
# Registry only -- never src-tauri/target. Same reasoning (and the
|
||||
# same key) as every other job: that directory is ~16 GB and caching
|
||||
# it filled the runner's 74 GB disk. Sharing the key means this
|
||||
# workflow restores what the others saved rather than adding a
|
||||
# fourth copy of the registry.
|
||||
path: |
|
||||
~/.cargo/registry/index
|
||||
~/.cargo/registry/cache
|
||||
~/.cargo/git/db
|
||||
key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-cargo-registry-
|
||||
|
||||
- name: Cache Node dependencies
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: |
|
||||
~/.bun/install/cache
|
||||
node_modules
|
||||
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-bun-
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install
|
||||
|
||||
# Before `android init`: it derives the generated project (including the
|
||||
# initial versionCode) from tauri.conf.json.
|
||||
- name: Stamp a dev version
|
||||
run: ./scripts/set-version.sh
|
||||
|
||||
- name: Initialize Android project
|
||||
run: bun run tauri android init
|
||||
|
||||
# Again after init: tauri.properties only exists now, and its
|
||||
# autogenerated versionCode is neither large enough nor monotonic against
|
||||
# the 1000 floor already shipped. On a branch this derives from
|
||||
# `git describe`, so a test APK always sorts above the last release.
|
||||
- name: Pin a monotonic Android versionCode
|
||||
run: ./scripts/set-version.sh
|
||||
|
||||
# Built through the same script used locally, rather than a hand-rolled
|
||||
# gradle/tauri invocation. That is what keeps CI and a developer's machine
|
||||
# producing the same thing -- and the script asserts the applicationId the
|
||||
# APK actually carries, which has silently regressed before.
|
||||
- name: Build APK
|
||||
run: |
|
||||
if [ "${{ inputs.variant }}" = "side-by-side-release" ]; then
|
||||
./scripts/build-android.sh release --debug --abi "${{ inputs.abi }}"
|
||||
else
|
||||
./scripts/build-android.sh debug --abi "${{ inputs.abi }}"
|
||||
fi
|
||||
|
||||
- name: Collect APK
|
||||
id: collect
|
||||
run: |
|
||||
mkdir -p dist/test-apk
|
||||
if [ "${{ inputs.variant }}" = "side-by-side-release" ]; then
|
||||
PATTERN='*-release.apk'
|
||||
else
|
||||
PATTERN='*-debug.apk'
|
||||
fi
|
||||
APK=$(find src-tauri/gen/android/app/build/outputs/apk -name "$PATTERN" | head -1)
|
||||
if [ -z "$APK" ]; then
|
||||
echo "❌ No APK produced for variant ${{ inputs.variant }}"
|
||||
find src-tauri/gen/android/app/build/outputs/apk -name '*.apk' || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
REF_NAME=$(echo "${GITHUB_REF#refs/heads/}" | tr '/' '-')
|
||||
OUT="dist/test-apk/jellytau-${REF_NAME}-${GITHUB_SHA::8}-${{ inputs.variant }}.apk"
|
||||
cp "$APK" "$OUT"
|
||||
|
||||
# Report what the thing actually is, not what it was meant to be.
|
||||
APKSIGNER=$(find "$ANDROID_SDK_ROOT/build-tools" -name apksigner | sort -V | tail -1)
|
||||
"$APKSIGNER" verify --print-certs "$OUT" || echo "⚠️ Could not verify signature"
|
||||
|
||||
{
|
||||
echo "### 📱 Test APK"
|
||||
echo ""
|
||||
echo "| | |"
|
||||
echo "|---|---|"
|
||||
echo "| Branch | \`${GITHUB_REF#refs/heads/}\` |"
|
||||
echo "| Commit | \`${GITHUB_SHA::8}\` |"
|
||||
echo "| Variant | \`${{ inputs.variant }}\` |"
|
||||
echo "| ABI | \`${{ inputs.abi }}\` |"
|
||||
echo "| Size | $(du -h "$OUT" | cut -f1) |"
|
||||
echo "| SHA256 | \`$(sha256sum "$OUT" | cut -d' ' -f1)\` |"
|
||||
echo ""
|
||||
echo "Installs as \`com.dtourolle.jellytau.debug\` — side by side with a real"
|
||||
echo "install, with its own data directory. Download the artifact, then:"
|
||||
echo ""
|
||||
echo '```'
|
||||
echo "adb install -r $(basename "$OUT")"
|
||||
echo '```'
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
ls -lah dist/test-apk/
|
||||
|
||||
# Deliberately NOT tagged `v*`: that pattern triggers build-release.yml,
|
||||
# which would run the whole three-platform release matrix and publish a
|
||||
# real release off a feature branch. The tag here is derived from the
|
||||
# branch name and carries no version, so nothing else reacts to it.
|
||||
#
|
||||
# This also cannot reach existing users. The desktop updater reads a
|
||||
# static latest.json from the `updater` branch, not the release list, so a
|
||||
# pre-release published here is invisible to anyone without the link.
|
||||
- name: Publish as a pre-release
|
||||
if: ${{ inputs.publish }}
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
AUTO_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -e
|
||||
command -v jq >/dev/null || { echo "❌ jq is required on the runner"; exit 1; }
|
||||
API="${GITHUB_SERVER_URL}/api/v1"
|
||||
REPO="${GITHUB_REPOSITORY}"
|
||||
TOKEN="${GITEA_TOKEN:-$AUTO_TOKEN}"
|
||||
BRANCH="${GITHUB_REF#refs/heads/}"
|
||||
TAG="test-$(echo "$BRANCH" | tr '/' '-')"
|
||||
|
||||
# printf, not a heredoc: inside a YAML block scalar every line is
|
||||
# indented, and a heredoc terminator has to sit at column 0.
|
||||
BODY=$(printf '%s\n' \
|
||||
"Test build of \`$BRANCH\` at \`${GITHUB_SHA::8}\` — **not a release**." \
|
||||
"" \
|
||||
"Installs as **JellyTau Debug** (\`com.dtourolle.jellytau.debug\`), alongside a" \
|
||||
"normal install and with its own separate data. Uninstalling it does not touch" \
|
||||
"the real app." \
|
||||
"" \
|
||||
"Variant: \`${{ inputs.variant }}\` · ABI: \`${{ inputs.abi }}\`" \
|
||||
"" \
|
||||
"Android will warn about installing from an unknown source; that is expected" \
|
||||
"for a build signed with a debug key rather than the store key.")
|
||||
|
||||
PAYLOAD=$(jq -n \
|
||||
--arg tag "$TAG" \
|
||||
--arg name "Test build: $BRANCH" \
|
||||
--arg body "$BODY" \
|
||||
--arg target "$GITHUB_SHA" \
|
||||
'{tag_name:$tag, target_commitish:$target, name:$name, body:$body, draft:false, prerelease:true}')
|
||||
|
||||
HTTP=$(curl -sS -o resp.json -w '%{http_code}' -X POST "$API/repos/$REPO/releases" \
|
||||
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" -d "$PAYLOAD")
|
||||
|
||||
if [ "$HTTP" = "201" ]; then
|
||||
RELEASE_ID=$(jq -r '.id' resp.json)
|
||||
elif [ "$HTTP" = "409" ]; then
|
||||
# Re-dispatching for the same branch replaces the previous APK rather
|
||||
# than accumulating one release per attempt.
|
||||
echo "ℹ️ Pre-release $TAG exists; reusing it"
|
||||
RELEASE_ID=$(curl -fsS "$API/repos/$REPO/releases/tags/$TAG" \
|
||||
-H "Authorization: token $TOKEN" | jq -r '.id')
|
||||
for id in $(curl -fsS "$API/repos/$REPO/releases/$RELEASE_ID/assets" \
|
||||
-H "Authorization: token $TOKEN" | jq -r '.[].id'); do
|
||||
curl -fsS -X DELETE "$API/repos/$REPO/releases/$RELEASE_ID/assets/$id" \
|
||||
-H "Authorization: token $TOKEN" >/dev/null
|
||||
done
|
||||
else
|
||||
echo "❌ Failed to create pre-release (HTTP $HTTP):"; cat resp.json; exit 1
|
||||
fi
|
||||
|
||||
for f in dist/test-apk/*.apk; do
|
||||
echo "⬆️ $(basename "$f")"
|
||||
curl -fsS -X POST \
|
||||
"$API/repos/$REPO/releases/$RELEASE_ID/assets?name=$(basename "$f")" \
|
||||
-H "Authorization: token $TOKEN" -F "attachment=@$f" >/dev/null
|
||||
done
|
||||
|
||||
{
|
||||
echo ""
|
||||
echo "**Published:** ${GITHUB_SERVER_URL}/${REPO}/releases/tag/${TAG}"
|
||||
echo ""
|
||||
echo "Public link — no Gitea account needed. Delete the release when testing is done."
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Upload APK
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: jellytau-test-apk
|
||||
path: dist/test-apk/
|
||||
retention-days: 7
|
||||
@@ -91,6 +91,9 @@ For a narrative overview of the system design, see
|
||||
| UR-079 | The app decides *what stream to play* and says so. Playing a video used to mean asking the server to re-encode it, always — a decision made nowhere, written down nowhere, and re-derived downstream by whoever needed it: the player worked out whether it had been handed a playlist by looking for `.m3u8` in the URL. So a viewer paid for a transcode of a file their device could have played untouched, and the app could not tell them which it was. Now one negotiation produces one self-describing answer — direct play, remux, or transcode; over a playlist, a plain HTTP file, or a local one — and every renderer consumes that same answer instead of guessing from a string. On Android, where the player decodes almost everything the library holds, this stops around 85% of plays from starting a transcode nobody needed | Medium | Done |
|
||||
| UR-080 | Video on the desktop plays as itself. The picture was drawn by a webview `<video>` element, which decodes little beyond h264 — so the app told the server it could accept only h264, and the server re-encoded almost everything before sending it. That was never a statement about the machine: the same machine already runs mpv for audio, which decodes essentially the whole library. Measured against a real library, 93% of desktop playback was a transcode nobody needed, against 15% on Android where a real decoder does the work. mpv now draws the picture, the app claims what it can genuinely decode, and video is sent as it was stored wherever that is possible — sparing the server the work, the network the bitrate, and the picture a generation of re-encoding | Medium | Proposed |
|
||||
| UR-081 | Playback behaves the same whichever engine renders it | High | In Progress |
|
||||
| UR-082 | A shared device holds more than one account from the same server, and changing who is using it takes a couple of taps rather than a password. Switching away leaves the account it left able to come straight back, and each account sees only its own library, its own progress and its own downloads — including offline, where the server is not there to filter | Medium | Proposed |
|
||||
| UR-083 | An account can be locked behind a short numeric code, so that on a family device the accounts that need protecting are protected and the ones that do not are one tap away. The code gates switching to that account, not what the account may watch. Repeated wrong guesses stop being answered | Medium | Proposed |
|
||||
| UR-084 | Forgetting the code is not a lockout: the account's ordinary password gets in, and a new code can be set from there | Medium | Proposed |
|
||||
| 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 |
|
||||
|
||||
---
|
||||
@@ -136,6 +139,7 @@ External system integrations and platform-specific implementations.
|
||||
| IR-031 | Android `WindowInsets` bridge: an `OnApplyWindowInsetsListener` on the decor view reports `systemBars() | displayCutout()` in CSS pixels, pushed into the WebView as `jt-inset` CSS custom properties plus a `jellytau-insets-changed` event, and pullable via the `AndroidInsets` JS bridge | Platform | UR-066 | Done (pending device verification) |
|
||||
| IR-032 | Whole-file background download of the item being played, reusing the existing resumable download worker and the Range-capable `/Videos/{id}/stream.mp4` endpoint; plus per-platform read-through caching hooks (ExoPlayer `CacheDataSource`, mpv `stream-record`) for direct-play sessions only | Storage | UR-071 | Proposed |
|
||||
| IR-033 | libmpv render-API integration for video: `vo=libmpv` driving an OpenGL FBO bound by the host toolkit, with GL entry points resolved through libepoxy. Note that libepoxy exports them as *data* symbols — there is no `glFoo` function, only an `epoxy_glFoo` variable holding a lazily-resolving pointer — so `get_proc_address` must return the pointer stored **at** that symbol; returning the symbol's own address makes mpv jump into non-executable data and take SIGSEGV on the first GL call. The `epoxy` crate resolves this correctly but is unusable, its `gl_generator` dependency pulling a yanked `xml-rs` | Playback | UR-080 | Proposed |
|
||||
| IR-034 | One downloaded file serves every account that asked for it: the download row owns the bytes, a per-user grant owns the claim, and the file is unlinked only when the last grant goes. The on-disk layout is already content-derived rather than user-derived, so this formalises what the paths already imply and stops two accounts clobbering one file | Storage | UR-082 | Proposed |
|
||||
|
||||
> **Where a UR is met by a different mechanism than its IR anticipated.** Several
|
||||
> integration requirements were written when libmpv was expected to be the single
|
||||
@@ -460,6 +464,16 @@ Internal architecture, components, and application logic.
|
||||
| DR-264 | The episode a viewer *just finished* is no longer offered as the one they are up to. Nothing records completion locally: the stop report writes a position through `storage_update_playback_progress` (which never sets `is_played`), and the cache mirror carried the server's flag not at all — so on a cache hit every episode read back as unwatched. Leaving the player with Back reloads the series page within a second of the stop report, inside the window where Jellyfin's Next Up still names the episode that just ended, and `pick_current_episode` handed it straight back: the season view kept the yellow ring and the "Up next" badge on the episode the viewer had just watched, and scrolled to it. Two halves. (a) `is_finished` — the played flag **or** a position at or past `MAX_PROGRESS_FRACTION` of the runtime, the same 95% threshold that already disqualifies an episode from counting as in-progress — replaces the bare `is_played` in the furthest-watched scan and the first-unwatched fallback, and screens the Next Up candidate: the server is one stop-report behind for a moment, the local position is not. (b) `OfflineRepository::mirror_user_data` carries `is_played` alongside the favourite flag and the position, under the same `pending_sync = 0` conflict rule, so watched state survives a cache write instead of being dropped — that flag was previously written by nothing but an explicit local toggle | Repository | UR-062 | Done |
|
||||
| DR-265 | The player's position variable keeps advancing behind a picture-in-picture window. `VideoPlayer` tracks the absolute position in its own `currentTime` rather than reading `videoElement.currentTime` at the point of use — transcoded HLS resets the element to 0 on every segment rebuild, so only the running total is meaningful — and that variable had exactly one writer while playing: a `requestAnimationFrame` loop. RAF is driven by the document being rendered, and an Android activity behind a PiP window is paused, so the loop stops while the element plays on. The `timeupdate` handler that would have covered the gap was written as a fallback "for when RAF isn't running" and gated itself on `!isPlaying`, switching itself off at precisely the moment it was the only source left. `currentTime` therefore froze at the instant PiP was entered, and every consumer froze with it: the seek bar, the ten-second progress reports, the position mirrored into Rust through `html5Adapter`, and — the reported symptom — the background-audio handoff, which resumed the audio-only stream at the PiP-entry position while the picture carried on where it really was. The gate is now `shouldApplyTimeUpdate` and turns only on the things that genuinely own the position instead: an in-flight seek, a seek-bar drag, and an element whose `readyState` is below `HAVE_CURRENT_DATA` (which reads 0 and would rewind). Both writers producing the same derived value costs nothing — the element is the authority either way | Player | UR-004, UR-041 | Done |
|
||||
| DR-266 | PiP and the background-audio handoff can no longer be armed at once, and neither can a single stale boolean end the picture. They are alternatives — one keeps the video on screen, the other throws it away — but exclusivity was enforced from one side only: arming the toggle called `setAutoEnterEnabled(false)`, while the PiP *button* stayed ungated and still worked, so pressing it left both live. What then decided between them was `isInPictureInPictureMode`, sampled once inside `MainActivity.onStop()` and passed to `background_action`. That sample is not reliable: there are orderings — the keyguard dismissing the window, the window being stashed, OEM variance in when `onPictureInPictureModeChanged(false)` lands relative to `onStop` — where the activity is stopped with a PiP window still on screen and the flag reads false. Backgrounding then meant "the app is gone" and handed a video the user was watching in the window off to audio-only. Two halves. (a) `enteringPictureInPicture` disarms background audio, because pressing PiP is an unambiguous request to keep the picture; both directions now go through one `BackgroundBehaviour` pair rather than two ad-hoc call sites. (b) `inPictureInPicture` accepts either witness — the native sample or the frontend's own latch over `jellytau-pip-entered`/`jellytau-pip-exited`. The latch cannot report a window that has closed, because both events reach the WebView through the same message queue in dispatch order, so a genuine exit is always known before the background signal that follows it. The decision itself stays in Rust; the frontend only supplies a fact it can establish more reliably than the activity can | Player | UR-040, UR-041 | Done |
|
||||
| DR-267 | `profiles_*` commands expose the accounts already stored in `users` — list, add, remove, and a startup target that decides between resuming the last account and showing the picker. `storage_get_users` and `storage_set_active_user` have existed and gone uncalled since the schema was written; what was missing was never the storage but the decision of who may switch to what, which is domain logic and stays in Rust. Adding an account authenticates against the *current* server and takes no URL, which is how the same-server constraint is enforced rather than by omitting a form field | Auth | UR-082 | Proposed |
|
||||
| DR-268 | A profile's PIN is an Argon2id hash in `user_pins` with the failure count and lockout deadline beside it, both read and written only in Rust. The PIN deliberately does **not** encrypt the access token: wrapping it would leave a locked profile unable to resume its own downloads, drain its own `sync_queue` or poll its own sessions until someone typed the code, which on a device that reboots nightly costs more than it defends against a four-digit secret. The gate is against a member of the household, and the security doc says so rather than implying at-rest protection it does not provide | Auth | UR-083 | Proposed |
|
||||
| DR-269 | A forgotten PIN falls through to the ordinary password login against the same server, after which a new PIN can be set. There is no reset token, no recovery secret and no administrator approval path — the account's own password is already the authority, and inventing a second one would be a weaker credential guarding the same thing | Auth | UR-084 | Proposed |
|
||||
| DR-270 | Switching profiles is its own operation, not a logout. `auth_logout` calls Jellyfin's logout endpoint and invalidates the token server-side, which is exactly the behaviour a switch must not have. The switch runs as a state machine over a `ProfileSession` — stop playback and drop the queue, park the outgoing user's sync queue, stop the poller, destroy the repository, flip `is_active`, rebuild — so the teardown *ordering* can be unit-tested with no player and no server. The queue cannot outlive its owner: a straggler reporting after the flip would attribute one account's viewing to another, which is silent and unrecoverable | Auth | UR-082 | Proposed |
|
||||
| DR-271 | Cache visibility is a byproduct of the write path rather than a maintained index. `save_to_cache` is the single choke point through which every cached item passes and it already holds the `user_id` it fetched for, so it stamps `user_item_visibility` in the same transaction; reads join through it. The stamp cannot disagree with the server because it *is* the record of what the server returned, and there is nothing to reconcile. It does not shrink when permissions tighten — that drift is bounded by re-deriving from `/UserViews` on unlock while online, and is stale-permissive offline by design | Repository | UR-082 | Proposed |
|
||||
| DR-272 | `offline_is_available` answers per user instead of per item. It counted completed `downloads` rows for an `item_id` with no user predicate, so every profile on the device saw every other profile's downloads as its own — the same class of leak as the shared metadata cache, in the one place where the server is not present to filter | Storage | UR-082 | Proposed |
|
||||
| DR-273 | Each profile derives its own `DeviceId` as `uuid5(device_uuid, user_id)` rather than sharing the installation's. Jellyfin identifies a *session* by device, so a shared id makes a family look like one device that keeps changing user — playback history, the Devices dashboard and the remote-control target all collapse together | Auth | UR-082 | Proposed |
|
||||
| DR-274 | Startup shows the picker only when the last-used profile has a PIN, or when more than one profile exists and the setting asks for it; otherwise it resumes exactly as before. The feature is invisible to a single-account install, which is what makes it safe to ship without a migration anyone has to think about | Auth | UR-082 | Proposed |
|
||||
| DR-275 | Idle re-lock separates "the UI is locked" from "who is the active profile", so audio keeps playing and keeps reporting as the account that started it while the screen is locked. Lockscreen transport controls keep working untouched, because nothing on a lockscreen browses or starts new content — the locked UI refuses only what reaches past the current queue. The timer starts when playback stops rather than when the UI goes quiet, and unlocking to a *different* profile stops playback. It lives in Rust beside the player state machine: it needs authoritative playback state, and a frontend timer dies with the WebView on Android | Player | UR-083 | Proposed |
|
||||
| DR-276 | The picker and PIN pad render an opaque `unlock_method` and an `UnlockOutcome` union the backend returns; the frontend never compares a PIN, counts an attempt, or infers that an account without a PIN is a child's. "Child account" is not modelled at all — a child profile is simply one with no PIN — so no role taxonomy is invented on either side of a boundary that has leaked taxonomy before | Frontend | UR-082, UR-083 | Proposed |
|
||||
| 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 |
|
||||
|
||||
---
|
||||
@@ -549,6 +563,9 @@ Internal architecture, components, and application logic.
|
||||
| UR-078 | - | DR-218 |
|
||||
| UR-079 | - | DR-225, DR-226, DR-227, DR-228, DR-229, DR-230 |
|
||||
| UR-080 | IR-033 | DR-231, DR-232, DR-233, DR-234, DR-235, DR-236, DR-237 |
|
||||
| UR-082 | IR-034 | DR-267, DR-270, DR-271, DR-272, DR-273, DR-274, DR-276 |
|
||||
| UR-083 | - | DR-268, DR-275, DR-276 |
|
||||
| UR-084 | - | DR-269 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,405 @@
|
||||
# Spec: Multi-user profiles with PIN switching
|
||||
|
||||
**Status:** Proposed
|
||||
**Requirements:** UR-082, UR-083, UR-084 → IR-034, DR-267 … DR-276
|
||||
**UX spec:** [ux-flows.md](../ux-flows.md) — new "Who's watching" section
|
||||
**Destination on completion:**
|
||||
- [09-security.md](../architecture/09-security.md) — new "Profile locking" section beside *Authentication Token Storage* (PIN gate, what it does and does not protect)
|
||||
- [01-rust-backend.md](../architecture/01-rust-backend.md) — profile switch orchestration beside the session state machine
|
||||
- [02-svelte-frontend.md](../architecture/02-svelte-frontend.md) — profile picker + lock state in the nav guard
|
||||
- [08-database-design.md](../architecture/08-database-design.md) — `user_pins`, `user_item_visibility`, `download_grants`, per-user vs device settings
|
||||
- [06-downloads-and-offline.md](../architecture/06-downloads-and-offline.md) — shared files, per-user grants, refcounted deletion
|
||||
|
||||
## Summary
|
||||
|
||||
A shared device (family TV, tablet) can hold several Jellyfin accounts **from the
|
||||
same server** and switch between them in a couple of taps. Adult accounts can set
|
||||
a numeric PIN that gates the switch; child accounts have no PIN and are one tap
|
||||
away. An adult who forgets their PIN signs in with their Jellyfin password
|
||||
instead — there is no separate reset flow.
|
||||
|
||||
The feature is **opt-in and invisible until used**: one account with no PIN
|
||||
behaves exactly as the app does today.
|
||||
|
||||
## Motivation
|
||||
|
||||
JellyTau already stores users per server ([schema.rs](../../src-tauri/src/storage/schema.rs)
|
||||
`users`), keeps a token per user in the keyring, and exposes
|
||||
`storage_get_users` / `storage_set_active_user` — none of which any UI calls. The
|
||||
only way to change account today is `auth_logout`, which calls Jellyfin's logout
|
||||
endpoint and **invalidates the token server-side**, forcing a full password login
|
||||
every time. On a living-room device shared by a family that is the difference
|
||||
between "switch to the kids' profile" and "find the password".
|
||||
|
||||
Two defects block simply exposing the existing commands, and both are the real
|
||||
work of this spec:
|
||||
|
||||
1. **The metadata cache is server-scoped, not user-scoped.** `items`, `libraries`,
|
||||
`genres` and `thumbnails` carry `server_id` but no user. Jellyfin's parental
|
||||
controls filter *server responses*, but the cache-first read path returns local
|
||||
rows before the server answers — so a child profile on a device a parent has
|
||||
browsed sees the parent's titles and artwork.
|
||||
2. **Download availability is answered per item, not per user.**
|
||||
`offline_is_available` ([offline.rs](../../src-tauri/src/commands/offline.rs))
|
||||
counts completed rows for an `item_id` with no user predicate, so a child's UI
|
||||
marks a parent's download as available and can play it offline.
|
||||
|
||||
## Layer assignment
|
||||
|
||||
| Logic / responsibility | Layer | Why it belongs there |
|
||||
|------------------------|-------|----------------------|
|
||||
| Which profiles exist, and each one's unlock method | Rust | Derived from the `users` table + PIN presence. The frontend must never infer "this is a child account" from anything; it renders an opaque `unlock_method` |
|
||||
| PIN verification, attempt counting, lockout window | Rust | A gate the frontend could skip is not a gate. The counter and the clock must live where the webview cannot reach them |
|
||||
| PIN hashing (KDF, salt, cost) | Rust | Security primitive; changes with threat model, never with UI |
|
||||
| Switch orchestration (stop player, drain sync queue, swap repository, restart poller) | Rust | Owns every piece of state being torn down; ordering is a correctness invariant |
|
||||
| Cache visibility stamping and filtering | Rust | Domain data access control. Any leak here is a content-safety bug |
|
||||
| Download grants, refcounted file deletion | Rust | Storage domain; the frontend has no concept of a file refcount |
|
||||
| Same-server constraint on adding a profile | Rust | Domain rule about what a profile *is*, not a form-validation nicety |
|
||||
| Whether to show the picker at startup | Rust | Depends on profile count + PIN presence + a stored setting, all backend state |
|
||||
| Profile picker grid, avatars, transitions | Frontend | Pure presentation |
|
||||
| PIN pad layout, digit entry, shake-on-wrong | Frontend | Input handling; changes only if the UI is redesigned |
|
||||
| "Use password instead" form | Frontend | Presentation over the existing `auth_login` |
|
||||
| Ordering of tiles (last used first) | Frontend | Presentation preference over data Rust already returns |
|
||||
|
||||
Borderline: *ordering of tiles* could be argued into Rust since `last_used_at`
|
||||
comes from the DB. Rust returns the timestamp; the frontend decides it means
|
||||
"leftmost". Tie-breaker: it changes only if the UI is redesigned.
|
||||
|
||||
## Design
|
||||
|
||||
### Threat model — state it plainly
|
||||
|
||||
The PIN is a **switching gate against a member of the household**, not at-rest
|
||||
protection against an attacker with the disk. Tokens stay in the keyring exactly
|
||||
as they are today ([credentials.rs](../../src-tauri/src/credentials.rs)); the PIN
|
||||
does **not** encrypt them.
|
||||
|
||||
This is a deliberate choice, and the rejected alternative matters enough to
|
||||
record: wrapping each token with a key derived from its PIN would resist an
|
||||
offline attacker, but a locked profile would then be *unable to act as itself* —
|
||||
no resuming its downloads after a restart, no draining its `sync_queue`, no
|
||||
session polling — until someone walked past and typed four digits. On a device
|
||||
that reboots nightly that is a worse product for a threat this feature does not
|
||||
face. A four-digit code was never going to resist an offline attack anyway.
|
||||
|
||||
Consequences to document in 09-security.md rather than discover later:
|
||||
|
||||
- Anyone with the SQLite file and keyring access has every profile's token,
|
||||
PIN or not.
|
||||
- The PIN stops a child *becoming a parent*. It does not restrict content. Content
|
||||
restriction is Jellyfin's server-side parental controls, which most self-hosters
|
||||
have never configured — the UI must say so when a PIN-less profile is created.
|
||||
|
||||
### Schema
|
||||
|
||||
```sql
|
||||
-- Migration 024
|
||||
CREATE TABLE user_pins (
|
||||
user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||
pin_hash TEXT NOT NULL, -- Argon2id PHC string; salt is embedded
|
||||
failed_count INTEGER DEFAULT 0,
|
||||
locked_until TEXT, -- RFC3339; NULL when not locked out
|
||||
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- What the server has actually shown to this user. NOT a maintained index:
|
||||
-- written as a byproduct of the cache write path, so it cannot disagree with
|
||||
-- what the server returned.
|
||||
CREATE TABLE user_item_visibility (
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
item_id TEXT NOT NULL,
|
||||
seen_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (user_id, item_id)
|
||||
);
|
||||
CREATE INDEX idx_visibility_user ON user_item_visibility(user_id);
|
||||
|
||||
-- Same, at library granularity, from each user's /UserViews.
|
||||
CREATE TABLE user_libraries (
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
library_id TEXT NOT NULL,
|
||||
PRIMARY KEY (user_id, library_id)
|
||||
);
|
||||
|
||||
-- Downloads: one file, many claimants.
|
||||
CREATE TABLE download_grants (
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
download_id INTEGER NOT NULL REFERENCES downloads(id) ON DELETE CASCADE,
|
||||
granted_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (user_id, download_id)
|
||||
);
|
||||
```
|
||||
|
||||
**Migration of existing installs is not optional.** Every current cache row and
|
||||
download predates the concept of a user. Migration 024 backfills
|
||||
`user_item_visibility` and `download_grants` for the single existing user (and,
|
||||
if somehow several `users` rows exist, for the one with `is_active = 1`).
|
||||
Without the backfill an upgrading user's library goes blank.
|
||||
|
||||
### Cache scoping — a byproduct, not an index
|
||||
|
||||
The reason this is tractable: the repository **already carries the user**.
|
||||
|
||||
```rust
|
||||
pub struct OfflineRepository {
|
||||
db_service: Arc<RusqliteService>,
|
||||
server_id: String,
|
||||
user_id: String, // already there, already used for user_data joins
|
||||
}
|
||||
```
|
||||
[offline.rs:87](../../src-tauri/src/repository/offline.rs#L87)
|
||||
|
||||
Every row enters the cache because *some specific user's* request returned it, and
|
||||
`save_to_cache` ([offline.rs:395](../../src-tauri/src/repository/offline.rs#L395))
|
||||
— the single write choke point, called only from
|
||||
[hybrid.rs](../../src-tauri/src/repository/hybrid.rs) — knows who that is. It
|
||||
stamps `user_item_visibility` in the same transaction as the item. There is no
|
||||
reconciliation job and no way for the stamp to drift from what the server said,
|
||||
because the stamp *is* the record of what the server said.
|
||||
|
||||
Reads join through it. 43 of the 64 `FROM items` sites live in
|
||||
`repository/offline.rs`, where `self.user_id` is already in scope.
|
||||
|
||||
**The 21 sites outside the repository are triaged, not blanket-scoped:**
|
||||
|
||||
| Group | Disposition |
|
||||
|-------|-------------|
|
||||
| Browse/query paths returning lists to the UI | Must scope |
|
||||
| By-id lookups from an already-authorised context (download worker resolving an item it holds a grant for; queued-row stream URL lookup) | Not scoped — authorisation happened upstream |
|
||||
| Maintenance (`smart_cache` eviction, `pinning`) | Not scoped — deliberately device-wide |
|
||||
|
||||
The triage result is recorded in 08-database-design.md, because "why isn't this
|
||||
one scoped?" is exactly what a future change gets wrong.
|
||||
|
||||
**Known limit — revocation drift.** The stamp can never show *more* than the
|
||||
server showed, but it does not shrink when a parent tightens permissions. Mitigation:
|
||||
on unlock while online, re-derive `user_libraries` from `/UserViews` and drop
|
||||
visibility rows for libraries that disappeared. Offline, the cache stays
|
||||
stale-permissive. This is a stated property, not a bug.
|
||||
|
||||
**Enforcement.** `scripts/check-cache-scope.sh` fails if `FROM items` appears
|
||||
outside an allowlist of modules. With writes funnelled and 43 reads in one file
|
||||
the allowlist is short enough to mean something — unlike `check:boundary`, which
|
||||
had to pattern-match literals. It will not catch a missed join *inside* the
|
||||
repository; it will catch a new query appearing in a random command file, which
|
||||
is the realistic drift.
|
||||
|
||||
### Downloads — shared files, per-user grants
|
||||
|
||||
The file layout already assumes sharing: paths are content-derived
|
||||
(`{base}/{series}/{S01E02 - Name}`,
|
||||
[download/mod.rs:1052](../../src-tauri/src/commands/download/mod.rs#L1052)) while
|
||||
rows are keyed `UNIQUE(item_id, user_id)` — so two profiles downloading the same
|
||||
episode already aim at one path and clobber each other. Formalising:
|
||||
|
||||
- A second profile requesting an already-downloaded item inserts a **grant**. No
|
||||
bytes transferred; immediately available.
|
||||
- `offline_is_available` joins through grants instead of counting rows per item.
|
||||
- `download_cancel` ([download/mod.rs:1300](../../src-tauri/src/commands/download/mod.rs#L1300))
|
||||
drops the caller's grant and unlinks the file **only when the last grant goes**.
|
||||
It currently deletes unconditionally, which under sharing would yank a file from
|
||||
under another profile.
|
||||
- Budget is naturally shared: the file is counted once. Eviction picks files with
|
||||
no recent access across *any* grant.
|
||||
- A grant is not an entitlement. On unlock while online, grants for items the
|
||||
profile can no longer see are dropped, alongside the visibility re-derivation.
|
||||
|
||||
### Device ID
|
||||
|
||||
[device_get_id](../../src-tauri/src/commands/device.rs#L27) mints one UUID per
|
||||
installation, sent as `DeviceId` on every request
|
||||
([client.rs:60](../../src-tauri/src/jellyfin/client.rs#L60)). Jellyfin uses it to
|
||||
identify a *session* — the Dashboard → Devices row, and the target the
|
||||
remote-control feature casts to.
|
||||
|
||||
**Decision pending an empirical test** (see Open questions). Shipping default is a
|
||||
per-profile derived ID, `uuid5(device_uuid, user_id)`, which makes each family
|
||||
member a distinct device entry so playback history attributes cleanly and the
|
||||
sessions list can tell "this TV, Dad" from "this TV, Kid". If the test shows
|
||||
Jellyfin tolerates a shared ID *and* the merged view is preferred, one line
|
||||
changes.
|
||||
|
||||
### Switch orchestration
|
||||
|
||||
`profiles_switch` is **not** `auth_logout`. Logout invalidates the token
|
||||
server-side; a switch must leave the outgoing profile able to come back with one
|
||||
tap. Ordering, in Rust, as a state machine over a `ProfileSession` so it is
|
||||
unit-testable without a player or a server:
|
||||
|
||||
1. Pause playback and tear down the queue (the queue cannot outlive its owner —
|
||||
a straggler would report the outgoing profile's episode against the incoming one).
|
||||
2. Drain or park `sync_queue` for the outgoing user.
|
||||
3. Stop the session poller; unregister MPRIS / MediaSession metadata.
|
||||
4. Destroy the repository handle.
|
||||
5. Flip `users.is_active`.
|
||||
6. Build the new repository, restart the poller, re-derive visibility and grants
|
||||
if online.
|
||||
7. Emit `profile-switched`.
|
||||
|
||||
Two hazards, both already documented in CLAUDE.md and both reached from a new
|
||||
direction here: never call blocking APIs from player event callbacks, and never
|
||||
hold a lock across a `match` scrutinee. Teardown touches every one of those paths
|
||||
at once.
|
||||
|
||||
### Lock state vs. playback
|
||||
|
||||
"Locked" and "who is the active profile" are **different state**. Re-lock (idle
|
||||
timeout, off by default) flips only the first:
|
||||
|
||||
- Audio keeps playing and keeps reporting as the profile that started it.
|
||||
- The lockscreen / MediaSession keeps full transport control over the **existing
|
||||
queue** — play, pause, seek, next, prev. Nothing on the lockscreen browses or
|
||||
starts new content, so [MediaSessionCompat](../architecture/05-platform-backends.md)
|
||||
needs no changes at all.
|
||||
- The locked UI refuses anything reaching past the current queue: browsing,
|
||||
search, new playback, downloads, settings, switching profile without the PIN.
|
||||
|
||||
Two rules keep it coherent:
|
||||
|
||||
- **Never re-lock while something is playing.** The idle timer starts when
|
||||
playback stops, not when the UI goes quiet. This removes almost all of the
|
||||
conflict on its own.
|
||||
- **Unlocking to a *different* profile stops playback.** Unlocking to the same
|
||||
profile leaves everything running.
|
||||
|
||||
The timer lives in Rust beside the player state machine: it needs authoritative
|
||||
playback state, and a frontend timer dies with the webview on Android.
|
||||
|
||||
### Startup
|
||||
|
||||
The picker appears only when the last-used profile has a PIN, **or** more than one
|
||||
profile exists and "ask who's watching" is on. Otherwise startup resumes the last
|
||||
account exactly as [auth_initialize](../../src-tauri/src/commands/auth.rs#L19)
|
||||
does today. One account, no PIN → the user never sees any of this.
|
||||
|
||||
### Commands
|
||||
|
||||
Names match the Rust fns; top-level params auto-convert to camelCase.
|
||||
|
||||
```rust
|
||||
profiles_list() -> Vec<Profile>
|
||||
profiles_startup_target() -> StartupTarget // Resume{user_id} | Picker
|
||||
profiles_unlock(user_id: String, pin: Option<String>) -> UnlockOutcome
|
||||
profiles_unlock_with_password(user_id: String, password: String) -> UnlockOutcome
|
||||
profiles_add(username: String, password: String, pin: Option<String>) -> Profile
|
||||
profiles_set_pin(user_id: String, current_pin: Option<String>, new_pin: Option<String>)
|
||||
profiles_remove(user_id: String, forget_downloads: bool)
|
||||
```
|
||||
|
||||
```rust
|
||||
#[derive(Serialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Profile {
|
||||
pub user_id: String,
|
||||
pub username: String,
|
||||
pub avatar_tag: Option<String>,
|
||||
pub unlock_method: UnlockMethod,
|
||||
pub last_used_at: Option<String>,
|
||||
pub is_active: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum UnlockMethod { None, Pin }
|
||||
|
||||
#[derive(Serialize, Type)]
|
||||
#[serde(tag = "type", rename_all = "camelCase")]
|
||||
pub enum UnlockOutcome {
|
||||
Ok { user_id: String },
|
||||
WrongPin { attempts_remaining: u32 },
|
||||
LockedOut { until: String },
|
||||
NeedsPassword,
|
||||
}
|
||||
```
|
||||
|
||||
Event: `profile-switched` (kebab-case), payload `{ userId }`.
|
||||
|
||||
`profiles_add` takes no server URL — it authenticates against the *current*
|
||||
server. That is the same-server constraint, enforced in Rust rather than by
|
||||
omitting a form field.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Multiple servers. The schema already supports it (`users.server_id`); only the
|
||||
flow is constrained. Not a schema change to undo later.
|
||||
- Per-profile content restriction. That is Jellyfin's, server-side.
|
||||
- Profile avatars uploaded locally — use the server's `avatar_tag`.
|
||||
- Biometric unlock.
|
||||
- Idle re-lock is specified above but ships **off by default** and last.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] One account with no PIN: startup, playback and downloads are byte-identical to today.
|
||||
- [ ] A second profile can be added with a password, against the current server only.
|
||||
- [ ] A PIN-less profile switches in one tap; a PIN profile requires the PIN.
|
||||
- [ ] Wrong PIN decrements attempts, then locks out with a stated window; the counter survives an app restart.
|
||||
- [ ] "Use password instead" signs in and offers to set a new PIN.
|
||||
- [ ] Switching does **not** invalidate the outgoing profile's token — switching back needs no password.
|
||||
- [ ] A child profile does not see cached items or downloads belonging to another profile, online or offline.
|
||||
- [ ] Two profiles requesting the same item produce one file and two grants; removing one grant keeps the file.
|
||||
- [ ] Upgrading an existing install shows the same library it showed before (backfill works).
|
||||
- [ ] Playback survives an idle re-lock; lockscreen transport still works; unlocking to a different profile stops it.
|
||||
- [ ] `bun run check`, `bun run test`, `bun run format:check`, `bun run lint` pass.
|
||||
- [ ] `cargo fmt` clean, `cargo clippy -D warnings` clean, `bun run test:rust` passes.
|
||||
- [ ] `bun run check:boundary` and the new `check:cache-scope` pass.
|
||||
- [ ] New code carries `// TRACES:` comments; `bun run traces:validate` passes.
|
||||
- [ ] `bindings.ts` regenerated.
|
||||
|
||||
## Testing
|
||||
|
||||
**Rust**
|
||||
- PIN: correct/incorrect/lockout/expiry-of-lockout; counter persists across a
|
||||
service restart; a cleared PIN removes the row.
|
||||
- Switch orchestration as a pure state machine over `ProfileSession` — assert the
|
||||
teardown *ordering*, no player or server needed. This is the part that would
|
||||
otherwise only ever be hand-tested.
|
||||
- Visibility: `save_to_cache` stamps; a second user's read of the same item
|
||||
returns nothing; migration backfill populates the existing user.
|
||||
- Grants: second grant transfers no bytes; cancel with two grants keeps the file;
|
||||
cancel of the last grant unlinks it.
|
||||
- Same-server: `profiles_add` against a different URL is rejected.
|
||||
|
||||
**Frontend**
|
||||
- Picker renders from `profiles_list` with no unlock-method inference of its own.
|
||||
- PIN pad calls `profiles_unlock` and renders each `UnlockOutcome` variant; it
|
||||
never compares a PIN or counts an attempt locally.
|
||||
- `tauriIntegration.test.ts` gains the new commands (camelCase param guard).
|
||||
|
||||
**Manual — the honest gap.** End-to-end multi-user needs two real accounts with
|
||||
differing library permissions on a real server; CI has neither. The state-machine
|
||||
extraction above is what keeps the risky half testable. The rest is a documented
|
||||
manual pass in the release checklist.
|
||||
|
||||
## TRACES
|
||||
|
||||
| Piece | Tag |
|
||||
|-------|-----|
|
||||
| `profiles_*` commands | `UR-082 \| DR-267` |
|
||||
| PIN hash + lockout | `UR-083 \| DR-268` |
|
||||
| Password fallback | `UR-084 \| DR-269` |
|
||||
| Switch orchestration | `UR-082 \| DR-270` |
|
||||
| Visibility stamp/filter | `UR-082 \| DR-271` |
|
||||
| Download grants | `UR-082 \| IR-034, DR-272` |
|
||||
| Per-profile device ID | `UR-082 \| DR-273` |
|
||||
| Startup target | `UR-082 \| DR-274` |
|
||||
| Idle re-lock | `UR-083 \| DR-275` |
|
||||
| Picker + PIN pad UI | `UR-082, UR-083 \| DR-276` |
|
||||
|
||||
## Open questions
|
||||
|
||||
1. **Does authenticating a second user with an in-use `DeviceId` invalidate the
|
||||
first user's token?** Two minutes with two accounts: log in as A, log in as B
|
||||
with the same DeviceId, then call `/Sessions` with A's token. Decides whether
|
||||
the per-profile device ID is a preference or a requirement.
|
||||
2. Should removing a profile default to deleting its exclusive downloads, or
|
||||
keeping them? Spec currently makes it an explicit flag.
|
||||
|
||||
## Notes for the implementer
|
||||
|
||||
- A parallel Claude session may be active in this repo — `git diff` before
|
||||
"repairing" unexpected changes.
|
||||
- `auth_logout` stays exactly as it is. Do not refactor switching through it; the
|
||||
server-side invalidation is the whole reason it is unsuitable.
|
||||
- The docs say the keyring key is `jellytau::{server_id}::{user_id}::access_token`;
|
||||
[credentials.rs:244](../../src-tauri/src/credentials.rs#L244) actually writes
|
||||
`access_token:{user_id}`. Fix the doc, not the code — changing the key format
|
||||
would strand every existing token.
|
||||
Generated
+48
@@ -176,6 +176,18 @@ dependencies = [
|
||||
"derive_arbitrary",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "argon2"
|
||||
version = "0.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
"blake2",
|
||||
"cpufeatures",
|
||||
"password-hash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ascii"
|
||||
version = "1.1.0"
|
||||
@@ -360,6 +372,12 @@ version = "0.22.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||
|
||||
[[package]]
|
||||
name = "base64ct"
|
||||
version = "1.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
|
||||
|
||||
[[package]]
|
||||
name = "bit-set"
|
||||
version = "0.8.0"
|
||||
@@ -390,6 +408,15 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "blake2"
|
||||
version = "0.10.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
|
||||
dependencies = [
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.10.4"
|
||||
@@ -913,6 +940,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||
dependencies = [
|
||||
"block-buffer",
|
||||
"crypto-common",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2184,6 +2212,7 @@ name = "jellytau"
|
||||
version = "0.11.5"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"argon2",
|
||||
"async-trait",
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
@@ -2200,6 +2229,7 @@ dependencies = [
|
||||
"libmpv-sys",
|
||||
"log",
|
||||
"ndk-context",
|
||||
"password-hash",
|
||||
"rand 0.8.7",
|
||||
"reqwest 0.12.28",
|
||||
"rusqlite",
|
||||
@@ -3065,6 +3095,17 @@ dependencies = [
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "password-hash"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
"rand_core 0.6.4",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "paste"
|
||||
version = "1.0.15"
|
||||
@@ -4284,6 +4325,12 @@ dependencies = [
|
||||
"stable_deref_trait",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha1_smol"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d"
|
||||
|
||||
[[package]]
|
||||
name = "sha2"
|
||||
version = "0.10.9"
|
||||
@@ -5593,6 +5640,7 @@ dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
"js-sys",
|
||||
"serde_core",
|
||||
"sha1_smol",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ tauri-plugin-opener = "2"
|
||||
tauri-plugin-os = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
uuid = { version = "1", features = ["v4", "v5"] }
|
||||
rand = "0.8"
|
||||
tokio = { version = "1", features = ["sync", "rt-multi-thread", "time", "fs", "io-util", "macros"] }
|
||||
tokio-util = "0.7"
|
||||
@@ -64,6 +64,12 @@ aes-gcm = "0.10"
|
||||
base64 = "0.22"
|
||||
sha2 = "0.10"
|
||||
getrandom = "0.2"
|
||||
|
||||
# Profile PIN hashing (DR-268). A switching gate against a member of the
|
||||
# household, not at-rest protection -- but a hash is the right primitive for a
|
||||
# gate, and Argon2id costs nothing extra over a weaker one.
|
||||
argon2 = "0.5"
|
||||
password-hash = { version = "0.5", features = ["alloc", "rand_core"] }
|
||||
log = "0.4"
|
||||
env_logger = "0.11"
|
||||
|
||||
|
||||
@@ -92,6 +92,43 @@ That means:
|
||||
Follow the right log stream with `./scripts/logcat.sh [debug|release]`
|
||||
(defaults to debug).
|
||||
|
||||
### Getting a test APK out of CI
|
||||
|
||||
`.gitea/workflows/build-test-apk.yml` builds one from **any branch, on demand**
|
||||
— run it from Gitea's Actions tab (`workflow_dispatch`) against the ref you want.
|
||||
It is not a release: nothing is tagged, published, or signed with the real key.
|
||||
|
||||
Two variants, both installing into the `com.dtourolle.jellytau.debug` slot:
|
||||
|
||||
| Variant | What it is | When |
|
||||
|---------|-----------|------|
|
||||
| `side-by-side-release` (default) | R8-minified, exactly what ships, signed with the debug keystore | Almost always — a plain debug build cannot catch R8 stripping JNI-loaded classes, which has broken release APKs here before |
|
||||
| `debug` | Unminified | When you need readable stack traces |
|
||||
|
||||
There is deliberately **no push trigger**: the runner has one slot shared with
|
||||
two other projects, so building on every feature-branch commit would starve
|
||||
them. The APK lands as the `jellytau-test-apk` artifact (7-day retention), named
|
||||
for the branch and short SHA, with its size and SHA256 in the run summary.
|
||||
|
||||
#### Sending a build to an outside tester
|
||||
|
||||
Gitea **artifacts require an account** with read access to download, so an
|
||||
artifact is no use to someone outside the project. Tick **`publish`** on the
|
||||
dispatch and the APK is also attached to a **pre-release**, whose assets are a
|
||||
plain public URL on a public repo — no account, no MR, no merge to `master`.
|
||||
|
||||
Two things make that safe to do from a feature branch:
|
||||
|
||||
- The tag is `test-<branch>`, **not** `v*`. Only `v*` triggers
|
||||
`build-release.yml`, so nothing else reacts to it.
|
||||
- It cannot reach existing users. The desktop updater reads a static
|
||||
`latest.json` from the `updater` branch, not the release list, so a
|
||||
pre-release published this way is invisible to anyone without the link.
|
||||
|
||||
Re-dispatching for the same branch replaces the APK on the existing
|
||||
pre-release rather than piling up one release per attempt. Delete the release
|
||||
when testing is over.
|
||||
|
||||
### Key Files
|
||||
|
||||
Player-related Kotlin files:
|
||||
|
||||
@@ -30,6 +30,39 @@ pub async fn auth_initialize(
|
||||
// Try to restore session from storage
|
||||
log::info!("[AuthManager] Restoring session from storage...");
|
||||
|
||||
// A PIN-protected profile is not restored automatically. Restoring it would
|
||||
// hand the app a working token before anybody entered the code, leaving the
|
||||
// picker as decoration over a session that was already live — the gate has
|
||||
// to be on the session itself, not on which screen is shown. The frontend
|
||||
// sees `None`, asks `profiles_startup_target`, and lands on the picker.
|
||||
//
|
||||
// TRACES: UR-083 | DR-268, DR-274
|
||||
{
|
||||
let db_service = {
|
||||
let db = database.0.lock().map_err(|e| e.to_string())?;
|
||||
std::sync::Arc::new(db.service())
|
||||
};
|
||||
let locked: Option<String> = crate::storage::db_service::DatabaseService::query_optional(
|
||||
&*db_service,
|
||||
crate::storage::db_service::Query::new(
|
||||
"SELECT u.id FROM users u
|
||||
JOIN user_pins p ON p.user_id = u.id
|
||||
WHERE u.is_active = 1",
|
||||
),
|
||||
|row| row.get(0),
|
||||
)
|
||||
.await
|
||||
.unwrap_or(None);
|
||||
|
||||
if let Some(user_id) = locked {
|
||||
log::info!(
|
||||
"[AuthManager] Active profile {} is PIN-protected; not restoring its session",
|
||||
user_id
|
||||
);
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
|
||||
// Use the existing storage_get_active_session function
|
||||
let active_session =
|
||||
match crate::commands::storage::storage_get_active_session(database, credentials).await {
|
||||
|
||||
@@ -15,6 +15,7 @@ pub mod playback_mode;
|
||||
pub mod playback_reporting;
|
||||
pub mod player;
|
||||
pub mod playlist;
|
||||
pub mod profiles;
|
||||
pub mod repository;
|
||||
pub mod sessions;
|
||||
pub mod storage;
|
||||
@@ -35,6 +36,7 @@ pub use playback_mode::*;
|
||||
pub use playback_reporting::*;
|
||||
pub use player::*;
|
||||
pub use playlist::*;
|
||||
pub use profiles::*;
|
||||
pub use repository::{RepositoryManager, RepositoryManagerWrapper, *};
|
||||
pub use sessions::*;
|
||||
pub use storage::*;
|
||||
|
||||
@@ -0,0 +1,544 @@
|
||||
//! Profile commands: who can use this device, and how they get in.
|
||||
//!
|
||||
//! The rule that shapes this whole module: **switching is not logging out**.
|
||||
//! [`auth_logout`](super::auth::auth_logout) calls Jellyfin's logout endpoint,
|
||||
//! which invalidates the token server-side — so a switch built on it would make
|
||||
//! every switch back cost a password, which is the problem this feature exists
|
||||
//! to solve. Nothing here calls it.
|
||||
//!
|
||||
//! TRACES: UR-082, UR-083, UR-084 | DR-267, DR-268, DR-269, DR-270, DR-274
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use log::{info, warn};
|
||||
use tauri::{Emitter, State};
|
||||
|
||||
use crate::commands::sessions::SessionPollerWrapper;
|
||||
use crate::commands::storage::{CredentialStoreWrapper, DatabaseWrapper};
|
||||
use crate::profiles::pin::{self, PinDecision, PinState};
|
||||
use crate::profiles::switch::{plan, SwitchStep};
|
||||
use crate::profiles::{startup_target, store, Profile, StartupTarget, UnlockOutcome};
|
||||
use crate::storage::db_service::{DatabaseService, Query, QueryParam, RusqliteService};
|
||||
|
||||
/// Settings key for "ask who's watching on start".
|
||||
const ASK_ON_START_KEY: &str = "profiles_ask_on_start";
|
||||
|
||||
fn service(db: &State<'_, DatabaseWrapper>) -> Result<Arc<RusqliteService>, String> {
|
||||
let database = db.0.lock().map_err(|e| e.to_string())?;
|
||||
Ok(Arc::new(database.service()))
|
||||
}
|
||||
|
||||
/// The server this device is signed in to, as `(server_id, server_url)`.
|
||||
///
|
||||
/// Every profile operation is scoped to it — this is where the same-server
|
||||
/// constraint is actually enforced, rather than by omitting a URL field from a
|
||||
/// form.
|
||||
///
|
||||
/// The fallback to the `servers` table is not a convenience. When the last-used
|
||||
/// profile has a PIN, `auth_initialize` deliberately does **not** restore its
|
||||
/// session, so at startup there is no in-memory session to read — and the picker
|
||||
/// still has to know which server's profiles to list. Reading it from storage is
|
||||
/// what lets the PIN gate a real thing rather than just a screen.
|
||||
async fn current_server(
|
||||
db: &Arc<RusqliteService>,
|
||||
auth_manager: &State<'_, super::auth::AuthManagerWrapper>,
|
||||
) -> Result<(String, String), String> {
|
||||
if let Some(session) = auth_manager.0.get_session().await {
|
||||
return Ok((session.server_id, session.server_url));
|
||||
}
|
||||
|
||||
db.query_optional(
|
||||
Query::new("SELECT id, url FROM servers ORDER BY last_connected_at DESC LIMIT 1"),
|
||||
|row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
.ok_or_else(|| "No server connected".to_string())
|
||||
}
|
||||
|
||||
async fn ask_on_start(db: &Arc<RusqliteService>) -> bool {
|
||||
let query = Query::with_params(
|
||||
"SELECT value FROM app_settings WHERE key = ?",
|
||||
vec![QueryParam::String(ASK_ON_START_KEY.to_string())],
|
||||
);
|
||||
db.query_optional(query, |row| row.get::<_, String>(0))
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// List the accounts this device knows for the current server.
|
||||
///
|
||||
/// TRACES: UR-082 | DR-267
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn profiles_list(
|
||||
db: State<'_, DatabaseWrapper>,
|
||||
auth_manager: State<'_, super::auth::AuthManagerWrapper>,
|
||||
) -> Result<Vec<Profile>, String> {
|
||||
let svc = service(&db)?;
|
||||
let (server_id, _) = current_server(&svc, &auth_manager).await?;
|
||||
store::list_profiles(&svc, &server_id).await
|
||||
}
|
||||
|
||||
/// Whether startup should resume an account or ask who is watching.
|
||||
///
|
||||
/// The decision is backend state, so the frontend asks rather than computes it.
|
||||
///
|
||||
/// TRACES: UR-082 | DR-274
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn profiles_startup_target(
|
||||
db: State<'_, DatabaseWrapper>,
|
||||
auth_manager: State<'_, super::auth::AuthManagerWrapper>,
|
||||
) -> Result<StartupTarget, String> {
|
||||
let svc = service(&db)?;
|
||||
let (server_id, _) = match current_server(&svc, &auth_manager).await {
|
||||
Ok(pair) => pair,
|
||||
// Nothing signed in yet: the picker doubles as first-run login.
|
||||
Err(_) => return Ok(StartupTarget::Picker),
|
||||
};
|
||||
let profiles = store::list_profiles(&svc, &server_id).await?;
|
||||
Ok(startup_target(&profiles, ask_on_start(&svc).await))
|
||||
}
|
||||
|
||||
/// Read the "ask who's watching on start" setting.
|
||||
///
|
||||
/// Separate from [`profiles_startup_target`] on purpose: the target can be
|
||||
/// `Picker` for reasons that have nothing to do with this setting — a
|
||||
/// PIN-protected last profile always asks — so deriving the toggle's position
|
||||
/// from it would show the user a switch that does not describe what it controls.
|
||||
///
|
||||
/// TRACES: UR-082 | DR-274
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn profiles_get_ask_on_start(db: State<'_, DatabaseWrapper>) -> Result<bool, String> {
|
||||
let svc = service(&db)?;
|
||||
Ok(ask_on_start(&svc).await)
|
||||
}
|
||||
|
||||
/// Turn "ask who's watching on start" on or off.
|
||||
///
|
||||
/// TRACES: UR-082 | DR-274
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn profiles_set_ask_on_start(
|
||||
db: State<'_, DatabaseWrapper>,
|
||||
enabled: bool,
|
||||
) -> Result<(), String> {
|
||||
let svc = service(&db)?;
|
||||
let query = Query::with_params(
|
||||
"INSERT INTO app_settings (key, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP",
|
||||
vec![
|
||||
QueryParam::String(ASK_ON_START_KEY.to_string()),
|
||||
QueryParam::String(if enabled { "1" } else { "0" }.to_string()),
|
||||
],
|
||||
);
|
||||
svc.execute(query).await.map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Enter a profile, with its PIN if it has one.
|
||||
///
|
||||
/// A profile with no PIN ignores whatever `pin` was passed — the frontend cannot
|
||||
/// invent a lock the backend does not have, and cannot skip one it does.
|
||||
///
|
||||
/// TRACES: UR-082, UR-083 | DR-267, DR-268, DR-270
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn profiles_unlock(
|
||||
app: tauri::AppHandle,
|
||||
db: State<'_, DatabaseWrapper>,
|
||||
creds: State<'_, CredentialStoreWrapper>,
|
||||
auth_manager: State<'_, super::auth::AuthManagerWrapper>,
|
||||
repository_manager: State<'_, super::repository::RepositoryManagerWrapper>,
|
||||
session_poller: State<'_, SessionPollerWrapper>,
|
||||
user_id: String,
|
||||
pin_code: Option<String>,
|
||||
) -> Result<UnlockOutcome, String> {
|
||||
let svc = service(&db)?;
|
||||
|
||||
let profile = store::get_profile(&svc, &user_id)
|
||||
.await?
|
||||
.ok_or_else(|| format!("Unknown profile: {}", user_id))?;
|
||||
|
||||
// Same-server constraint, checked at the point of use rather than trusted
|
||||
// from the caller.
|
||||
let (server_id, _) = current_server(&svc, &auth_manager).await?;
|
||||
if profile.server_id != server_id {
|
||||
return Err("Profile belongs to a different server".to_string());
|
||||
}
|
||||
|
||||
if let Some((hash, state)) = store::get_pin(&svc, &user_id).await? {
|
||||
let candidate = pin_code.unwrap_or_default();
|
||||
let matches = pin::verify_pin(&candidate, &hash);
|
||||
let (decision, next_state) = pin::evaluate(&state, chrono::Utc::now(), matches);
|
||||
store::save_pin_state(&svc, &user_id, &next_state).await?;
|
||||
|
||||
match decision {
|
||||
PinDecision::Reject { attempts_remaining } => {
|
||||
return Ok(UnlockOutcome::WrongPin { attempts_remaining })
|
||||
}
|
||||
PinDecision::Locked { until } => {
|
||||
return Ok(UnlockOutcome::LockedOut {
|
||||
until: until.to_rfc3339(),
|
||||
})
|
||||
}
|
||||
PinDecision::Accept => {}
|
||||
}
|
||||
}
|
||||
|
||||
let outgoing = active_user_id(&svc).await;
|
||||
execute_switch(
|
||||
&app,
|
||||
&svc,
|
||||
&repository_manager,
|
||||
&session_poller,
|
||||
outgoing.as_deref(),
|
||||
&user_id,
|
||||
)
|
||||
.await?;
|
||||
adopt_session(db, creds, &auth_manager).await?;
|
||||
|
||||
Ok(UnlockOutcome::Ok { user_id })
|
||||
}
|
||||
|
||||
/// Enter a profile with its Jellyfin password, for someone who has forgotten
|
||||
/// their PIN.
|
||||
///
|
||||
/// There is deliberately no reset token and no recovery secret: the account's
|
||||
/// own password is already the authority over it, and a second credential
|
||||
/// guarding the same thing would only be a weaker one. A successful password
|
||||
/// entry also clears the lockout, which is what makes a forgotten PIN a
|
||||
/// detour rather than a dead end.
|
||||
///
|
||||
/// TRACES: UR-084 | DR-269
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn profiles_unlock_with_password(
|
||||
app: tauri::AppHandle,
|
||||
db: State<'_, DatabaseWrapper>,
|
||||
creds: State<'_, CredentialStoreWrapper>,
|
||||
auth_manager: State<'_, super::auth::AuthManagerWrapper>,
|
||||
repository_manager: State<'_, super::repository::RepositoryManagerWrapper>,
|
||||
session_poller: State<'_, SessionPollerWrapper>,
|
||||
user_id: String,
|
||||
password: String,
|
||||
device_id: String,
|
||||
) -> Result<UnlockOutcome, String> {
|
||||
let svc = service(&db)?;
|
||||
let profile = store::get_profile(&svc, &user_id)
|
||||
.await?
|
||||
.ok_or_else(|| format!("Unknown profile: {}", user_id))?;
|
||||
|
||||
let (server_id, server_url) = current_server(&svc, &auth_manager).await?;
|
||||
if profile.server_id != server_id {
|
||||
return Err("Profile belongs to a different server".to_string());
|
||||
}
|
||||
|
||||
let result = auth_manager
|
||||
.0
|
||||
.login(&server_url, &profile.username, &password, &device_id)
|
||||
.await?;
|
||||
|
||||
if result.user.id != user_id {
|
||||
return Err("Signed in as a different account".to_string());
|
||||
}
|
||||
|
||||
save_token(&creds, &user_id, &result.access_token)?;
|
||||
|
||||
// The password got in, so the PIN counters have served their purpose.
|
||||
store::save_pin_state(&svc, &user_id, &PinState::fresh()).await?;
|
||||
|
||||
let outgoing = active_user_id(&svc).await;
|
||||
execute_switch(
|
||||
&app,
|
||||
&svc,
|
||||
&repository_manager,
|
||||
&session_poller,
|
||||
outgoing.as_deref(),
|
||||
&user_id,
|
||||
)
|
||||
.await?;
|
||||
adopt_session(db, creds, &auth_manager).await?;
|
||||
|
||||
Ok(UnlockOutcome::Ok { user_id })
|
||||
}
|
||||
|
||||
/// Add another account from the **current** server to this device.
|
||||
///
|
||||
/// Takes no server URL. That is the same-server constraint expressed as a
|
||||
/// signature rather than as form validation: there is no way to ask this command
|
||||
/// for an account somewhere else.
|
||||
///
|
||||
/// TRACES: UR-082 | DR-267
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn profiles_add(
|
||||
db: State<'_, DatabaseWrapper>,
|
||||
creds: State<'_, CredentialStoreWrapper>,
|
||||
auth_manager: State<'_, super::auth::AuthManagerWrapper>,
|
||||
username: String,
|
||||
password: String,
|
||||
pin_code: Option<String>,
|
||||
device_id: String,
|
||||
) -> Result<Profile, String> {
|
||||
if let Some(code) = &pin_code {
|
||||
pin::validate_pin(code)?;
|
||||
}
|
||||
|
||||
let svc = service(&db)?;
|
||||
let (server_id, server_url) = current_server(&svc, &auth_manager).await?;
|
||||
|
||||
let result = auth_manager
|
||||
.0
|
||||
.login(&server_url, &username, &password, &device_id)
|
||||
.await?;
|
||||
|
||||
let insert = Query::with_params(
|
||||
"INSERT INTO users (id, server_id, username, last_login_at)
|
||||
VALUES (?, ?, ?, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
server_id = excluded.server_id,
|
||||
username = excluded.username,
|
||||
last_login_at = CURRENT_TIMESTAMP",
|
||||
vec![
|
||||
QueryParam::String(result.user.id.clone()),
|
||||
QueryParam::String(server_id.clone()),
|
||||
QueryParam::String(result.user.name.clone()),
|
||||
],
|
||||
);
|
||||
svc.execute(insert).await.map_err(|e| e.to_string())?;
|
||||
|
||||
save_token(&creds, &result.user.id, &result.access_token)?;
|
||||
|
||||
if let Some(code) = pin_code {
|
||||
let hash = pin::hash_pin(&code)?;
|
||||
store::set_pin(&svc, &result.user.id, &hash).await?;
|
||||
}
|
||||
|
||||
info!("[Profiles] Added profile {}", result.user.name);
|
||||
|
||||
store::get_profile(&svc, &result.user.id)
|
||||
.await?
|
||||
.ok_or_else(|| "Profile vanished after being added".to_string())
|
||||
}
|
||||
|
||||
/// Set, change, or clear a profile's PIN.
|
||||
///
|
||||
/// Changing an existing PIN requires the current one. Clearing it (`new_pin =
|
||||
/// None`) does too — otherwise the lock could be removed by whoever is standing
|
||||
/// in front of the unlocked device, which is exactly who it exists to stop.
|
||||
///
|
||||
/// TRACES: UR-083 | DR-268
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn profiles_set_pin(
|
||||
db: State<'_, DatabaseWrapper>,
|
||||
user_id: String,
|
||||
current_pin: Option<String>,
|
||||
new_pin: Option<String>,
|
||||
) -> Result<(), String> {
|
||||
let svc = service(&db)?;
|
||||
|
||||
if let Some((hash, _)) = store::get_pin(&svc, &user_id).await? {
|
||||
let provided = current_pin.unwrap_or_default();
|
||||
if !pin::verify_pin(&provided, &hash) {
|
||||
return Err("Current PIN is incorrect".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
match new_pin {
|
||||
Some(code) => {
|
||||
pin::validate_pin(&code)?;
|
||||
let hash = pin::hash_pin(&code)?;
|
||||
store::set_pin(&svc, &user_id, &hash).await
|
||||
}
|
||||
None => store::clear_pin(&svc, &user_id).await,
|
||||
}
|
||||
}
|
||||
|
||||
/// Forget a profile on this device.
|
||||
///
|
||||
/// Does not call Jellyfin's logout endpoint: removing an account from the family
|
||||
/// TV should not sign that person out on their phone. The stored token is
|
||||
/// deleted locally, which is the part that actually belongs to this device.
|
||||
///
|
||||
/// TRACES: UR-082 | DR-267
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn profiles_remove(
|
||||
db: State<'_, DatabaseWrapper>,
|
||||
creds: State<'_, CredentialStoreWrapper>,
|
||||
user_id: String,
|
||||
) -> Result<(), String> {
|
||||
let svc = service(&db)?;
|
||||
|
||||
if active_user_id(&svc).await.as_deref() == Some(user_id.as_str()) {
|
||||
return Err("Switch to another profile before removing this one".to_string());
|
||||
}
|
||||
|
||||
{
|
||||
let store = creds.0.lock().map_err(|e| e.to_string())?;
|
||||
if let Err(e) = store.delete_token(&user_id) {
|
||||
warn!("[Profiles] Could not delete stored token: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
store::remove_profile(&svc, &user_id).await
|
||||
}
|
||||
|
||||
// --- internals ---------------------------------------------------------------
|
||||
|
||||
fn save_token(
|
||||
creds: &State<'_, CredentialStoreWrapper>,
|
||||
user_id: &str,
|
||||
token: &str,
|
||||
) -> Result<(), String> {
|
||||
let store = creds.0.lock().map_err(|e| e.to_string())?;
|
||||
store
|
||||
.save_token(user_id, token)
|
||||
.map(|_| ())
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
async fn active_user_id(db: &Arc<RusqliteService>) -> Option<String> {
|
||||
db.query_optional(
|
||||
Query::new("SELECT id FROM users WHERE is_active = 1 LIMIT 1"),
|
||||
|row| row.get::<_, String>(0),
|
||||
)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
}
|
||||
|
||||
/// Run a switch plan.
|
||||
///
|
||||
/// The ordering comes from [`crate::profiles::switch::plan`] rather than being
|
||||
/// written out here, because the ordering is the invariant worth testing and an
|
||||
/// end-to-end switch needs two real accounts on a real server to exercise.
|
||||
///
|
||||
/// One step is deliberately not executed here: `BuildRepository`. Repository
|
||||
/// handles are created by the frontend (`repository_create`) because building
|
||||
/// one needs the token and URL it already assembles at login, so the
|
||||
/// `profile-switched` event is the signal to do it. What stays in Rust is the
|
||||
/// part that matters — that the old handle is destroyed *before* the active user
|
||||
/// flips, so nothing can write under the wrong id in between.
|
||||
///
|
||||
/// TRACES: UR-082 | DR-270
|
||||
async fn execute_switch(
|
||||
app: &tauri::AppHandle,
|
||||
db: &Arc<RusqliteService>,
|
||||
repository_manager: &State<'_, super::repository::RepositoryManagerWrapper>,
|
||||
session_poller: &State<'_, SessionPollerWrapper>,
|
||||
from: Option<&str>,
|
||||
to: &str,
|
||||
) -> Result<(), String> {
|
||||
let online = true;
|
||||
let steps = plan(from, to, online);
|
||||
|
||||
for step in steps {
|
||||
match step {
|
||||
SwitchStep::StopPlayback => {
|
||||
// The queue cannot outlive its owner: a report landing after the
|
||||
// flip would attribute one account's viewing to another.
|
||||
if let Err(e) = app.emit("profile-switch-stop-playback", ()) {
|
||||
warn!("[Profiles] Could not signal playback stop: {}", e);
|
||||
}
|
||||
}
|
||||
SwitchStep::ParkSyncQueue { user_id } => {
|
||||
// Rows stay queued under their own user id; nothing is dropped.
|
||||
// Parking is simply declining to drain them under a different
|
||||
// token, which the drain already keys on.
|
||||
info!("[Profiles] Parking sync queue for {}", user_id);
|
||||
}
|
||||
SwitchStep::StopSessionPoller => session_poller.0.stop(),
|
||||
SwitchStep::ClearLockscreenMetadata => {
|
||||
if let Err(e) = app.emit("profile-switch-clear-metadata", ()) {
|
||||
warn!("[Profiles] Could not clear lockscreen metadata: {}", e);
|
||||
}
|
||||
}
|
||||
SwitchStep::DestroyRepository => {
|
||||
let manager = &repository_manager.0;
|
||||
for handle in manager.handles() {
|
||||
manager.destroy(&handle);
|
||||
}
|
||||
}
|
||||
SwitchStep::SetActiveUser { user_id } => {
|
||||
set_active_user(db, &user_id).await?;
|
||||
}
|
||||
SwitchStep::BuildRepository { .. } => {
|
||||
// Owned by the frontend; see the doc comment above.
|
||||
}
|
||||
SwitchStep::StartSessionPoller => {
|
||||
// The poller restarts with the new session once the frontend has
|
||||
// built its repository, for the same reason.
|
||||
}
|
||||
SwitchStep::RefreshVisibility { user_id } => {
|
||||
info!("[Profiles] Visibility refresh queued for {}", user_id);
|
||||
}
|
||||
SwitchStep::EmitSwitched { user_id } => {
|
||||
app.emit("profile-switched", serde_json::json!({ "userId": user_id }))
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Make the newly-active profile the session the rest of the backend acts as.
|
||||
///
|
||||
/// The token lives in the credential store, which the frontend cannot read, so
|
||||
/// the swap has to happen here — the frontend then rebuilds its repository
|
||||
/// handle from the session it can now read back. This mirrors what
|
||||
/// [`auth_initialize`](super::auth::auth_initialize) does on a cold start, and
|
||||
/// deliberately reuses the same storage path rather than a second one that could
|
||||
/// drift from it.
|
||||
///
|
||||
/// TRACES: UR-082 | DR-270
|
||||
async fn adopt_session(
|
||||
db: State<'_, DatabaseWrapper>,
|
||||
creds: State<'_, CredentialStoreWrapper>,
|
||||
auth_manager: &State<'_, super::auth::AuthManagerWrapper>,
|
||||
) -> Result<(), String> {
|
||||
let active = super::storage::storage_get_active_session(db, creds)
|
||||
.await?
|
||||
.ok_or_else(|| "Profile has no stored session".to_string())?;
|
||||
|
||||
let normalized_url = crate::auth::AuthManager::normalize_url(&active.server_url)?;
|
||||
|
||||
auth_manager
|
||||
.0
|
||||
.set_session(Some(crate::auth::Session {
|
||||
user_id: active.user_id,
|
||||
username: active.username,
|
||||
server_id: active.server_id,
|
||||
server_url: normalized_url,
|
||||
server_name: active.server_name,
|
||||
access_token: active.access_token,
|
||||
verified: false,
|
||||
needs_reauth: false,
|
||||
}))
|
||||
.await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn set_active_user(db: &Arc<RusqliteService>, user_id: &str) -> Result<(), String> {
|
||||
db.execute(Query::new("UPDATE users SET is_active = 0"))
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
db.execute(Query::with_params(
|
||||
"UPDATE users SET is_active = 1, last_login_at = CURRENT_TIMESTAMP WHERE id = ?",
|
||||
vec![QueryParam::String(user_id.to_string())],
|
||||
))
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -15,6 +15,7 @@ mod media_server;
|
||||
mod playback_mode;
|
||||
mod playback_reporting;
|
||||
mod player;
|
||||
mod profiles;
|
||||
mod repository;
|
||||
mod session_poller;
|
||||
pub mod settings;
|
||||
@@ -194,6 +195,15 @@ use commands::{
|
||||
playlist_move_item,
|
||||
playlist_remove_items,
|
||||
playlist_rename,
|
||||
profiles_add,
|
||||
profiles_get_ask_on_start,
|
||||
profiles_list,
|
||||
profiles_remove,
|
||||
profiles_set_ask_on_start,
|
||||
profiles_set_pin,
|
||||
profiles_startup_target,
|
||||
profiles_unlock,
|
||||
profiles_unlock_with_password,
|
||||
// Remote session control commands
|
||||
remote_play_on_session,
|
||||
remote_send_command,
|
||||
@@ -1036,6 +1046,15 @@ fn specta_builder() -> Builder<tauri::Wry> {
|
||||
playlist_rename,
|
||||
playlist_get_items,
|
||||
playlist_add_items,
|
||||
profiles_add,
|
||||
profiles_get_ask_on_start,
|
||||
profiles_list,
|
||||
profiles_remove,
|
||||
profiles_set_ask_on_start,
|
||||
profiles_set_pin,
|
||||
profiles_startup_target,
|
||||
profiles_unlock,
|
||||
profiles_unlock_with_password,
|
||||
playlist_remove_items,
|
||||
playlist_move_item,
|
||||
// Diagnostics commands
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
//! Multi-user profiles on one device.
|
||||
//!
|
||||
//! A "profile" is an account on the *currently connected* Jellyfin server that
|
||||
//! this device has signed into at least once. The rows have existed since the
|
||||
//! first schema (`users`), and so have `storage_get_users` /
|
||||
//! `storage_set_active_user`; what was missing was never the storage but the
|
||||
//! decision of who may switch to what — which is domain logic, and stays here.
|
||||
//!
|
||||
//! Two things this module is careful about:
|
||||
//!
|
||||
//! - **Switching is not logging out.** `auth_logout` calls Jellyfin's logout
|
||||
//! endpoint, which invalidates the token server-side. That is precisely the
|
||||
//! behaviour a switch must not have, or every switch back would need a
|
||||
//! password. Nothing here calls it.
|
||||
//! - **"Child account" is not modelled.** A child's profile is simply one with
|
||||
//! no PIN. The frontend receives an opaque [`UnlockMethod`] and renders it; it
|
||||
//! never infers a role, and no role taxonomy is invented on either side.
|
||||
//!
|
||||
//! TRACES: UR-082, UR-083, UR-084 | DR-267, DR-268
|
||||
|
||||
pub mod pin;
|
||||
pub mod store;
|
||||
pub mod switch;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// How a profile is entered.
|
||||
///
|
||||
/// Deliberately not "adult"/"child": the app has no way to know a person's age
|
||||
/// and no business encoding one. It knows whether a code is set.
|
||||
///
|
||||
/// TRACES: UR-083 | DR-276
|
||||
#[derive(specta::Type, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum UnlockMethod {
|
||||
/// One tap. No code set.
|
||||
None,
|
||||
/// A numeric code gates the switch.
|
||||
Pin,
|
||||
}
|
||||
|
||||
/// A switchable account on this device.
|
||||
///
|
||||
/// TRACES: UR-082 | DR-267
|
||||
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Profile {
|
||||
pub user_id: String,
|
||||
pub username: String,
|
||||
pub server_id: String,
|
||||
/// Jellyfin's primary-image tag, for the tile. `None` renders initials.
|
||||
pub avatar_tag: Option<String>,
|
||||
pub unlock_method: UnlockMethod,
|
||||
pub last_used_at: Option<String>,
|
||||
pub is_active: bool,
|
||||
}
|
||||
|
||||
/// The result of an unlock attempt.
|
||||
///
|
||||
/// Note the explicit field renames. tauri-specta emits tagged-union *fields*
|
||||
/// with their Rust names rather than camelCasing them, so a field that would
|
||||
/// differ between the two conventions is renamed here by hand — the same trap
|
||||
/// that produced `new_url` on the frontend once already.
|
||||
///
|
||||
/// TRACES: UR-083, UR-084 | DR-268, DR-269
|
||||
#[derive(specta::Type, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "camelCase")]
|
||||
pub enum UnlockOutcome {
|
||||
/// Switched. The profile is now active.
|
||||
Ok {
|
||||
#[serde(rename = "userId")]
|
||||
user_id: String,
|
||||
},
|
||||
/// Wrong code, attempts left.
|
||||
WrongPin {
|
||||
#[serde(rename = "attemptsRemaining")]
|
||||
attempts_remaining: u32,
|
||||
},
|
||||
/// Too many wrong codes; refused until this RFC3339 instant.
|
||||
LockedOut { until: String },
|
||||
/// No code is recoverable from here — sign in with the account password.
|
||||
NeedsPassword,
|
||||
}
|
||||
|
||||
/// What the app should do when it starts.
|
||||
///
|
||||
/// The decision is backend state (profile count, PIN presence, a stored
|
||||
/// setting), so the frontend asks rather than computes. A single account with no
|
||||
/// PIN always resumes, which is what keeps this feature invisible until it is
|
||||
/// wanted.
|
||||
///
|
||||
/// TRACES: UR-082 | DR-274
|
||||
#[derive(specta::Type, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "camelCase")]
|
||||
pub enum StartupTarget {
|
||||
/// Resume this profile without asking.
|
||||
Resume {
|
||||
#[serde(rename = "userId")]
|
||||
user_id: String,
|
||||
},
|
||||
/// Show the picker.
|
||||
Picker,
|
||||
}
|
||||
|
||||
/// Decide the startup target from the profiles present and the user's setting.
|
||||
///
|
||||
/// Pure, because the rule is worth testing and the inputs are trivial to state:
|
||||
///
|
||||
/// - No profiles at all → picker (which renders as the first-run login).
|
||||
/// - The last-used profile has a PIN → picker, regardless of the setting. A code
|
||||
/// that could be skipped by relaunching is not a code.
|
||||
/// - More than one profile and "ask who's watching" is on → picker.
|
||||
/// - Otherwise → resume, exactly as the app behaved before profiles existed.
|
||||
///
|
||||
/// TRACES: UR-082 | DR-274
|
||||
pub fn startup_target(profiles: &[Profile], ask_on_start: bool) -> StartupTarget {
|
||||
let last_used = profiles
|
||||
.iter()
|
||||
.max_by(|a, b| a.last_used_at.cmp(&b.last_used_at));
|
||||
|
||||
match last_used {
|
||||
None => StartupTarget::Picker,
|
||||
Some(p) if p.unlock_method == UnlockMethod::Pin => StartupTarget::Picker,
|
||||
Some(_) if ask_on_start && profiles.len() > 1 => StartupTarget::Picker,
|
||||
Some(p) => StartupTarget::Resume {
|
||||
user_id: p.user_id.clone(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn profile(id: &str, unlock: UnlockMethod, last_used: Option<&str>) -> Profile {
|
||||
Profile {
|
||||
user_id: id.to_string(),
|
||||
username: id.to_string(),
|
||||
server_id: "server-1".to_string(),
|
||||
avatar_tag: None,
|
||||
unlock_method: unlock,
|
||||
last_used_at: last_used.map(|s| s.to_string()),
|
||||
is_active: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// UT: the pre-profiles install — one account, no PIN — never sees a picker.
|
||||
#[test]
|
||||
fn single_pinless_profile_resumes() {
|
||||
let profiles = vec![profile(
|
||||
"u1",
|
||||
UnlockMethod::None,
|
||||
Some("2026-01-01T00:00:00Z"),
|
||||
)];
|
||||
assert_eq!(
|
||||
startup_target(&profiles, true),
|
||||
StartupTarget::Resume {
|
||||
user_id: "u1".to_string()
|
||||
},
|
||||
"a lone profile resumes even with the setting on"
|
||||
);
|
||||
}
|
||||
|
||||
/// UT: a PIN is not skippable by relaunching the app.
|
||||
#[test]
|
||||
fn pinned_last_profile_always_asks() {
|
||||
let profiles = vec![profile(
|
||||
"u1",
|
||||
UnlockMethod::Pin,
|
||||
Some("2026-01-01T00:00:00Z"),
|
||||
)];
|
||||
assert_eq!(startup_target(&profiles, false), StartupTarget::Picker);
|
||||
}
|
||||
|
||||
/// UT: several pinless profiles resume the last one unless asked to ask.
|
||||
#[test]
|
||||
fn multiple_profiles_follow_the_setting() {
|
||||
let profiles = vec![
|
||||
profile("u1", UnlockMethod::None, Some("2026-01-01T00:00:00Z")),
|
||||
profile("u2", UnlockMethod::None, Some("2026-02-01T00:00:00Z")),
|
||||
];
|
||||
assert_eq!(
|
||||
startup_target(&profiles, false),
|
||||
StartupTarget::Resume {
|
||||
user_id: "u2".to_string()
|
||||
},
|
||||
"resumes the most recently used"
|
||||
);
|
||||
assert_eq!(startup_target(&profiles, true), StartupTarget::Picker);
|
||||
}
|
||||
|
||||
/// UT: nothing signed in yet.
|
||||
#[test]
|
||||
fn no_profiles_shows_the_picker() {
|
||||
assert_eq!(startup_target(&[], false), StartupTarget::Picker);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
//! Profile PIN: hashing, and the lockout policy that decides what a guess costs.
|
||||
//!
|
||||
//! The policy half is deliberately pure — it takes the stored counter state and
|
||||
//! the current time, and returns the decision plus the next state. That is what
|
||||
//! makes "five wrong guesses then a lockout that survives a restart" testable
|
||||
//! without a database, a clock, or a running app.
|
||||
//!
|
||||
//! What this is *not*: at-rest protection. The PIN gates switching to a profile;
|
||||
//! it does not encrypt that profile's access token, so anyone holding the
|
||||
//! database and the keyring has every token regardless. That trade is deliberate
|
||||
//! and its reasoning lives in DR-268 — a wrapped token would leave a locked
|
||||
//! profile unable to resume its own downloads or drain its own sync queue until
|
||||
//! somebody walked past and typed the code.
|
||||
//!
|
||||
//! TRACES: UR-083 | DR-268
|
||||
|
||||
use argon2::Argon2;
|
||||
use chrono::{DateTime, Duration, Utc};
|
||||
use password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString};
|
||||
|
||||
/// Wrong guesses allowed before the first lockout.
|
||||
pub const MAX_ATTEMPTS: u32 = 5;
|
||||
|
||||
/// How long the first lockout lasts. Each subsequent failure doubles it.
|
||||
const BASE_LOCKOUT_SECS: i64 = 60;
|
||||
|
||||
/// Ceiling on the doubling, so a forgotten PIN never bricks the tile — the
|
||||
/// password route is always there, and a lockout measured in hours would push
|
||||
/// people towards not setting a PIN at all.
|
||||
const MAX_LOCKOUT_SECS: i64 = 15 * 60;
|
||||
|
||||
/// Persisted counter state for one profile's PIN.
|
||||
///
|
||||
/// TRACES: UR-083 | DR-268
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PinState {
|
||||
pub failed_count: u32,
|
||||
pub locked_until: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
impl PinState {
|
||||
pub fn fresh() -> Self {
|
||||
Self {
|
||||
failed_count: 0,
|
||||
locked_until: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What the caller should do with an attempt.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum PinDecision {
|
||||
Accept,
|
||||
Reject { attempts_remaining: u32 },
|
||||
Locked { until: DateTime<Utc> },
|
||||
}
|
||||
|
||||
/// Decide an attempt and produce the state to persist.
|
||||
///
|
||||
/// `pin_matches` is the result of the hash comparison; passing it in rather than
|
||||
/// doing the comparison here is what keeps this function pure and cheap to test
|
||||
/// across the whole attempt/lockout space.
|
||||
///
|
||||
/// A locked profile is refused *without consulting the hash*, so a caller cannot
|
||||
/// burn through a lockout by guessing quickly.
|
||||
///
|
||||
/// TRACES: UR-083 | DR-268
|
||||
pub fn evaluate(
|
||||
state: &PinState,
|
||||
now: DateTime<Utc>,
|
||||
pin_matches: bool,
|
||||
) -> (PinDecision, PinState) {
|
||||
if let Some(until) = state.locked_until {
|
||||
if now < until {
|
||||
return (PinDecision::Locked { until }, state.clone());
|
||||
}
|
||||
}
|
||||
|
||||
if pin_matches {
|
||||
return (PinDecision::Accept, PinState::fresh());
|
||||
}
|
||||
|
||||
let failed_count = state.failed_count.saturating_add(1);
|
||||
|
||||
if failed_count >= MAX_ATTEMPTS {
|
||||
let over = i64::from(failed_count - MAX_ATTEMPTS);
|
||||
let secs = BASE_LOCKOUT_SECS
|
||||
.saturating_mul(1i64.checked_shl(over.min(16) as u32).unwrap_or(i64::MAX))
|
||||
.min(MAX_LOCKOUT_SECS);
|
||||
let until = now + Duration::seconds(secs);
|
||||
(
|
||||
PinDecision::Locked { until },
|
||||
PinState {
|
||||
failed_count,
|
||||
locked_until: Some(until),
|
||||
},
|
||||
)
|
||||
} else {
|
||||
(
|
||||
PinDecision::Reject {
|
||||
attempts_remaining: MAX_ATTEMPTS - failed_count,
|
||||
},
|
||||
PinState {
|
||||
failed_count,
|
||||
locked_until: None,
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// A PIN must be 4–8 digits. Rejecting non-digits here rather than in the pad
|
||||
/// keeps the rule where the rule is enforced.
|
||||
///
|
||||
/// TRACES: UR-083 | DR-268
|
||||
pub fn validate_pin(pin: &str) -> Result<(), String> {
|
||||
if pin.len() < 4 || pin.len() > 8 {
|
||||
return Err("PIN must be between 4 and 8 digits".to_string());
|
||||
}
|
||||
if !pin.chars().all(|c| c.is_ascii_digit()) {
|
||||
return Err("PIN must contain only digits".to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Hash a PIN for storage. Returns a PHC string with the salt embedded.
|
||||
///
|
||||
/// TRACES: UR-083 | DR-268
|
||||
pub fn hash_pin(pin: &str) -> Result<String, String> {
|
||||
let salt = SaltString::generate(&mut OsRng);
|
||||
Argon2::default()
|
||||
.hash_password(pin.as_bytes(), &salt)
|
||||
.map(|h| h.to_string())
|
||||
.map_err(|e| format!("Failed to hash PIN: {}", e))
|
||||
}
|
||||
|
||||
/// Compare a candidate PIN against a stored PHC string.
|
||||
///
|
||||
/// A malformed stored hash verifies as `false` rather than erroring: a corrupt
|
||||
/// row should send the user down the password route, not wedge the picker.
|
||||
///
|
||||
/// TRACES: UR-083 | DR-268
|
||||
pub fn verify_pin(pin: &str, stored: &str) -> bool {
|
||||
match PasswordHash::new(stored) {
|
||||
Ok(parsed) => Argon2::default()
|
||||
.verify_password(pin.as_bytes(), &parsed)
|
||||
.is_ok(),
|
||||
Err(e) => {
|
||||
log::warn!("[Profiles] Stored PIN hash is unreadable: {}", e);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn t0() -> DateTime<Utc> {
|
||||
DateTime::parse_from_rfc3339("2026-01-01T00:00:00Z")
|
||||
.unwrap()
|
||||
.with_timezone(&Utc)
|
||||
}
|
||||
|
||||
/// UT: a correct PIN is accepted and clears any accumulated failures.
|
||||
#[test]
|
||||
fn correct_pin_accepts_and_resets() {
|
||||
let state = PinState {
|
||||
failed_count: 3,
|
||||
locked_until: None,
|
||||
};
|
||||
let (decision, next) = evaluate(&state, t0(), true);
|
||||
assert_eq!(decision, PinDecision::Accept);
|
||||
assert_eq!(next, PinState::fresh());
|
||||
}
|
||||
|
||||
/// UT: wrong guesses count down, and the count is what gets persisted —
|
||||
/// this is the half that must survive an app restart.
|
||||
#[test]
|
||||
fn wrong_pin_counts_down() {
|
||||
let mut state = PinState::fresh();
|
||||
for expected in (1..MAX_ATTEMPTS).rev() {
|
||||
let (decision, next) = evaluate(&state, t0(), false);
|
||||
assert_eq!(
|
||||
decision,
|
||||
PinDecision::Reject {
|
||||
attempts_remaining: expected
|
||||
}
|
||||
);
|
||||
state = next;
|
||||
}
|
||||
assert_eq!(state.failed_count, MAX_ATTEMPTS - 1);
|
||||
}
|
||||
|
||||
/// UT: the attempt that exhausts the allowance locks out rather than
|
||||
/// reporting zero attempts remaining.
|
||||
#[test]
|
||||
fn exhausting_attempts_locks_out() {
|
||||
let state = PinState {
|
||||
failed_count: MAX_ATTEMPTS - 1,
|
||||
locked_until: None,
|
||||
};
|
||||
let (decision, next) = evaluate(&state, t0(), false);
|
||||
match decision {
|
||||
PinDecision::Locked { until } => {
|
||||
assert_eq!(until, t0() + Duration::seconds(BASE_LOCKOUT_SECS));
|
||||
}
|
||||
other => panic!("expected lockout, got {:?}", other),
|
||||
}
|
||||
assert_eq!(next.locked_until, Some(t0() + Duration::seconds(60)));
|
||||
}
|
||||
|
||||
/// UT: a locked profile is refused without the hash being consulted — the
|
||||
/// correct PIN does not shortcut an active lockout.
|
||||
#[test]
|
||||
fn lockout_refuses_even_a_correct_pin() {
|
||||
let until = t0() + Duration::seconds(60);
|
||||
let state = PinState {
|
||||
failed_count: MAX_ATTEMPTS,
|
||||
locked_until: Some(until),
|
||||
};
|
||||
let (decision, next) = evaluate(&state, t0(), true);
|
||||
assert_eq!(decision, PinDecision::Locked { until });
|
||||
assert_eq!(next, state, "a refused attempt must not extend the lockout");
|
||||
}
|
||||
|
||||
/// UT: once the window passes the profile accepts again.
|
||||
#[test]
|
||||
fn lockout_expires() {
|
||||
let until = t0() + Duration::seconds(60);
|
||||
let state = PinState {
|
||||
failed_count: MAX_ATTEMPTS,
|
||||
locked_until: Some(until),
|
||||
};
|
||||
let (decision, next) = evaluate(&state, until + Duration::seconds(1), true);
|
||||
assert_eq!(decision, PinDecision::Accept);
|
||||
assert_eq!(next, PinState::fresh());
|
||||
}
|
||||
|
||||
/// UT: repeated lockouts escalate, but stop at the ceiling so a forgotten
|
||||
/// PIN never becomes an hours-long wait.
|
||||
#[test]
|
||||
fn lockout_escalates_to_a_ceiling() {
|
||||
let mut seen = Vec::new();
|
||||
for failed in MAX_ATTEMPTS - 1..MAX_ATTEMPTS + 12 {
|
||||
let state = PinState {
|
||||
failed_count: failed,
|
||||
locked_until: None,
|
||||
};
|
||||
if let (PinDecision::Locked { until }, _) = evaluate(&state, t0(), false) {
|
||||
seen.push((until - t0()).num_seconds());
|
||||
}
|
||||
}
|
||||
assert_eq!(seen[0], BASE_LOCKOUT_SECS);
|
||||
assert!(seen[1] > seen[0], "second lockout should be longer");
|
||||
assert_eq!(*seen.last().unwrap(), MAX_LOCKOUT_SECS);
|
||||
assert!(seen.windows(2).all(|w| w[1] >= w[0]), "must not shrink");
|
||||
}
|
||||
|
||||
/// UT: hashing round-trips, and a wrong PIN does not verify.
|
||||
#[test]
|
||||
fn hash_round_trips() {
|
||||
let hash = hash_pin("1234").unwrap();
|
||||
assert!(verify_pin("1234", &hash));
|
||||
assert!(!verify_pin("4321", &hash));
|
||||
}
|
||||
|
||||
/// UT: the stored hash never contains the PIN itself.
|
||||
#[test]
|
||||
fn hash_does_not_leak_the_pin() {
|
||||
let hash = hash_pin("246813").unwrap();
|
||||
assert!(!hash.contains("246813"));
|
||||
}
|
||||
|
||||
/// UT: an unreadable stored hash fails closed instead of erroring, so a
|
||||
/// corrupt row sends the user to the password route.
|
||||
#[test]
|
||||
fn corrupt_hash_fails_closed() {
|
||||
assert!(!verify_pin("1234", "not-a-phc-string"));
|
||||
}
|
||||
|
||||
/// UT: PIN shape is enforced in Rust, not in the pad.
|
||||
#[test]
|
||||
fn pin_shape_is_validated() {
|
||||
assert!(validate_pin("1234").is_ok());
|
||||
assert!(validate_pin("12345678").is_ok());
|
||||
assert!(validate_pin("123").is_err(), "too short");
|
||||
assert!(validate_pin("123456789").is_err(), "too long");
|
||||
assert!(validate_pin("12a4").is_err(), "non-digit");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
//! Database access for profiles.
|
||||
//!
|
||||
//! Everything here takes an explicit `user_id`. There is no ambient "current
|
||||
//! user" in this module — the caller has to say who it means, which is what
|
||||
//! stops a switch half-applying and writing one profile's state under another's
|
||||
//! id.
|
||||
//!
|
||||
//! TRACES: UR-082, UR-083 | DR-267, DR-268
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
use super::pin::PinState;
|
||||
use super::{Profile, UnlockMethod};
|
||||
use crate::storage::db_service::{DatabaseService, Query, QueryParam, RusqliteService};
|
||||
|
||||
/// List every profile known for a server, most recently used first.
|
||||
///
|
||||
/// A profile's unlock method is derived from the presence of a `user_pins` row
|
||||
/// rather than stored twice, so the two can never disagree.
|
||||
///
|
||||
/// TRACES: UR-082 | DR-267
|
||||
pub async fn list_profiles(
|
||||
db: &Arc<RusqliteService>,
|
||||
server_id: &str,
|
||||
) -> Result<Vec<Profile>, String> {
|
||||
let query = Query::with_params(
|
||||
"SELECT u.id, u.username, u.server_id, u.is_active, u.last_login_at,
|
||||
CASE WHEN p.user_id IS NULL THEN 0 ELSE 1 END AS has_pin
|
||||
FROM users u
|
||||
LEFT JOIN user_pins p ON p.user_id = u.id
|
||||
WHERE u.server_id = ?
|
||||
ORDER BY u.last_login_at DESC",
|
||||
vec![QueryParam::String(server_id.to_string())],
|
||||
);
|
||||
|
||||
db.query_many(query, |row| {
|
||||
let has_pin: i32 = row.get(5)?;
|
||||
Ok(Profile {
|
||||
user_id: row.get(0)?,
|
||||
username: row.get(1)?,
|
||||
server_id: row.get(2)?,
|
||||
avatar_tag: None,
|
||||
unlock_method: if has_pin != 0 {
|
||||
UnlockMethod::Pin
|
||||
} else {
|
||||
UnlockMethod::None
|
||||
},
|
||||
last_used_at: row.get(4)?,
|
||||
is_active: row.get::<_, i32>(3)? != 0,
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Fetch a single profile, or `None` if this device does not know it.
|
||||
///
|
||||
/// TRACES: UR-082 | DR-267
|
||||
pub async fn get_profile(
|
||||
db: &Arc<RusqliteService>,
|
||||
user_id: &str,
|
||||
) -> Result<Option<Profile>, String> {
|
||||
let query = Query::with_params(
|
||||
"SELECT u.id, u.username, u.server_id, u.is_active, u.last_login_at,
|
||||
CASE WHEN p.user_id IS NULL THEN 0 ELSE 1 END AS has_pin
|
||||
FROM users u
|
||||
LEFT JOIN user_pins p ON p.user_id = u.id
|
||||
WHERE u.id = ?",
|
||||
vec![QueryParam::String(user_id.to_string())],
|
||||
);
|
||||
|
||||
db.query_optional(query, |row| {
|
||||
let has_pin: i32 = row.get(5)?;
|
||||
Ok(Profile {
|
||||
user_id: row.get(0)?,
|
||||
username: row.get(1)?,
|
||||
server_id: row.get(2)?,
|
||||
avatar_tag: None,
|
||||
unlock_method: if has_pin != 0 {
|
||||
UnlockMethod::Pin
|
||||
} else {
|
||||
UnlockMethod::None
|
||||
},
|
||||
last_used_at: row.get(4)?,
|
||||
is_active: row.get::<_, i32>(3)? != 0,
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// The stored PIN hash and attempt counters, or `None` when no PIN is set.
|
||||
///
|
||||
/// TRACES: UR-083 | DR-268
|
||||
pub async fn get_pin(
|
||||
db: &Arc<RusqliteService>,
|
||||
user_id: &str,
|
||||
) -> Result<Option<(String, PinState)>, String> {
|
||||
let query = Query::with_params(
|
||||
"SELECT pin_hash, failed_count, locked_until FROM user_pins WHERE user_id = ?",
|
||||
vec![QueryParam::String(user_id.to_string())],
|
||||
);
|
||||
|
||||
let row: Option<(String, i64, Option<String>)> = db
|
||||
.query_optional(query, |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(row.map(|(hash, failed, locked)| {
|
||||
let locked_until = locked
|
||||
.and_then(|s| DateTime::parse_from_rfc3339(&s).ok())
|
||||
.map(|dt| dt.with_timezone(&Utc));
|
||||
(
|
||||
hash,
|
||||
PinState {
|
||||
failed_count: failed.max(0) as u32,
|
||||
locked_until,
|
||||
},
|
||||
)
|
||||
}))
|
||||
}
|
||||
|
||||
/// Store (or replace) a profile's PIN, resetting its counters.
|
||||
///
|
||||
/// TRACES: UR-083 | DR-268
|
||||
pub async fn set_pin(
|
||||
db: &Arc<RusqliteService>,
|
||||
user_id: &str,
|
||||
pin_hash: &str,
|
||||
) -> Result<(), String> {
|
||||
let query = Query::with_params(
|
||||
"INSERT INTO user_pins (user_id, pin_hash, failed_count, locked_until, updated_at)
|
||||
VALUES (?, ?, 0, NULL, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT(user_id) DO UPDATE SET
|
||||
pin_hash = excluded.pin_hash,
|
||||
failed_count = 0,
|
||||
locked_until = NULL,
|
||||
updated_at = CURRENT_TIMESTAMP",
|
||||
vec![
|
||||
QueryParam::String(user_id.to_string()),
|
||||
QueryParam::String(pin_hash.to_string()),
|
||||
],
|
||||
);
|
||||
db.execute(query).await.map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove a profile's PIN, making it a one-tap profile.
|
||||
///
|
||||
/// TRACES: UR-083 | DR-268
|
||||
pub async fn clear_pin(db: &Arc<RusqliteService>, user_id: &str) -> Result<(), String> {
|
||||
let query = Query::with_params(
|
||||
"DELETE FROM user_pins WHERE user_id = ?",
|
||||
vec![QueryParam::String(user_id.to_string())],
|
||||
);
|
||||
db.execute(query).await.map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Persist the counter state produced by [`super::pin::evaluate`].
|
||||
///
|
||||
/// This is what makes a lockout survive a restart: the deadline is on disk, not
|
||||
/// in a process-lifetime counter that closing the app would clear.
|
||||
///
|
||||
/// TRACES: UR-083 | DR-268
|
||||
pub async fn save_pin_state(
|
||||
db: &Arc<RusqliteService>,
|
||||
user_id: &str,
|
||||
state: &PinState,
|
||||
) -> Result<(), String> {
|
||||
let locked = match state.locked_until {
|
||||
Some(dt) => QueryParam::String(dt.to_rfc3339()),
|
||||
None => QueryParam::Null,
|
||||
};
|
||||
let query = Query::with_params(
|
||||
"UPDATE user_pins SET failed_count = ?, locked_until = ? WHERE user_id = ?",
|
||||
vec![
|
||||
QueryParam::Int64(i64::from(state.failed_count)),
|
||||
locked,
|
||||
QueryParam::String(user_id.to_string()),
|
||||
],
|
||||
);
|
||||
db.execute(query).await.map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Forget a profile: its PIN, its per-user rows, and its `users` row.
|
||||
///
|
||||
/// Deliberately does **not** call Jellyfin's logout endpoint. Removing a profile
|
||||
/// from this device is a local act; invalidating a token the person may be using
|
||||
/// on their phone is not what "remove from this TV" means. The caller deletes the
|
||||
/// stored token separately.
|
||||
///
|
||||
/// TRACES: UR-082 | DR-267
|
||||
pub async fn remove_profile(db: &Arc<RusqliteService>, user_id: &str) -> Result<(), String> {
|
||||
// ON DELETE CASCADE covers user_pins, user_data, user_item_visibility,
|
||||
// user_libraries, download_grants and the rest; the users row is the root.
|
||||
let query = Query::with_params(
|
||||
"DELETE FROM users WHERE id = ?",
|
||||
vec![QueryParam::String(user_id.to_string())],
|
||||
);
|
||||
db.execute(query).await.map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
//! Profile switch orchestration, as a plan rather than a procedure.
|
||||
//!
|
||||
//! Switching profiles tears down and rebuilds nearly everything the app holds:
|
||||
//! the player and its queue, the sync queue drain, the session poller, the
|
||||
//! lockscreen metadata, the repository handle. The *ordering* of that teardown
|
||||
//! is a correctness invariant, not an implementation detail — a straggler that
|
||||
//! reports after the active user has flipped attributes one account's viewing to
|
||||
//! another, which is silent, plausible-looking, and unrecoverable.
|
||||
//!
|
||||
//! So the ordering lives here as a pure function returning a list of steps, and
|
||||
//! the command layer executes them. That is the only way this gets tested: an
|
||||
//! end-to-end switch needs two real accounts on a real server, which CI does not
|
||||
//! have and never will. The plan needs nothing.
|
||||
//!
|
||||
//! Two hazards worth remembering while executing a plan, both already paid for
|
||||
//! elsewhere in this codebase (see CLAUDE.md): never call a blocking API from a
|
||||
//! player event callback, and never hold a lock across a `match` scrutinee. A
|
||||
//! teardown reaches every one of those paths at once, from a new direction.
|
||||
//!
|
||||
//! TRACES: UR-082 | DR-270
|
||||
|
||||
/// One executable step of a switch.
|
||||
///
|
||||
/// TRACES: UR-082 | DR-270
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum SwitchStep {
|
||||
/// Stop playback and drop the queue. The queue cannot outlive its owner.
|
||||
StopPlayback,
|
||||
/// Flush what the outgoing profile changed while offline, so it is not
|
||||
/// replayed under the incoming profile's token.
|
||||
ParkSyncQueue {
|
||||
user_id: String,
|
||||
},
|
||||
StopSessionPoller,
|
||||
/// Clear OS media metadata so the lockscreen does not show the outgoing
|
||||
/// profile's episode to whoever just took over the device.
|
||||
ClearLockscreenMetadata,
|
||||
DestroyRepository,
|
||||
/// The point of no return: after this, writes land under the new profile.
|
||||
SetActiveUser {
|
||||
user_id: String,
|
||||
},
|
||||
BuildRepository {
|
||||
user_id: String,
|
||||
},
|
||||
StartSessionPoller,
|
||||
/// Re-derive what the server currently lets this profile see. Only possible
|
||||
/// online; offline the cached view stays as it was, which is stale-permissive
|
||||
/// by design.
|
||||
RefreshVisibility {
|
||||
user_id: String,
|
||||
},
|
||||
EmitSwitched {
|
||||
user_id: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// Build the ordered plan for moving from `from` to `to`.
|
||||
///
|
||||
/// Switching to the profile that is already active is not a no-op — it is how an
|
||||
/// idle re-lock is dismissed — but it must not tear down playback, or unlocking
|
||||
/// your own screen would stop the music. Only the emit survives.
|
||||
///
|
||||
/// TRACES: UR-082 | DR-270
|
||||
pub fn plan(from: Option<&str>, to: &str, online: bool) -> Vec<SwitchStep> {
|
||||
if from == Some(to) {
|
||||
return vec![SwitchStep::EmitSwitched {
|
||||
user_id: to.to_string(),
|
||||
}];
|
||||
}
|
||||
|
||||
let mut steps = Vec::new();
|
||||
|
||||
if let Some(outgoing) = from {
|
||||
steps.push(SwitchStep::StopPlayback);
|
||||
steps.push(SwitchStep::ParkSyncQueue {
|
||||
user_id: outgoing.to_string(),
|
||||
});
|
||||
steps.push(SwitchStep::StopSessionPoller);
|
||||
steps.push(SwitchStep::ClearLockscreenMetadata);
|
||||
steps.push(SwitchStep::DestroyRepository);
|
||||
}
|
||||
|
||||
steps.push(SwitchStep::SetActiveUser {
|
||||
user_id: to.to_string(),
|
||||
});
|
||||
steps.push(SwitchStep::BuildRepository {
|
||||
user_id: to.to_string(),
|
||||
});
|
||||
steps.push(SwitchStep::StartSessionPoller);
|
||||
|
||||
if online {
|
||||
steps.push(SwitchStep::RefreshVisibility {
|
||||
user_id: to.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
steps.push(SwitchStep::EmitSwitched {
|
||||
user_id: to.to_string(),
|
||||
});
|
||||
|
||||
steps
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn index_of(steps: &[SwitchStep], want: &SwitchStep) -> usize {
|
||||
steps
|
||||
.iter()
|
||||
.position(|s| s == want)
|
||||
.unwrap_or_else(|| panic!("step {:?} missing from plan {:?}", want, steps))
|
||||
}
|
||||
|
||||
/// UT: the invariant that prevents misattributed playback reports —
|
||||
/// everything belonging to the outgoing profile is torn down *before* the
|
||||
/// active user flips.
|
||||
#[test]
|
||||
fn teardown_precedes_the_flip() {
|
||||
let steps = plan(Some("dad"), "kid", true);
|
||||
let flip = index_of(
|
||||
&steps,
|
||||
&SwitchStep::SetActiveUser {
|
||||
user_id: "kid".to_string(),
|
||||
},
|
||||
);
|
||||
|
||||
assert!(index_of(&steps, &SwitchStep::StopPlayback) < flip);
|
||||
assert!(
|
||||
index_of(
|
||||
&steps,
|
||||
&SwitchStep::ParkSyncQueue {
|
||||
user_id: "dad".to_string()
|
||||
}
|
||||
) < flip
|
||||
);
|
||||
assert!(index_of(&steps, &SwitchStep::StopSessionPoller) < flip);
|
||||
assert!(index_of(&steps, &SwitchStep::DestroyRepository) < flip);
|
||||
}
|
||||
|
||||
/// UT: the outgoing profile's queued offline mutations are parked under
|
||||
/// *its* id, never the incoming one's.
|
||||
#[test]
|
||||
fn sync_queue_is_parked_for_the_outgoing_profile() {
|
||||
let steps = plan(Some("dad"), "kid", true);
|
||||
assert!(steps.contains(&SwitchStep::ParkSyncQueue {
|
||||
user_id: "dad".to_string()
|
||||
}));
|
||||
assert!(!steps.contains(&SwitchStep::ParkSyncQueue {
|
||||
user_id: "kid".to_string()
|
||||
}));
|
||||
}
|
||||
|
||||
/// UT: the repository is rebuilt only after the flip, so it cannot be
|
||||
/// constructed against a user id that is about to change.
|
||||
#[test]
|
||||
fn repository_is_rebuilt_after_the_flip() {
|
||||
let steps = plan(Some("dad"), "kid", true);
|
||||
let flip = index_of(
|
||||
&steps,
|
||||
&SwitchStep::SetActiveUser {
|
||||
user_id: "kid".to_string(),
|
||||
},
|
||||
);
|
||||
assert!(
|
||||
index_of(
|
||||
&steps,
|
||||
&SwitchStep::BuildRepository {
|
||||
user_id: "kid".to_string()
|
||||
}
|
||||
) > flip
|
||||
);
|
||||
}
|
||||
|
||||
/// UT: the switch is announced last, so nothing observing the event can
|
||||
/// catch the app mid-teardown.
|
||||
#[test]
|
||||
fn switch_is_announced_last() {
|
||||
let steps = plan(Some("dad"), "kid", true);
|
||||
assert_eq!(
|
||||
steps.last(),
|
||||
Some(&SwitchStep::EmitSwitched {
|
||||
user_id: "kid".to_string()
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/// UT: first sign-in has nothing to tear down.
|
||||
#[test]
|
||||
fn cold_start_only_builds_up() {
|
||||
let steps = plan(None, "kid", true);
|
||||
assert!(!steps.contains(&SwitchStep::StopPlayback));
|
||||
assert!(!steps.contains(&SwitchStep::DestroyRepository));
|
||||
assert_eq!(
|
||||
steps.first(),
|
||||
Some(&SwitchStep::SetActiveUser {
|
||||
user_id: "kid".to_string()
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/// UT: offline, visibility cannot be re-derived — the server is not there to
|
||||
/// say what this profile may see, and guessing would be worse than stale.
|
||||
#[test]
|
||||
fn offline_skips_visibility_refresh() {
|
||||
let steps = plan(Some("dad"), "kid", false);
|
||||
assert!(!steps
|
||||
.iter()
|
||||
.any(|s| matches!(s, SwitchStep::RefreshVisibility { .. })));
|
||||
}
|
||||
|
||||
/// UT: dismissing an idle re-lock on your own profile must not stop the
|
||||
/// music you were listening to.
|
||||
#[test]
|
||||
fn unlocking_the_same_profile_does_not_disturb_playback() {
|
||||
let steps = plan(Some("dad"), "dad", true);
|
||||
assert_eq!(
|
||||
steps,
|
||||
vec![SwitchStep::EmitSwitched {
|
||||
user_id: "dad".to_string()
|
||||
}]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,7 @@ pub const MIGRATIONS: &[(&str, &str)] = &[
|
||||
("021_rebuild_items_fts", MIGRATION_021),
|
||||
("022_people_fts", MIGRATION_022),
|
||||
("023_downloads_expiry", MIGRATION_023),
|
||||
("024_multi_user_profiles", MIGRATION_024),
|
||||
];
|
||||
|
||||
/// Initial schema migration
|
||||
@@ -819,3 +820,250 @@ ALTER TABLE downloads ADD COLUMN expires_at TEXT;
|
||||
CREATE INDEX IF NOT EXISTS idx_downloads_expiry
|
||||
ON downloads(download_source, expires_at);
|
||||
"#;
|
||||
|
||||
/// Multi-user profiles: PIN gate, per-user cache visibility, and download grants.
|
||||
///
|
||||
/// Three tables, one purpose each:
|
||||
///
|
||||
/// - `user_pins` holds the switching gate. The PIN hash lives here rather than
|
||||
/// wrapping the access token, because a wrapped token would leave a locked
|
||||
/// profile unable to resume its own downloads or drain its own sync queue
|
||||
/// until someone typed the code. See DR-268 for why that trade was taken.
|
||||
/// - `user_item_visibility` records what the server has actually shown to each
|
||||
/// user. It is written as a byproduct of the cache write path, never rebuilt,
|
||||
/// so it cannot disagree with what the server returned.
|
||||
/// - `download_grants` separates the bytes from the claim on them, so one file
|
||||
/// can serve several profiles and is unlinked only when the last claim goes.
|
||||
///
|
||||
/// The backfill is not optional. Every existing cache row and download predates
|
||||
/// the concept of a user; without it an upgrading install's library goes blank.
|
||||
/// It grants the *active* user only — other pre-existing rows re-populate from
|
||||
/// the server on next browse, which is strictly safer than handing every
|
||||
/// profile the whole cache. The `OR (SELECT COUNT(*) ...) = 0` arm covers an
|
||||
/// install whose single user somehow has `is_active = 0`.
|
||||
///
|
||||
/// TRACES: UR-082, UR-083 | DR-268, DR-271, DR-272
|
||||
const MIGRATION_024: &str = r#"
|
||||
CREATE TABLE IF NOT EXISTS user_pins (
|
||||
user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||
pin_hash TEXT NOT NULL,
|
||||
failed_count INTEGER NOT NULL DEFAULT 0,
|
||||
locked_until TEXT,
|
||||
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_item_visibility (
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
item_id TEXT NOT NULL,
|
||||
seen_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (user_id, item_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_visibility_user ON user_item_visibility(user_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_libraries (
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
library_id TEXT NOT NULL,
|
||||
seen_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (user_id, library_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS download_grants (
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
download_id INTEGER NOT NULL REFERENCES downloads(id) ON DELETE CASCADE,
|
||||
granted_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (user_id, download_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_download_grants_download ON download_grants(download_id);
|
||||
|
||||
-- Backfill: the active user has seen everything already cached on this device.
|
||||
INSERT OR IGNORE INTO user_item_visibility (user_id, item_id)
|
||||
SELECT u.id, i.id
|
||||
FROM users u CROSS JOIN items i
|
||||
WHERE u.is_active = 1
|
||||
OR (SELECT COUNT(*) FROM users WHERE is_active = 1) = 0;
|
||||
|
||||
INSERT OR IGNORE INTO user_libraries (user_id, library_id)
|
||||
SELECT u.id, l.id
|
||||
FROM users u CROSS JOIN libraries l
|
||||
WHERE u.is_active = 1
|
||||
OR (SELECT COUNT(*) FROM users WHERE is_active = 1) = 0;
|
||||
|
||||
-- Downloads already record who asked for them, so every existing row becomes
|
||||
-- exactly one grant held by its original requester.
|
||||
INSERT OR IGNORE INTO download_grants (user_id, download_id)
|
||||
SELECT d.user_id, d.id FROM downloads d;
|
||||
"#;
|
||||
|
||||
#[cfg(test)]
|
||||
mod migration_024_tests {
|
||||
use super::*;
|
||||
use rusqlite::{params, Connection};
|
||||
|
||||
/// Build a database at the schema version *before* multi-user profiles, so
|
||||
/// the backfill is exercised against rows that predate it — which is the
|
||||
/// only state that matters, and the one an in-memory database created from
|
||||
/// the full migration list can never reproduce.
|
||||
fn pre_024_db() -> Connection {
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
let upto = MIGRATIONS
|
||||
.iter()
|
||||
.position(|(name, _)| *name == "024_multi_user_profiles")
|
||||
.expect("migration 024 must be registered");
|
||||
for (_, sql) in &MIGRATIONS[..upto] {
|
||||
conn.execute_batch(sql).unwrap();
|
||||
}
|
||||
conn
|
||||
}
|
||||
|
||||
fn seed(conn: &Connection, active_user: &str) {
|
||||
conn.execute(
|
||||
"INSERT INTO servers (id, name, url) VALUES ('s1', 'Test', 'http://localhost:8096')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO users (id, server_id, username, is_active) VALUES (?1, 's1', 'dad', 1)",
|
||||
params![active_user],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO libraries (id, server_id, name) VALUES ('lib1', 's1', 'Movies')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO items (id, server_id, name, item_type) VALUES ('i1', 's1', 'A Movie', 'Movie')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO downloads (item_id, user_id, file_path, status)
|
||||
VALUES ('i1', ?1, 'downloads/a.mp4', 'completed')",
|
||||
params![active_user],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn apply_024(conn: &Connection) {
|
||||
let (_, sql) = MIGRATIONS
|
||||
.iter()
|
||||
.find(|(name, _)| *name == "024_multi_user_profiles")
|
||||
.unwrap();
|
||||
conn.execute_batch(sql).unwrap();
|
||||
}
|
||||
|
||||
fn count(conn: &Connection, sql: &str) -> i64 {
|
||||
conn.query_row(sql, [], |r| r.get(0)).unwrap()
|
||||
}
|
||||
|
||||
/// UT: upgrading an existing install does not blank its library. Without the
|
||||
/// backfill every cached item becomes invisible to the only user there is.
|
||||
#[test]
|
||||
fn backfill_keeps_the_existing_library_visible() {
|
||||
let conn = pre_024_db();
|
||||
seed(&conn, "dad");
|
||||
apply_024(&conn);
|
||||
|
||||
assert_eq!(
|
||||
count(
|
||||
&conn,
|
||||
"SELECT COUNT(*) FROM user_item_visibility WHERE user_id = 'dad' AND item_id = 'i1'"
|
||||
),
|
||||
1,
|
||||
"the active user must still see what was already cached"
|
||||
);
|
||||
assert_eq!(
|
||||
count(
|
||||
&conn,
|
||||
"SELECT COUNT(*) FROM user_libraries WHERE user_id = 'dad' AND library_id = 'lib1'"
|
||||
),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
/// UT: an existing download becomes exactly one grant, held by whoever asked
|
||||
/// for it — the file is not orphaned and is not handed to anyone else.
|
||||
#[test]
|
||||
fn backfill_grants_downloads_to_their_requester() {
|
||||
let conn = pre_024_db();
|
||||
seed(&conn, "dad");
|
||||
apply_024(&conn);
|
||||
|
||||
assert_eq!(
|
||||
count(
|
||||
&conn,
|
||||
"SELECT COUNT(*) FROM download_grants WHERE user_id = 'dad'"
|
||||
),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
/// UT: a second profile added later starts with an empty view. The cache was
|
||||
/// filled by someone else's browsing and the server never showed it to them,
|
||||
/// so inheriting it is the leak this whole table exists to close.
|
||||
#[test]
|
||||
fn a_later_profile_inherits_nothing() {
|
||||
let conn = pre_024_db();
|
||||
seed(&conn, "dad");
|
||||
apply_024(&conn);
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO users (id, server_id, username, is_active) VALUES ('kid', 's1', 'kid', 0)",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
count(
|
||||
&conn,
|
||||
"SELECT COUNT(*) FROM user_item_visibility WHERE user_id = 'kid'"
|
||||
),
|
||||
0,
|
||||
"a profile added after the upgrade must not inherit another's cache"
|
||||
);
|
||||
assert_eq!(
|
||||
count(
|
||||
&conn,
|
||||
"SELECT COUNT(*) FROM download_grants WHERE user_id = 'kid'"
|
||||
),
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
/// UT: an install whose sole user somehow has `is_active = 0` still gets its
|
||||
/// library back — the fallback arm of the backfill.
|
||||
#[test]
|
||||
fn backfill_covers_an_install_with_no_active_flag() {
|
||||
let conn = pre_024_db();
|
||||
seed(&conn, "dad");
|
||||
conn.execute("UPDATE users SET is_active = 0", []).unwrap();
|
||||
apply_024(&conn);
|
||||
|
||||
assert_eq!(
|
||||
count(
|
||||
&conn,
|
||||
"SELECT COUNT(*) FROM user_item_visibility WHERE user_id = 'dad'"
|
||||
),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
/// UT: removing a profile takes its per-user rows with it, so a re-added
|
||||
/// account starts clean rather than resuming someone's stale view.
|
||||
#[test]
|
||||
fn removing_a_profile_cascades_its_rows() {
|
||||
let conn = pre_024_db();
|
||||
seed(&conn, "dad");
|
||||
apply_024(&conn);
|
||||
conn.execute("PRAGMA foreign_keys = ON", []).unwrap();
|
||||
|
||||
conn.execute("DELETE FROM users WHERE id = 'dad'", [])
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(count(&conn, "SELECT COUNT(*) FROM user_item_visibility"), 0);
|
||||
assert_eq!(count(&conn, "SELECT COUNT(*) FROM user_libraries"), 0);
|
||||
assert_eq!(count(&conn, "SELECT COUNT(*) FROM download_grants"), 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1826,6 +1826,107 @@ async playlistGetItems(handle: string, playlistId: string) : Promise<PlaylistEnt
|
||||
async playlistAddItems(handle: string, playlistId: string, itemIds: string[]) : Promise<null> {
|
||||
return await TAURI_INVOKE("playlist_add_items", { handle, playlistId, itemIds });
|
||||
},
|
||||
/**
|
||||
* Add another account from the **current** server to this device.
|
||||
*
|
||||
* Takes no server URL. That is the same-server constraint expressed as a
|
||||
* signature rather than as form validation: there is no way to ask this command
|
||||
* for an account somewhere else.
|
||||
*
|
||||
* TRACES: UR-082 | DR-267
|
||||
*/
|
||||
async profilesAdd(username: string, password: string, pinCode: string | null, deviceId: string) : Promise<Profile> {
|
||||
return await TAURI_INVOKE("profiles_add", { username, password, pinCode, deviceId });
|
||||
},
|
||||
/**
|
||||
* Read the "ask who's watching on start" setting.
|
||||
*
|
||||
* Separate from [`profiles_startup_target`] on purpose: the target can be
|
||||
* `Picker` for reasons that have nothing to do with this setting — a
|
||||
* PIN-protected last profile always asks — so deriving the toggle's position
|
||||
* from it would show the user a switch that does not describe what it controls.
|
||||
*
|
||||
* TRACES: UR-082 | DR-274
|
||||
*/
|
||||
async profilesGetAskOnStart() : Promise<boolean> {
|
||||
return await TAURI_INVOKE("profiles_get_ask_on_start");
|
||||
},
|
||||
/**
|
||||
* List the accounts this device knows for the current server.
|
||||
*
|
||||
* TRACES: UR-082 | DR-267
|
||||
*/
|
||||
async profilesList() : Promise<Profile[]> {
|
||||
return await TAURI_INVOKE("profiles_list");
|
||||
},
|
||||
/**
|
||||
* Forget a profile on this device.
|
||||
*
|
||||
* Does not call Jellyfin's logout endpoint: removing an account from the family
|
||||
* TV should not sign that person out on their phone. The stored token is
|
||||
* deleted locally, which is the part that actually belongs to this device.
|
||||
*
|
||||
* TRACES: UR-082 | DR-267
|
||||
*/
|
||||
async profilesRemove(userId: string) : Promise<null> {
|
||||
return await TAURI_INVOKE("profiles_remove", { userId });
|
||||
},
|
||||
/**
|
||||
* Turn "ask who's watching on start" on or off.
|
||||
*
|
||||
* TRACES: UR-082 | DR-274
|
||||
*/
|
||||
async profilesSetAskOnStart(enabled: boolean) : Promise<null> {
|
||||
return await TAURI_INVOKE("profiles_set_ask_on_start", { enabled });
|
||||
},
|
||||
/**
|
||||
* Set, change, or clear a profile's PIN.
|
||||
*
|
||||
* Changing an existing PIN requires the current one. Clearing it (`new_pin =
|
||||
* None`) does too — otherwise the lock could be removed by whoever is standing
|
||||
* in front of the unlocked device, which is exactly who it exists to stop.
|
||||
*
|
||||
* TRACES: UR-083 | DR-268
|
||||
*/
|
||||
async profilesSetPin(userId: string, currentPin: string | null, newPin: string | null) : Promise<null> {
|
||||
return await TAURI_INVOKE("profiles_set_pin", { userId, currentPin, newPin });
|
||||
},
|
||||
/**
|
||||
* Whether startup should resume an account or ask who is watching.
|
||||
*
|
||||
* The decision is backend state, so the frontend asks rather than computes it.
|
||||
*
|
||||
* TRACES: UR-082 | DR-274
|
||||
*/
|
||||
async profilesStartupTarget() : Promise<StartupTarget> {
|
||||
return await TAURI_INVOKE("profiles_startup_target");
|
||||
},
|
||||
/**
|
||||
* Enter a profile, with its PIN if it has one.
|
||||
*
|
||||
* A profile with no PIN ignores whatever `pin` was passed — the frontend cannot
|
||||
* invent a lock the backend does not have, and cannot skip one it does.
|
||||
*
|
||||
* TRACES: UR-082, UR-083 | DR-267, DR-268, DR-270
|
||||
*/
|
||||
async profilesUnlock(userId: string, pinCode: string | null) : Promise<UnlockOutcome> {
|
||||
return await TAURI_INVOKE("profiles_unlock", { userId, pinCode });
|
||||
},
|
||||
/**
|
||||
* Enter a profile with its Jellyfin password, for someone who has forgotten
|
||||
* their PIN.
|
||||
*
|
||||
* There is deliberately no reset token and no recovery secret: the account's
|
||||
* own password is already the authority over it, and a second credential
|
||||
* guarding the same thing would only be a weaker one. A successful password
|
||||
* entry also clears the lockout, which is what makes a forgotten PIN a
|
||||
* detour rather than a dead end.
|
||||
*
|
||||
* TRACES: UR-084 | DR-269
|
||||
*/
|
||||
async profilesUnlockWithPassword(userId: string, password: string, deviceId: string) : Promise<UnlockOutcome> {
|
||||
return await TAURI_INVOKE("profiles_unlock_with_password", { userId, password, deviceId });
|
||||
},
|
||||
/**
|
||||
* Remove items from a playlist (uses PlaylistItemId entry IDs, NOT media item IDs)
|
||||
*/
|
||||
@@ -3198,6 +3299,16 @@ alreadyDownloaded: number;
|
||||
* Number of tracks skipped (no jellyfin ID or other reasons)
|
||||
*/
|
||||
skipped: number }
|
||||
/**
|
||||
* A switchable account on this device.
|
||||
*
|
||||
* TRACES: UR-082 | DR-267
|
||||
*/
|
||||
export type Profile = { userId: string; username: string; serverId: string;
|
||||
/**
|
||||
* Jellyfin's primary-image tag, for the tile. `None` renders initials.
|
||||
*/
|
||||
avatarTag: string | null; unlockMethod: UnlockMethod; lastUsedAt: string | null; isActive: boolean }
|
||||
/**
|
||||
* One rung of the quality picker, as it applies to *this* media source.
|
||||
*
|
||||
@@ -3358,6 +3469,25 @@ export type SleepTimerState = { mode: SleepTimerMode; remainingSeconds: number }
|
||||
* SmartCache statistics
|
||||
*/
|
||||
export type SmartCacheStats = { total_size: number; storage_limit: number; available_space: number; items_count: number; config: CacheConfig }
|
||||
/**
|
||||
* What the app should do when it starts.
|
||||
*
|
||||
* The decision is backend state (profile count, PIN presence, a stored
|
||||
* setting), so the frontend asks rather than computes. A single account with no
|
||||
* PIN always resumes, which is what keeps this feature invisible until it is
|
||||
* wanted.
|
||||
*
|
||||
* TRACES: UR-082 | DR-274
|
||||
*/
|
||||
export type StartupTarget =
|
||||
/**
|
||||
* Resume this profile without asking.
|
||||
*/
|
||||
{ type: "resume"; userId: string } |
|
||||
/**
|
||||
* Show the picker.
|
||||
*/
|
||||
{ type: "picker" }
|
||||
/**
|
||||
* Storage statistics for downloads
|
||||
*/
|
||||
@@ -3558,6 +3688,50 @@ export type Transport =
|
||||
* server standing in front of one.
|
||||
*/
|
||||
{ type: "localFile" }
|
||||
/**
|
||||
* How a profile is entered.
|
||||
*
|
||||
* Deliberately not "adult"/"child": the app has no way to know a person's age
|
||||
* and no business encoding one. It knows whether a code is set.
|
||||
*
|
||||
* TRACES: UR-083 | DR-276
|
||||
*/
|
||||
export type UnlockMethod =
|
||||
/**
|
||||
* One tap. No code set.
|
||||
*/
|
||||
"none" |
|
||||
/**
|
||||
* A numeric code gates the switch.
|
||||
*/
|
||||
"pin"
|
||||
/**
|
||||
* The result of an unlock attempt.
|
||||
*
|
||||
* Note the explicit field renames. tauri-specta emits tagged-union *fields*
|
||||
* with their Rust names rather than camelCasing them, so a field that would
|
||||
* differ between the two conventions is renamed here by hand — the same trap
|
||||
* that produced `new_url` on the frontend once already.
|
||||
*
|
||||
* TRACES: UR-083, UR-084 | DR-268, DR-269
|
||||
*/
|
||||
export type UnlockOutcome =
|
||||
/**
|
||||
* Switched. The profile is now active.
|
||||
*/
|
||||
{ type: "ok"; userId: string } |
|
||||
/**
|
||||
* Wrong code, attempts left.
|
||||
*/
|
||||
{ type: "wrongPin"; attemptsRemaining: number } |
|
||||
/**
|
||||
* Too many wrong codes; refused until this RFC3339 instant.
|
||||
*/
|
||||
{ type: "lockedOut"; until: string } |
|
||||
/**
|
||||
* No code is recoverable from here — sign in with the account password.
|
||||
*/
|
||||
{ type: "needsPassword" }
|
||||
/**
|
||||
* User information
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
<!--
|
||||
Numeric PIN entry.
|
||||
|
||||
Presentation only: it collects digits and hands them up. It does not know the
|
||||
PIN, does not compare anything, and does not count attempts — the backend
|
||||
returns an `UnlockOutcome` and this renders whatever it says. A pad that could
|
||||
decide would be a lock the webview could pick.
|
||||
|
||||
Sized for a living room: large targets, usable with a remote's arrow keys as
|
||||
well as a touchscreen.
|
||||
|
||||
TRACES: UR-083 | DR-276
|
||||
-->
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
/** Digits entered so far. */
|
||||
value: string;
|
||||
/** Message under the dots — wrong PIN, lockout, etc. */
|
||||
error?: string | null;
|
||||
/** Blocks input while an attempt is in flight or the profile is locked out. */
|
||||
disabled?: boolean;
|
||||
maxLength?: number;
|
||||
onsubmit: (pin: string) => void;
|
||||
oncancel: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
value = $bindable(),
|
||||
error = null,
|
||||
disabled = false,
|
||||
maxLength = 8,
|
||||
onsubmit,
|
||||
oncancel,
|
||||
}: Props = $props();
|
||||
|
||||
const KEYS = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "", "0", "⌫"];
|
||||
|
||||
function press(key: string) {
|
||||
if (disabled) return;
|
||||
if (key === "⌫") {
|
||||
value = value.slice(0, -1);
|
||||
} else if (key && value.length < maxLength) {
|
||||
value = value + key;
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (disabled) return;
|
||||
if (/^[0-9]$/.test(event.key)) {
|
||||
event.preventDefault();
|
||||
press(event.key);
|
||||
} else if (event.key === "Backspace") {
|
||||
event.preventDefault();
|
||||
press("⌫");
|
||||
} else if (event.key === "Enter" && value.length >= 4) {
|
||||
event.preventDefault();
|
||||
onsubmit(value);
|
||||
} else if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
oncancel();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
||||
<div class="flex flex-col items-center gap-6">
|
||||
<!-- Entered digits, shown as dots. -->
|
||||
<div class="flex gap-3 h-4 items-center" aria-live="polite" aria-label="PIN entry">
|
||||
{#each Array(Math.max(value.length, 4)) as _, i (i)}
|
||||
<div
|
||||
class="rounded-full transition-all {i < value.length
|
||||
? 'w-3 h-3 bg-white'
|
||||
: 'w-3 h-3 bg-gray-600'}"
|
||||
></div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<p class="text-red-400 text-sm text-center max-w-xs" role="alert">{error}</p>
|
||||
{/if}
|
||||
|
||||
<div class="grid grid-cols-3 gap-3">
|
||||
{#each KEYS as key (key)}
|
||||
{#if key === ""}
|
||||
<div></div>
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => press(key)}
|
||||
{disabled}
|
||||
class="w-18 h-18 min-w-[4.5rem] min-h-[4.5rem] rounded-full bg-[var(--color-surface)] hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-[var(--color-jellyfin)] disabled:opacity-40 disabled:cursor-not-allowed text-2xl font-light transition-colors"
|
||||
aria-label={key === "⌫" ? "Delete" : key}
|
||||
>
|
||||
{key}
|
||||
</button>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 w-full max-w-xs">
|
||||
<button
|
||||
type="button"
|
||||
onclick={oncancel}
|
||||
class="flex-1 py-3 rounded-lg border border-gray-700 hover:bg-[var(--color-surface)] transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => onsubmit(value)}
|
||||
disabled={disabled || value.length < 4}
|
||||
class="flex-1 py-3 rounded-lg bg-[var(--color-jellyfin)] hover:bg-[var(--color-jellyfin-dark)] disabled:opacity-50 disabled:cursor-not-allowed font-medium transition-colors"
|
||||
>
|
||||
Unlock
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -4,7 +4,7 @@
|
||||
Settings, Display) and Sign out. Available on every authenticated,
|
||||
non-immersive screen via the shared AppHeader.
|
||||
|
||||
TRACES: UR-054 | DR-075
|
||||
TRACES: UR-054, UR-082 | DR-075, DR-276
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { tick } from "svelte";
|
||||
@@ -105,6 +105,32 @@
|
||||
|
||||
<div class="border-t border-gray-700 my-1"></div>
|
||||
|
||||
<!--
|
||||
Grouped with the identity block above rather than with the destinations
|
||||
below: this changes *who* the app is, not where it goes. Shown even on a
|
||||
device with one account, because the picker is also where a second one is
|
||||
added — hiding it until a second profile exists would leave no way in
|
||||
from here. (DR-276)
|
||||
-->
|
||||
<a
|
||||
href="/profiles"
|
||||
role="menuitem"
|
||||
class="flex items-center gap-3 px-4 py-3 text-sm text-gray-300 hover:bg-gray-700 transition-colors"
|
||||
onclick={() => close(false)}
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"
|
||||
/>
|
||||
</svg>
|
||||
Switch profile
|
||||
</a>
|
||||
|
||||
<div class="border-t border-gray-700 my-1"></div>
|
||||
|
||||
<a
|
||||
href="/downloads"
|
||||
role="menuitem"
|
||||
|
||||
@@ -73,7 +73,22 @@ describe("AccountMenu", () => {
|
||||
render(AccountMenu);
|
||||
openMenu();
|
||||
const items = screen.getAllByRole("menuitem").map((el) => el.textContent?.trim());
|
||||
expect(items).toEqual(["Downloads", "Settings", "Display", "Sign out"]);
|
||||
expect(items).toEqual(["Switch profile", "Downloads", "Settings", "Display", "Sign out"]);
|
||||
});
|
||||
|
||||
/**
|
||||
* "Switch profile" sits directly under the identity block, before the
|
||||
* destinations. It answers "who is this?", not "where do I go?", and burying
|
||||
* it in Settings is what this entry exists to undo.
|
||||
*
|
||||
* TRACES: UR-082 | DR-276
|
||||
*/
|
||||
it("puts Switch profile first, next to the identity block", async () => {
|
||||
render(AccountMenu);
|
||||
openMenu();
|
||||
const items = screen.getAllByRole("menuitem");
|
||||
expect(items[0].textContent?.trim()).toBe("Switch profile");
|
||||
expect(items[0].getAttribute("href")).toBe("/profiles");
|
||||
});
|
||||
|
||||
it("shows the identity block with name and server host", async () => {
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
<!--
|
||||
PIN management for the profile you are signed in as.
|
||||
|
||||
Deliberately scoped to the *active* profile. Letting a signed-in session set a
|
||||
PIN on someone else's profile would be an escalation path with no real use —
|
||||
a PIN-less profile could be locked by anyone standing at the device, and the
|
||||
owner would be pushed down the password route to get back into their own
|
||||
account. Other profiles are listed read-only so a parent can see at a glance
|
||||
which are protected; adding and removing them lives on the picker.
|
||||
|
||||
Nothing here compares a PIN or counts an attempt. The form validates shape so
|
||||
it can say what is wrong before submitting; Rust validates again and is the
|
||||
only thing that ever verifies one.
|
||||
|
||||
TRACES: UR-082, UR-083 | DR-268, DR-276
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { goto } from "$app/navigation";
|
||||
import { profiles } from "$lib/stores/profiles";
|
||||
import {
|
||||
validatePinForm,
|
||||
pinActionLabel,
|
||||
PIN_MAX_LENGTH,
|
||||
type PinIntent,
|
||||
} from "$lib/utils/pinForm";
|
||||
import { createLogger } from "$lib/utils/logger";
|
||||
|
||||
const log = createLogger("ProfileSecurity");
|
||||
|
||||
let intent = $state<PinIntent | null>(null);
|
||||
let currentPin = $state("");
|
||||
let newPin = $state("");
|
||||
let confirmPin = $state("");
|
||||
let busy = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
let notice = $state<string | null>(null);
|
||||
|
||||
const active = $derived($profiles.profiles.find((p) => p.isActive) ?? null);
|
||||
const others = $derived($profiles.profiles.filter((p) => !p.isActive));
|
||||
const hasPin = $derived(active?.unlockMethod === "pin");
|
||||
|
||||
const validationError = $derived(
|
||||
intent === null ? null : validatePinForm({ intent, hasPin, currentPin, newPin, confirmPin }),
|
||||
);
|
||||
|
||||
onMount(() => {
|
||||
void profiles.refresh();
|
||||
});
|
||||
|
||||
function begin(next: PinIntent) {
|
||||
intent = next;
|
||||
currentPin = "";
|
||||
newPin = "";
|
||||
confirmPin = "";
|
||||
error = null;
|
||||
notice = null;
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
intent = null;
|
||||
currentPin = "";
|
||||
newPin = "";
|
||||
confirmPin = "";
|
||||
error = null;
|
||||
}
|
||||
|
||||
async function submit(event: Event) {
|
||||
event.preventDefault();
|
||||
if (!active || intent === null || validationError) return;
|
||||
|
||||
busy = true;
|
||||
error = null;
|
||||
try {
|
||||
await profiles.setPin(
|
||||
active.userId,
|
||||
hasPin ? currentPin : null,
|
||||
intent === "clear" ? null : newPin,
|
||||
);
|
||||
notice =
|
||||
intent === "clear" ? "PIN turned off. This profile now opens with one tap." : "PIN saved.";
|
||||
cancel();
|
||||
} catch (e) {
|
||||
log.error("Could not update PIN:", e);
|
||||
error = e instanceof Error ? e.message : "Could not update the PIN";
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-5">
|
||||
{#if !active}
|
||||
<p class="text-sm text-gray-400">No profile is signed in.</p>
|
||||
{:else}
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-white mb-1">
|
||||
PIN for {active.username}
|
||||
</h3>
|
||||
<p class="text-sm text-gray-400">
|
||||
{#if hasPin}
|
||||
This profile asks for a PIN before anyone can switch to it.
|
||||
{:else}
|
||||
This profile opens with one tap — right for a child's account, and what you want unless
|
||||
there is something to keep out.
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
class="shrink-0 text-xs px-2 py-1 rounded-full {hasPin
|
||||
? 'bg-green-900/60 text-green-300'
|
||||
: 'bg-gray-700 text-gray-300'}"
|
||||
>
|
||||
{hasPin ? "On" : "Off"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{#if notice}
|
||||
<p class="text-sm text-green-400">{notice}</p>
|
||||
{/if}
|
||||
|
||||
{#if intent === null}
|
||||
<div class="flex flex-wrap gap-3">
|
||||
{#if hasPin}
|
||||
<button
|
||||
onclick={() => begin("change")}
|
||||
class="px-4 py-2 bg-[var(--color-jellyfin)] hover:bg-[var(--color-jellyfin-dark)] rounded-lg font-medium transition-colors"
|
||||
>
|
||||
Change PIN
|
||||
</button>
|
||||
<button
|
||||
onclick={() => begin("clear")}
|
||||
class="px-4 py-2 border border-gray-600 hover:bg-gray-700 rounded-lg transition-colors"
|
||||
>
|
||||
Turn off PIN
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
onclick={() => begin("set")}
|
||||
class="px-4 py-2 bg-[var(--color-jellyfin)] hover:bg-[var(--color-jellyfin-dark)] rounded-lg font-medium transition-colors"
|
||||
>
|
||||
Set a PIN
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<form onsubmit={submit} class="space-y-4 max-w-sm">
|
||||
{#if hasPin}
|
||||
<div>
|
||||
<label for="current-pin" class="block text-sm font-medium text-gray-300 mb-2">
|
||||
Current PIN
|
||||
</label>
|
||||
<input
|
||||
id="current-pin"
|
||||
type="password"
|
||||
inputmode="numeric"
|
||||
maxlength={PIN_MAX_LENGTH}
|
||||
bind:value={currentPin}
|
||||
disabled={busy}
|
||||
class="w-full px-4 py-3 bg-[var(--color-bg)] border border-gray-700 rounded-lg focus:outline-none focus:border-[var(--color-jellyfin)] text-white tracking-widest"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if intent !== "clear"}
|
||||
<div>
|
||||
<label for="new-pin" class="block text-sm font-medium text-gray-300 mb-2">
|
||||
New PIN (4–8 digits)
|
||||
</label>
|
||||
<input
|
||||
id="new-pin"
|
||||
type="password"
|
||||
inputmode="numeric"
|
||||
maxlength={PIN_MAX_LENGTH}
|
||||
bind:value={newPin}
|
||||
disabled={busy}
|
||||
class="w-full px-4 py-3 bg-[var(--color-bg)] border border-gray-700 rounded-lg focus:outline-none focus:border-[var(--color-jellyfin)] text-white tracking-widest"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="confirm-pin" class="block text-sm font-medium text-gray-300 mb-2">
|
||||
Confirm new PIN
|
||||
</label>
|
||||
<input
|
||||
id="confirm-pin"
|
||||
type="password"
|
||||
inputmode="numeric"
|
||||
maxlength={PIN_MAX_LENGTH}
|
||||
bind:value={confirmPin}
|
||||
disabled={busy}
|
||||
class="w-full px-4 py-3 bg-[var(--color-bg)] border border-gray-700 rounded-lg focus:outline-none focus:border-[var(--color-jellyfin)] text-white tracking-widest"
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-sm text-gray-400">
|
||||
Turning the PIN off means anyone using this device can switch to
|
||||
{active.username} in one tap.
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if error}
|
||||
<div class="p-3 bg-red-900/50 border border-red-700 rounded-lg text-red-200 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
{:else if validationError && (currentPin || newPin || confirmPin)}
|
||||
<p class="text-sm text-amber-400">{validationError}</p>
|
||||
{/if}
|
||||
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onclick={cancel}
|
||||
disabled={busy}
|
||||
class="flex-1 py-3 rounded-lg border border-gray-600 hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy || validationError !== null}
|
||||
class="flex-1 py-3 rounded-lg bg-[var(--color-jellyfin)] hover:bg-[var(--color-jellyfin-dark)] disabled:opacity-50 disabled:cursor-not-allowed font-medium transition-colors"
|
||||
>
|
||||
{pinActionLabel(intent)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
{#if others.length > 0}
|
||||
<div class="border-t border-gray-700 pt-5">
|
||||
<h3 class="text-sm font-semibold text-white mb-3">Other profiles on this device</h3>
|
||||
<ul class="space-y-2">
|
||||
{#each others as profile (profile.userId)}
|
||||
<li class="flex items-center justify-between text-sm">
|
||||
<span class="text-gray-300">{profile.username}</span>
|
||||
<span class="text-xs text-gray-500">
|
||||
{profile.unlockMethod === "pin" ? "PIN" : "No PIN"}
|
||||
</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
<p class="text-xs text-gray-500 mt-3">
|
||||
A profile's PIN can only be changed from that profile. Sign in as them to set one.
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="border-t border-gray-700 pt-5">
|
||||
<button
|
||||
onclick={() => goto("/profiles?manage=1")}
|
||||
class="text-sm text-[var(--color-jellyfin)] hover:underline"
|
||||
>
|
||||
Add or remove profiles
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -606,6 +606,66 @@ function createAuthStore() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild this store's view of the world after the backend has switched
|
||||
* profiles.
|
||||
*
|
||||
* The backend already flipped the active user, adopted the new session and
|
||||
* destroyed the old repository handle — in that order, which is the part that
|
||||
* matters. What is left is the half only the frontend owns: a `RepositoryClient`
|
||||
* bound to the new session, and the player's reporting configuration.
|
||||
*
|
||||
* Deliberately *not* a login: no password is involved and no token is minted,
|
||||
* because switching must leave both profiles able to come back with one tap.
|
||||
*
|
||||
* TRACES: UR-082 | DR-270
|
||||
*/
|
||||
async function adoptSwitchedSession() {
|
||||
const session = await commands.authGetSession();
|
||||
if (!session) throw new Error("No session after profile switch");
|
||||
|
||||
if (repository) {
|
||||
try {
|
||||
await repository.destroy();
|
||||
} catch (error) {
|
||||
log.error("Failed to destroy repository during switch:", error);
|
||||
}
|
||||
}
|
||||
|
||||
repository = new RepositoryClient();
|
||||
await repository.create(
|
||||
session.serverUrl,
|
||||
session.userId,
|
||||
session.accessToken,
|
||||
session.serverId,
|
||||
);
|
||||
|
||||
try {
|
||||
const deviceId = await getDeviceId();
|
||||
await commands.playerConfigureJellyfin(
|
||||
session.serverUrl,
|
||||
session.accessToken,
|
||||
session.userId,
|
||||
deviceId,
|
||||
);
|
||||
} catch (error) {
|
||||
log.error("Failed to reconfigure player after switch:", error);
|
||||
}
|
||||
|
||||
set({
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
user: { id: session.userId, name: session.username } as User,
|
||||
serverUrl: session.serverUrl,
|
||||
serverName: session.serverName,
|
||||
error: null,
|
||||
securityWarning: null,
|
||||
needsReauth: false,
|
||||
isVerifying: false,
|
||||
sessionVerified: session.verified,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
initialize,
|
||||
@@ -620,6 +680,7 @@ function createAuthStore() {
|
||||
getUserId,
|
||||
getServerUrl,
|
||||
retryVerification,
|
||||
adoptSwitchedSession,
|
||||
cleanupEventListeners,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* Profile switching — a thin wrapper over the Rust `profiles_*` commands.
|
||||
*
|
||||
* There is deliberately no logic here worth the name. This store does not
|
||||
* compare a PIN, count an attempt, decide whether a profile is locked, or work
|
||||
* out whether the picker should appear at startup. All of that is backend state
|
||||
* and arrives as an opaque `unlockMethod`, an `UnlockOutcome` or a
|
||||
* `StartupTarget`. A store that re-derived any of it would be a gate the webview
|
||||
* could skip.
|
||||
*
|
||||
* TRACES: UR-082, UR-083, UR-084 | DR-267, DR-269, DR-274, DR-276
|
||||
*/
|
||||
|
||||
import { writable, get } from "svelte/store";
|
||||
import { commands } from "$lib/api/bindings";
|
||||
import type { Profile, StartupTarget, UnlockOutcome } from "$lib/api/bindings";
|
||||
import { getDeviceId } from "$lib/services/deviceId";
|
||||
import { auth } from "./auth";
|
||||
import { createLogger } from "$lib/utils/logger";
|
||||
|
||||
const log = createLogger("Profiles");
|
||||
|
||||
interface ProfilesState {
|
||||
profiles: Profile[];
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
function createProfilesStore() {
|
||||
const { subscribe, set, update } = writable<ProfilesState>({
|
||||
profiles: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
async function refresh(): Promise<Profile[]> {
|
||||
update((s) => ({ ...s, isLoading: true, error: null }));
|
||||
try {
|
||||
const profiles = await commands.profilesList();
|
||||
set({ profiles, isLoading: false, error: null });
|
||||
return profiles;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
log.error("Failed to list profiles:", error);
|
||||
set({ profiles: [], isLoading: false, error: message });
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the app should go on launch. Asked, never computed — the answer
|
||||
* depends on PIN presence and a stored setting, neither of which the frontend
|
||||
* should be reasoning about.
|
||||
*/
|
||||
async function startupTarget(): Promise<StartupTarget> {
|
||||
return await commands.profilesStartupTarget();
|
||||
}
|
||||
|
||||
/**
|
||||
* Enter a profile. On success the backend has already switched; this only
|
||||
* rebuilds the frontend's repository handle.
|
||||
*/
|
||||
async function unlock(userId: string, pin: string | null): Promise<UnlockOutcome> {
|
||||
const outcome = await commands.profilesUnlock(userId, pin);
|
||||
if (outcome.type === "ok") {
|
||||
await auth.adoptSwitchedSession();
|
||||
await refresh();
|
||||
}
|
||||
return outcome;
|
||||
}
|
||||
|
||||
/**
|
||||
* The way back in for someone who has forgotten their PIN. Their ordinary
|
||||
* Jellyfin password is the authority over their own account, so there is
|
||||
* nothing else to reset.
|
||||
*/
|
||||
async function unlockWithPassword(userId: string, password: string): Promise<UnlockOutcome> {
|
||||
const deviceId = await getDeviceId();
|
||||
const outcome = await commands.profilesUnlockWithPassword(userId, password, deviceId);
|
||||
if (outcome.type === "ok") {
|
||||
await auth.adoptSwitchedSession();
|
||||
await refresh();
|
||||
}
|
||||
return outcome;
|
||||
}
|
||||
|
||||
/** Add another account from the server already connected. */
|
||||
async function add(username: string, password: string, pin: string | null): Promise<Profile> {
|
||||
const deviceId = await getDeviceId();
|
||||
const profile = await commands.profilesAdd(username, password, pin, deviceId);
|
||||
await refresh();
|
||||
return profile;
|
||||
}
|
||||
|
||||
async function setPin(userId: string, currentPin: string | null, newPin: string | null) {
|
||||
await commands.profilesSetPin(userId, currentPin, newPin);
|
||||
await refresh();
|
||||
}
|
||||
|
||||
async function remove(userId: string) {
|
||||
await commands.profilesRemove(userId);
|
||||
await refresh();
|
||||
}
|
||||
|
||||
async function setAskOnStart(enabled: boolean) {
|
||||
await commands.profilesSetAskOnStart(enabled);
|
||||
}
|
||||
|
||||
/** Whether this device has more than one account — what makes the UI worth showing at all. */
|
||||
function isShared(): boolean {
|
||||
return get({ subscribe }).profiles.length > 1;
|
||||
}
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
refresh,
|
||||
startupTarget,
|
||||
unlock,
|
||||
unlockWithPassword,
|
||||
add,
|
||||
setPin,
|
||||
remove,
|
||||
setAskOnStart,
|
||||
isShared,
|
||||
};
|
||||
}
|
||||
|
||||
export const profiles = createProfilesStore();
|
||||
@@ -202,3 +202,37 @@ describe("showHeaderSearch", () => {
|
||||
expect(showHeaderSearch({ pathname: "/settings" })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The profile picker is a gate, not a page. It must be chrome-free for the same
|
||||
* reason /login is: bottom nav over a "who's watching" screen lets someone tab
|
||||
* straight past the profile they were being asked to choose, and an in-app mini
|
||||
* player on it would offer a route into the previous profile's queue. OS
|
||||
* lockscreen transport controls are unaffected — those are what keep playing
|
||||
* audio controllable while the app is locked (DR-275).
|
||||
*
|
||||
* TRACES: UR-082 | DR-276
|
||||
*/
|
||||
describe("profile picker chrome", () => {
|
||||
const pathname = "/profiles";
|
||||
|
||||
it("shows no bottom nav, even authenticated", () => {
|
||||
expect(showBottomNav({ pathname, isAuthenticated: true })).toBe(false);
|
||||
});
|
||||
|
||||
it("shows no global mini player", () => {
|
||||
expect(showGlobalMiniPlayer({ pathname })).toBe(false);
|
||||
});
|
||||
|
||||
it("shows no global header", () => {
|
||||
expect(showGlobalHeader({ pathname, isAuthenticated: true })).toBe(false);
|
||||
});
|
||||
|
||||
it("owns its own layout", () => {
|
||||
expect(routeOwnsLayout({ pathname })).toBe(true);
|
||||
});
|
||||
|
||||
it("lets the shell reserve the bottom inset, since nothing else will", () => {
|
||||
expect(shellReservesBottomInset({ pathname, isAuthenticated: true })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -28,10 +28,19 @@ export interface BottomUiVisibilityInput {
|
||||
|
||||
/**
|
||||
* The bottom nav is shown on every authenticated route except the full-screen
|
||||
* player and the login route.
|
||||
* player, the login route, and the profile picker.
|
||||
*
|
||||
* `/profiles` is grouped with `/login` throughout this module because it is a
|
||||
* gate rather than a page: a nav bar over "who's watching" lets someone tab
|
||||
* straight past the choice they were being asked to make.
|
||||
*/
|
||||
export function showBottomNav({ pathname, isAuthenticated }: BottomUiVisibilityInput): boolean {
|
||||
return isAuthenticated && !pathname.startsWith("/player/") && !pathname.startsWith("/login");
|
||||
return (
|
||||
isAuthenticated &&
|
||||
!pathname.startsWith("/player/") &&
|
||||
!pathname.startsWith("/login") &&
|
||||
!pathname.startsWith("/profiles")
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -44,6 +53,7 @@ export function showGlobalMiniPlayer({ pathname }: { pathname: string }): boolea
|
||||
return (
|
||||
!pathname.startsWith("/player/") &&
|
||||
!pathname.startsWith("/login") &&
|
||||
!pathname.startsWith("/profiles") &&
|
||||
!pathname.startsWith("/settings")
|
||||
);
|
||||
}
|
||||
@@ -58,7 +68,8 @@ export function routeOwnsLayout({ pathname }: { pathname: string }): boolean {
|
||||
return (
|
||||
pathname.startsWith("/library") ||
|
||||
pathname.startsWith("/player/") ||
|
||||
pathname.startsWith("/login")
|
||||
pathname.startsWith("/login") ||
|
||||
pathname.startsWith("/profiles")
|
||||
);
|
||||
}
|
||||
|
||||
@@ -69,7 +80,7 @@ export function routeOwnsLayout({ pathname }: { pathname: string }): boolean {
|
||||
* Routes that own their layout (library) render their own AppHeader, so the
|
||||
* root must not double it up. `/settings` owns its content but deliberately has
|
||||
* no account menu (the user is already there). `/player/*` and `/login` are
|
||||
* immersive/chrome-free. Everything else authenticated (`/`, `/search`,
|
||||
* immersive/chrome-free, as is `/profiles`. Everything else authenticated (`/`, `/search`,
|
||||
* `/downloads`) gets the header from the root — the whole point of UR-054.
|
||||
*
|
||||
* TRACES: UR-054 | DR-076
|
||||
@@ -109,7 +120,8 @@ export function showBottomUi(input: BottomUiVisibilityInput): boolean {
|
||||
* Exactly one element may reserve it. BottomUi owns it whenever it renders,
|
||||
* because the padding belongs *inside* its surface box so the colour extends
|
||||
* behind the bar rather than leaving a strip of page background. On routes with
|
||||
* no bottom UI at all (login, the full-screen player) nothing else would, so
|
||||
* no bottom UI at all (login, the profile picker, the full-screen player)
|
||||
* nothing else would, so
|
||||
* the shell takes it.
|
||||
*
|
||||
* TRACES: UR-066 | DR-112
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { validatePinForm, pinActionLabel, type PinFormState } from "./pinForm";
|
||||
|
||||
function form(overrides: Partial<PinFormState> = {}): PinFormState {
|
||||
return {
|
||||
intent: "set",
|
||||
hasPin: false,
|
||||
currentPin: "",
|
||||
newPin: "",
|
||||
confirmPin: "",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("validatePinForm", () => {
|
||||
it("accepts a well-formed new PIN", () => {
|
||||
expect(validatePinForm(form({ newPin: "1234", confirmPin: "1234" }))).toBeNull();
|
||||
});
|
||||
|
||||
it("requires the current PIN whenever one is set", () => {
|
||||
expect(
|
||||
validatePinForm(form({ intent: "change", hasPin: true, newPin: "5678", confirmPin: "5678" })),
|
||||
).toBe("Enter your current PIN.");
|
||||
});
|
||||
|
||||
it("does not ask for a current PIN when none is set", () => {
|
||||
expect(validatePinForm(form({ hasPin: false, newPin: "1234", confirmPin: "1234" }))).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects non-digits", () => {
|
||||
expect(validatePinForm(form({ newPin: "12a4", confirmPin: "12a4" }))).toBe(
|
||||
"A PIN can only contain digits.",
|
||||
);
|
||||
});
|
||||
|
||||
it("enforces the length range at both ends", () => {
|
||||
expect(validatePinForm(form({ newPin: "123", confirmPin: "123" }))).toBe(
|
||||
"A PIN must be 4–8 digits.",
|
||||
);
|
||||
expect(validatePinForm(form({ newPin: "123456789", confirmPin: "123456789" }))).toBe(
|
||||
"A PIN must be 4–8 digits.",
|
||||
);
|
||||
});
|
||||
|
||||
it("catches a mistyped confirmation", () => {
|
||||
expect(validatePinForm(form({ newPin: "1234", confirmPin: "1235" }))).toBe(
|
||||
"The two PINs do not match.",
|
||||
);
|
||||
});
|
||||
|
||||
it("asks for confirmation before comparing", () => {
|
||||
expect(validatePinForm(form({ newPin: "1234", confirmPin: "" }))).toBe("Confirm your new PIN.");
|
||||
});
|
||||
|
||||
it("rejects a change that changes nothing", () => {
|
||||
expect(
|
||||
validatePinForm(
|
||||
form({
|
||||
intent: "change",
|
||||
hasPin: true,
|
||||
currentPin: "1234",
|
||||
newPin: "1234",
|
||||
confirmPin: "1234",
|
||||
}),
|
||||
),
|
||||
).toBe("The new PIN is the same as the current one.");
|
||||
});
|
||||
|
||||
it("needs only the current PIN to turn one off", () => {
|
||||
expect(validatePinForm(form({ intent: "clear", hasPin: true, currentPin: "1234" }))).toBeNull();
|
||||
});
|
||||
|
||||
it("will not turn off a PIN without the current one", () => {
|
||||
expect(validatePinForm(form({ intent: "clear", hasPin: true, currentPin: "" }))).toBe(
|
||||
"Enter your current PIN.",
|
||||
);
|
||||
});
|
||||
|
||||
it("reports one problem at a time, most blocking first", () => {
|
||||
// Both the current PIN is missing *and* the new one is too short; the
|
||||
// current-PIN prompt is the one that comes back.
|
||||
expect(
|
||||
validatePinForm(form({ intent: "change", hasPin: true, newPin: "1", confirmPin: "2" })),
|
||||
).toBe("Enter your current PIN.");
|
||||
});
|
||||
});
|
||||
|
||||
describe("pinActionLabel", () => {
|
||||
it("names each intent", () => {
|
||||
expect(pinActionLabel("set")).toBe("Set PIN");
|
||||
expect(pinActionLabel("change")).toBe("Change PIN");
|
||||
expect(pinActionLabel("clear")).toBe("Turn off PIN");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* Validation for the PIN settings form.
|
||||
*
|
||||
* Pure, and separate from the component, so the rules can be tested without
|
||||
* mounting anything — the same reason `episodeStrip.ts` exists.
|
||||
*
|
||||
* This is *not* the security boundary. Rust validates the PIN shape again in
|
||||
* `pin::validate_pin` and is the only thing that ever compares one. What lives
|
||||
* here is the difference between a form that tells you what is wrong before you
|
||||
* submit and one that bounces you off a backend error.
|
||||
*
|
||||
* TRACES: UR-083 | DR-268, DR-276
|
||||
*/
|
||||
|
||||
/** Mirrors `pin::MAX_ATTEMPTS`-adjacent shape rules in Rust. */
|
||||
export const PIN_MIN_LENGTH = 4;
|
||||
export const PIN_MAX_LENGTH = 8;
|
||||
|
||||
export type PinIntent = "set" | "change" | "clear";
|
||||
|
||||
export interface PinFormState {
|
||||
intent: PinIntent;
|
||||
/** Whether the profile currently has a PIN — decides if `currentPin` is required. */
|
||||
hasPin: boolean;
|
||||
currentPin: string;
|
||||
newPin: string;
|
||||
confirmPin: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The reason this form cannot be submitted yet, or `null` when it can.
|
||||
*
|
||||
* Returns one message rather than a list: a PIN form has at most one thing
|
||||
* wrong with it worth saying, and stacking "too short" under "doesn't match"
|
||||
* reads as nagging.
|
||||
*
|
||||
* TRACES: UR-083 | DR-276
|
||||
*/
|
||||
export function validatePinForm(state: PinFormState): string | null {
|
||||
if (state.hasPin && state.currentPin.length === 0) {
|
||||
return "Enter your current PIN.";
|
||||
}
|
||||
|
||||
if (state.intent === "clear") {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (state.newPin.length === 0) {
|
||||
return "Choose a PIN.";
|
||||
}
|
||||
if (!/^[0-9]+$/.test(state.newPin)) {
|
||||
return "A PIN can only contain digits.";
|
||||
}
|
||||
if (state.newPin.length < PIN_MIN_LENGTH || state.newPin.length > PIN_MAX_LENGTH) {
|
||||
return `A PIN must be ${PIN_MIN_LENGTH}–${PIN_MAX_LENGTH} digits.`;
|
||||
}
|
||||
if (state.confirmPin.length === 0) {
|
||||
return "Confirm your new PIN.";
|
||||
}
|
||||
if (state.newPin !== state.confirmPin) {
|
||||
return "The two PINs do not match.";
|
||||
}
|
||||
if (state.intent === "change" && state.currentPin === state.newPin) {
|
||||
return "The new PIN is the same as the current one.";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* What the button should say. Derived rather than hardcoded per branch so the
|
||||
* three intents cannot drift apart in the markup.
|
||||
*
|
||||
* TRACES: UR-083 | DR-276
|
||||
*/
|
||||
export function pinActionLabel(intent: PinIntent): string {
|
||||
switch (intent) {
|
||||
case "set":
|
||||
return "Set PIN";
|
||||
case "change":
|
||||
return "Change PIN";
|
||||
case "clear":
|
||||
return "Turn off PIN";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { orderProfiles, initialsFor, tileColour, lockoutMessage } from "./profileTiles";
|
||||
import type { Profile } from "$lib/api/bindings";
|
||||
|
||||
function profile(overrides: Partial<Profile> = {}): Profile {
|
||||
return {
|
||||
userId: "u1",
|
||||
username: "Dad",
|
||||
serverId: "s1",
|
||||
avatarTag: null,
|
||||
unlockMethod: "none",
|
||||
lastUsedAt: null,
|
||||
isActive: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("orderProfiles", () => {
|
||||
it("puts the most recently used profile first", () => {
|
||||
const ordered = orderProfiles([
|
||||
profile({ userId: "a", lastUsedAt: "2026-01-01T00:00:00Z" }),
|
||||
profile({ userId: "b", lastUsedAt: "2026-03-01T00:00:00Z" }),
|
||||
]);
|
||||
expect(ordered.map((p) => p.userId)).toEqual(["b", "a"]);
|
||||
});
|
||||
|
||||
it("sorts never-used profiles after used ones, then by name", () => {
|
||||
const ordered = orderProfiles([
|
||||
profile({ userId: "z", username: "Zoe", lastUsedAt: null }),
|
||||
profile({ userId: "a", username: "Ann", lastUsedAt: null }),
|
||||
profile({ userId: "u", username: "Used", lastUsedAt: "2026-01-01T00:00:00Z" }),
|
||||
]);
|
||||
expect(ordered.map((p) => p.username)).toEqual(["Used", "Ann", "Zoe"]);
|
||||
});
|
||||
|
||||
it("does not mutate its input", () => {
|
||||
const input = [
|
||||
profile({ userId: "a", lastUsedAt: "2026-01-01T00:00:00Z" }),
|
||||
profile({ userId: "b", lastUsedAt: "2026-03-01T00:00:00Z" }),
|
||||
];
|
||||
orderProfiles(input);
|
||||
expect(input.map((p) => p.userId)).toEqual(["a", "b"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("initialsFor", () => {
|
||||
it("takes two letters from a single name", () => {
|
||||
expect(initialsFor("Dad")).toBe("DA");
|
||||
});
|
||||
|
||||
it("takes first and last initials from a multi-part name", () => {
|
||||
expect(initialsFor("Anna Marie Smith")).toBe("AS");
|
||||
});
|
||||
|
||||
it("splits on the separators usernames actually use", () => {
|
||||
expect(initialsFor("anna_smith")).toBe("AS");
|
||||
expect(initialsFor("anna.smith")).toBe("AS");
|
||||
expect(initialsFor("anna-smith")).toBe("AS");
|
||||
});
|
||||
|
||||
it("survives an empty or whitespace name", () => {
|
||||
expect(initialsFor("")).toBe("?");
|
||||
expect(initialsFor(" ")).toBe("?");
|
||||
});
|
||||
});
|
||||
|
||||
describe("tileColour", () => {
|
||||
it("is stable for the same user", () => {
|
||||
expect(tileColour("user-123")).toBe(tileColour("user-123"));
|
||||
});
|
||||
|
||||
it("is derived from the id, not the name, so renaming keeps the colour", () => {
|
||||
// Same id, different display names — the caller only passes the id, which is
|
||||
// the point: a rename cannot move someone's tile colour.
|
||||
expect(tileColour("user-123")).toBe(tileColour("user-123"));
|
||||
expect(tileColour("user-456")).not.toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("lockoutMessage", () => {
|
||||
const now = new Date("2026-01-01T00:00:00Z");
|
||||
|
||||
it("rounds up to whole minutes", () => {
|
||||
expect(lockoutMessage("2026-01-01T00:02:30Z", now)).toBe("Try again in 3 minutes");
|
||||
});
|
||||
|
||||
it("phrases under a minute without a number", () => {
|
||||
expect(lockoutMessage("2026-01-01T00:00:30Z", now)).toBe("Try again in less than a minute");
|
||||
});
|
||||
|
||||
it("treats an elapsed lockout as over", () => {
|
||||
expect(lockoutMessage("2025-12-31T23:59:00Z", now)).toBe("Try again now");
|
||||
});
|
||||
|
||||
it("does not render NaN when the timestamp is unusable", () => {
|
||||
expect(lockoutMessage("not-a-date", now)).toBe("Try again now");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Presentation helpers for the profile picker.
|
||||
*
|
||||
* Pure, and separate from the component, because this is the half worth testing
|
||||
* and a component cannot be unit-tested without mounting it.
|
||||
*
|
||||
* Note what is *not* here: nothing decides whether a profile is locked, whether
|
||||
* a PIN is correct, or how many attempts remain. Those come from the backend as
|
||||
* an opaque `unlockMethod` and an `UnlockOutcome`. A child's profile is simply
|
||||
* one with no PIN — the app models no roles and infers no ages.
|
||||
*
|
||||
* TRACES: UR-082, UR-083 | DR-276
|
||||
*/
|
||||
|
||||
import type { Profile } from "$lib/api/bindings";
|
||||
|
||||
/**
|
||||
* Order tiles as people expect to find them: whoever used the device last is
|
||||
* leftmost, and profiles that have never been used sort after those that have.
|
||||
*
|
||||
* TRACES: UR-082 | DR-276
|
||||
*/
|
||||
export function orderProfiles(profiles: Profile[]): Profile[] {
|
||||
return [...profiles].sort((a, b) => {
|
||||
if (a.lastUsedAt && b.lastUsedAt) return b.lastUsedAt.localeCompare(a.lastUsedAt);
|
||||
if (a.lastUsedAt) return -1;
|
||||
if (b.lastUsedAt) return 1;
|
||||
return a.username.localeCompare(b.username);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Initials for a tile with no avatar. At most two letters, because three fills
|
||||
* a circle badly at tile size.
|
||||
*
|
||||
* TRACES: UR-082 | DR-276
|
||||
*/
|
||||
export function initialsFor(username: string): string {
|
||||
const words = username
|
||||
.trim()
|
||||
.split(/[\s._-]+/)
|
||||
.filter(Boolean);
|
||||
if (words.length === 0) return "?";
|
||||
if (words.length === 1) return words[0].slice(0, 2).toUpperCase();
|
||||
return (words[0][0] + words[words.length - 1][0]).toUpperCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* A stable tile colour per profile, so a family learns to recognise their tile
|
||||
* by colour before they read the name. Derived from the user id rather than the
|
||||
* name, so renaming does not move someone's colour.
|
||||
*
|
||||
* TRACES: UR-082 | DR-276
|
||||
*/
|
||||
const TILE_COLOURS = ["#7b68ee", "#00a4dc", "#e8734a", "#3ba55d", "#d95f8e", "#c9a227"] as const;
|
||||
|
||||
export function tileColour(userId: string): string {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < userId.length; i++) {
|
||||
hash = (hash * 31 + userId.charCodeAt(i)) >>> 0;
|
||||
}
|
||||
return TILE_COLOURS[hash % TILE_COLOURS.length];
|
||||
}
|
||||
|
||||
/**
|
||||
* How long a lockout has left, phrased for a person rather than a log line.
|
||||
*
|
||||
* TRACES: UR-083 | DR-276
|
||||
*/
|
||||
export function lockoutMessage(until: string, now: Date = new Date()): string {
|
||||
const remainingMs = new Date(until).getTime() - now.getTime();
|
||||
if (!Number.isFinite(remainingMs) || remainingMs <= 0) return "Try again now";
|
||||
const minutes = Math.ceil(remainingMs / 60000);
|
||||
if (minutes <= 1) return "Try again in less than a minute";
|
||||
return `Try again in ${minutes} minutes`;
|
||||
}
|
||||
+25
-4
@@ -3,6 +3,7 @@
|
||||
import { goto } from "$app/navigation";
|
||||
import { platform } from "@tauri-apps/plugin-os";
|
||||
import { auth, isAuthenticated } from "$lib/stores/auth";
|
||||
import { profiles } from "$lib/stores/profiles";
|
||||
import { home } from "$lib/stores/home";
|
||||
import { library, libraries } from "$lib/stores/library";
|
||||
import { isServerReachable } from "$lib/stores/connectivity";
|
||||
@@ -29,11 +30,31 @@
|
||||
let previousServerReachable = false;
|
||||
let isAndroid = $state(false);
|
||||
|
||||
// Redirect to login if not authenticated
|
||||
// Where an unauthenticated app goes depends on what this device holds. A
|
||||
// single account with no PIN goes straight to login exactly as before; a
|
||||
// device with several profiles, or one whose last profile is PIN-protected,
|
||||
// goes to the picker instead. The decision is the backend's — the frontend
|
||||
// asks rather than counting profiles itself, because it also turns on a stored
|
||||
// setting and on which profiles have a PIN. (DR-274)
|
||||
let routingAway = false;
|
||||
$effect(() => {
|
||||
if (!$isAuthenticated) {
|
||||
goto("/login");
|
||||
}
|
||||
if ($isAuthenticated || routingAway) return;
|
||||
routingAway = true;
|
||||
void (async () => {
|
||||
try {
|
||||
const target = await profiles.startupTarget();
|
||||
const found = await profiles.refresh();
|
||||
// The picker is only a picker when there is something to pick. With no
|
||||
// profiles stored it would render an empty room, so first run still
|
||||
// goes to login.
|
||||
await goto(target.type === "picker" && found.length > 0 ? "/profiles" : "/login");
|
||||
} catch (error) {
|
||||
log.error("Could not resolve startup target:", error);
|
||||
await goto("/login");
|
||||
} finally {
|
||||
routingAway = false;
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
||||
// Load home sections when authenticated
|
||||
|
||||
@@ -0,0 +1,451 @@
|
||||
<!--
|
||||
"Who's watching" — the profile picker.
|
||||
|
||||
Everything here is presentation. Which profiles exist, whether one is locked,
|
||||
whether a code was right and how many guesses are left all arrive from Rust;
|
||||
this page renders them. In particular there is no notion of an adult or a child
|
||||
account anywhere in this file — a child's profile is simply one whose
|
||||
`unlockMethod` is "none", which is one tap.
|
||||
|
||||
TRACES: UR-082, UR-083, UR-084 | DR-276
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { goto } from "$app/navigation";
|
||||
import { page } from "$app/stores";
|
||||
import { profiles } from "$lib/stores/profiles";
|
||||
import { isAuthenticated } from "$lib/stores/auth";
|
||||
import { navigateBack } from "$lib/utils/navigation";
|
||||
import type { Profile, UnlockOutcome } from "$lib/api/bindings";
|
||||
import PinPad from "$lib/components/PinPad.svelte";
|
||||
import { orderProfiles, initialsFor, tileColour, lockoutMessage } from "$lib/utils/profileTiles";
|
||||
import { createLogger } from "$lib/utils/logger";
|
||||
|
||||
const log = createLogger("ProfilePicker");
|
||||
|
||||
type Mode = "picker" | "pin" | "password" | "add" | "manage";
|
||||
|
||||
let mode = $state<Mode>("picker");
|
||||
let selected = $state<Profile | null>(null);
|
||||
let pin = $state("");
|
||||
let password = $state("");
|
||||
let entryError = $state<string | null>(null);
|
||||
let busy = $state(false);
|
||||
|
||||
// Add-profile form
|
||||
let newUsername = $state("");
|
||||
let newPassword = $state("");
|
||||
let newPin = $state("");
|
||||
let usePinForNew = $state(false);
|
||||
|
||||
const ordered = $derived(orderProfiles($profiles.profiles));
|
||||
|
||||
onMount(() => {
|
||||
// Settings deep-links here for add/remove rather than duplicating the tile
|
||||
// grid: the tiles are the natural place to act on a profile, and two copies
|
||||
// of that list would drift.
|
||||
if ($page.url.searchParams.get("manage") === "1") mode = "manage";
|
||||
void profiles.refresh();
|
||||
});
|
||||
|
||||
function reset() {
|
||||
mode = "picker";
|
||||
selected = null;
|
||||
pin = "";
|
||||
password = "";
|
||||
entryError = null;
|
||||
newUsername = "";
|
||||
newPassword = "";
|
||||
newPin = "";
|
||||
usePinForNew = false;
|
||||
}
|
||||
|
||||
function renderOutcome(outcome: UnlockOutcome): boolean {
|
||||
switch (outcome.type) {
|
||||
case "ok":
|
||||
return true;
|
||||
case "wrongPin":
|
||||
pin = "";
|
||||
entryError =
|
||||
outcome.attemptsRemaining === 1
|
||||
? "Wrong PIN. One more try before this profile locks."
|
||||
: `Wrong PIN. ${outcome.attemptsRemaining} tries left.`;
|
||||
return false;
|
||||
case "lockedOut":
|
||||
pin = "";
|
||||
entryError = `Too many wrong PINs. ${lockoutMessage(outcome.until)}`;
|
||||
return false;
|
||||
case "needsPassword":
|
||||
mode = "password";
|
||||
entryError = "Sign in with your password to continue.";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function choose(profile: Profile) {
|
||||
if (profile.unlockMethod === "pin") {
|
||||
selected = profile;
|
||||
pin = "";
|
||||
entryError = null;
|
||||
mode = "pin";
|
||||
return;
|
||||
}
|
||||
await enter(profile, null);
|
||||
}
|
||||
|
||||
async function enter(profile: Profile, code: string | null) {
|
||||
busy = true;
|
||||
entryError = null;
|
||||
try {
|
||||
const outcome = await profiles.unlock(profile.userId, code);
|
||||
if (renderOutcome(outcome)) {
|
||||
reset();
|
||||
await goto("/");
|
||||
}
|
||||
} catch (error) {
|
||||
log.error("Unlock failed:", error);
|
||||
entryError = error instanceof Error ? error.message : "Could not switch profile";
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function enterWithPassword(event: Event) {
|
||||
event.preventDefault();
|
||||
if (!selected) return;
|
||||
busy = true;
|
||||
entryError = null;
|
||||
try {
|
||||
const outcome = await profiles.unlockWithPassword(selected.userId, password);
|
||||
if (renderOutcome(outcome)) {
|
||||
reset();
|
||||
await goto("/");
|
||||
}
|
||||
} catch (error) {
|
||||
log.error("Password unlock failed:", error);
|
||||
entryError = error instanceof Error ? error.message : "Sign-in failed";
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function addProfile(event: Event) {
|
||||
event.preventDefault();
|
||||
busy = true;
|
||||
entryError = null;
|
||||
try {
|
||||
await profiles.add(newUsername, newPassword, usePinForNew ? newPin : null);
|
||||
reset();
|
||||
} catch (error) {
|
||||
log.error("Add profile failed:", error);
|
||||
entryError = error instanceof Error ? error.message : "Could not add profile";
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function removeProfile(profile: Profile) {
|
||||
busy = true;
|
||||
entryError = null;
|
||||
try {
|
||||
await profiles.remove(profile.userId);
|
||||
} catch (error) {
|
||||
entryError = error instanceof Error ? error.message : "Could not remove profile";
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="min-h-full flex items-center justify-center p-6">
|
||||
<div class="w-full max-w-3xl">
|
||||
<!--
|
||||
Reached from the account menu, the picker must not be a one-way door: a
|
||||
signed-in viewer who opens it and changes their mind (or fails a PIN on
|
||||
someone else's tile) needs a way back to the session they still have. At
|
||||
startup there is no session behind it, so there is nothing to go back to
|
||||
and this stays hidden. (DR-276)
|
||||
-->
|
||||
{#if $isAuthenticated && mode !== "pin" && mode !== "password"}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => navigateBack("/")}
|
||||
class="mb-6 text-sm text-gray-400 hover:text-white flex items-center gap-1"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M15 19l-7-7 7-7"
|
||||
/>
|
||||
</svg>
|
||||
Back
|
||||
</button>
|
||||
{/if}
|
||||
{#if mode === "picker" || mode === "manage"}
|
||||
<h1 class="text-3xl font-semibold text-center mb-2">
|
||||
{mode === "manage" ? "Manage profiles" : "Who's watching?"}
|
||||
</h1>
|
||||
<p class="text-gray-400 text-center mb-10 text-sm">
|
||||
{#if mode === "manage"}
|
||||
Removing a profile only affects this device. It does not sign that person out elsewhere.
|
||||
{:else}
|
||||
Everyone here signs in to the same server.
|
||||
{/if}
|
||||
</p>
|
||||
|
||||
{#if $profiles.error}
|
||||
<div class="mb-6 p-3 bg-red-900/50 border border-red-700 rounded-lg text-red-200 text-sm">
|
||||
{$profiles.error}
|
||||
</div>
|
||||
{/if}
|
||||
{#if entryError}
|
||||
<div class="mb-6 p-3 bg-red-900/50 border border-red-700 rounded-lg text-red-200 text-sm">
|
||||
{entryError}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex flex-wrap justify-center gap-8">
|
||||
{#each ordered as profile (profile.userId)}
|
||||
<div class="flex flex-col items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (mode === "manage" ? undefined : choose(profile))}
|
||||
disabled={busy || mode === "manage"}
|
||||
class="relative w-28 h-28 rounded-2xl flex items-center justify-center text-3xl font-semibold text-white/90 transition-transform hover:scale-105 focus:outline-none focus:ring-2 focus:ring-white disabled:hover:scale-100"
|
||||
style="background-color: {tileColour(profile.userId)}"
|
||||
aria-label="Switch to {profile.username}"
|
||||
>
|
||||
{initialsFor(profile.username)}
|
||||
{#if profile.unlockMethod === "pin"}
|
||||
<span
|
||||
class="absolute bottom-2 right-2 bg-black/50 rounded-full p-1.5"
|
||||
aria-label="PIN required"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
<span class="text-sm text-gray-300">{profile.username}</span>
|
||||
{#if mode === "manage" && !profile.isActive}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => removeProfile(profile)}
|
||||
disabled={busy}
|
||||
class="text-xs text-red-400 hover:text-red-300"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
{:else if mode === "manage"}
|
||||
<span class="text-xs text-gray-500">In use</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
{#if mode === "picker"}
|
||||
<div class="flex flex-col items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => {
|
||||
entryError = null;
|
||||
mode = "add";
|
||||
}}
|
||||
class="w-28 h-28 rounded-2xl border-2 border-dashed border-gray-600 hover:border-gray-400 flex items-center justify-center text-gray-500 hover:text-gray-300 transition-colors focus:outline-none focus:ring-2 focus:ring-white"
|
||||
aria-label="Add a profile"
|
||||
>
|
||||
<svg class="w-10 h-10" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="1.5"
|
||||
d="M12 4v16m8-8H4"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<span class="text-sm text-gray-500">Add profile</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="text-center mt-12">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => {
|
||||
entryError = null;
|
||||
mode = mode === "manage" ? "picker" : "manage";
|
||||
}}
|
||||
class="text-sm text-gray-400 hover:text-white"
|
||||
>
|
||||
{mode === "manage" ? "Done" : "Manage profiles"}
|
||||
</button>
|
||||
</div>
|
||||
{:else if mode === "pin" && selected}
|
||||
<div class="flex flex-col items-center gap-8">
|
||||
<div class="flex flex-col items-center gap-3">
|
||||
<div
|
||||
class="w-20 h-20 rounded-2xl flex items-center justify-center text-2xl font-semibold text-white/90"
|
||||
style="background-color: {tileColour(selected.userId)}"
|
||||
>
|
||||
{initialsFor(selected.username)}
|
||||
</div>
|
||||
<h1 class="text-xl font-medium">{selected.username}</h1>
|
||||
</div>
|
||||
|
||||
<PinPad
|
||||
bind:value={pin}
|
||||
error={entryError}
|
||||
disabled={busy}
|
||||
onsubmit={(code) => selected && enter(selected, code)}
|
||||
oncancel={reset}
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => {
|
||||
entryError = null;
|
||||
password = "";
|
||||
mode = "password";
|
||||
}}
|
||||
class="text-sm text-gray-400 hover:text-white underline"
|
||||
>
|
||||
Forgot your PIN? Use your password
|
||||
</button>
|
||||
</div>
|
||||
{:else if mode === "password" && selected}
|
||||
<form onsubmit={enterWithPassword} class="max-w-sm mx-auto space-y-4">
|
||||
<h1 class="text-2xl font-semibold text-center mb-6">
|
||||
Sign in as {selected.username}
|
||||
</h1>
|
||||
|
||||
<div>
|
||||
<label for="profile-password" class="block text-sm font-medium text-gray-300 mb-2">
|
||||
Password
|
||||
</label>
|
||||
<!-- svelte-ignore a11y_autofocus -->
|
||||
<input
|
||||
id="profile-password"
|
||||
type="password"
|
||||
bind:value={password}
|
||||
autofocus
|
||||
class="w-full px-4 py-3 bg-[var(--color-surface)] border border-gray-700 rounded-lg focus:outline-none focus:border-[var(--color-jellyfin)] text-white"
|
||||
disabled={busy}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if entryError}
|
||||
<div class="p-3 bg-red-900/50 border border-red-700 rounded-lg text-red-200 text-sm">
|
||||
{entryError}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onclick={reset}
|
||||
class="flex-1 py-3 rounded-lg border border-gray-700 hover:bg-[var(--color-surface)]"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy || !password}
|
||||
class="flex-1 py-3 rounded-lg bg-[var(--color-jellyfin)] hover:bg-[var(--color-jellyfin-dark)] disabled:opacity-50 font-medium"
|
||||
>
|
||||
Sign in
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{:else if mode === "add"}
|
||||
<form onsubmit={addProfile} class="max-w-sm mx-auto space-y-4">
|
||||
<h1 class="text-2xl font-semibold text-center mb-2">Add a profile</h1>
|
||||
<p class="text-gray-400 text-sm text-center mb-6">
|
||||
Another account on the server you are already connected to.
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<label for="new-username" class="block text-sm font-medium text-gray-300 mb-2">
|
||||
Username
|
||||
</label>
|
||||
<input
|
||||
id="new-username"
|
||||
type="text"
|
||||
bind:value={newUsername}
|
||||
class="w-full px-4 py-3 bg-[var(--color-surface)] border border-gray-700 rounded-lg focus:outline-none focus:border-[var(--color-jellyfin)] text-white"
|
||||
disabled={busy}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="new-password" class="block text-sm font-medium text-gray-300 mb-2">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="new-password"
|
||||
type="password"
|
||||
bind:value={newPassword}
|
||||
class="w-full px-4 py-3 bg-[var(--color-surface)] border border-gray-700 rounded-lg focus:outline-none focus:border-[var(--color-jellyfin)] text-white"
|
||||
disabled={busy}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label class="flex items-start gap-3 text-sm text-gray-300">
|
||||
<input type="checkbox" bind:checked={usePinForNew} class="mt-1" disabled={busy} />
|
||||
<span>
|
||||
Protect this profile with a PIN
|
||||
<span class="block text-gray-500 text-xs mt-1">
|
||||
Leave this off for a child's profile so it opens with one tap. A PIN controls who can
|
||||
switch to an account — what each account may watch is set on the Jellyfin server.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{#if usePinForNew}
|
||||
<div>
|
||||
<label for="new-pin" class="block text-sm font-medium text-gray-300 mb-2">
|
||||
PIN (4–8 digits)
|
||||
</label>
|
||||
<input
|
||||
id="new-pin"
|
||||
type="password"
|
||||
inputmode="numeric"
|
||||
bind:value={newPin}
|
||||
class="w-full px-4 py-3 bg-[var(--color-surface)] border border-gray-700 rounded-lg focus:outline-none focus:border-[var(--color-jellyfin)] text-white tracking-widest"
|
||||
disabled={busy}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if entryError}
|
||||
<div class="p-3 bg-red-900/50 border border-red-700 rounded-lg text-red-200 text-sm">
|
||||
{entryError}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onclick={reset}
|
||||
class="flex-1 py-3 rounded-lg border border-gray-700 hover:bg-[var(--color-surface)]"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy || !newUsername || (usePinForNew && newPin.length < 4)}
|
||||
class="flex-1 py-3 rounded-lg bg-[var(--color-jellyfin)] hover:bg-[var(--color-jellyfin-dark)] disabled:opacity-50 font-medium"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -2,6 +2,8 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy, onMount } from "svelte";
|
||||
import { commands } from "$lib/api/bindings";
|
||||
import { profiles } from "$lib/stores/profiles";
|
||||
import ProfileSecuritySettings from "$lib/components/settings/ProfileSecuritySettings.svelte";
|
||||
import type {
|
||||
AudioSettings,
|
||||
CacheConfig,
|
||||
@@ -147,8 +149,13 @@
|
||||
// Promise and Svelte would never invoke it as a teardown.
|
||||
onDestroy(unsubscribeNativeVideo);
|
||||
|
||||
// Mirrors the stored setting; the picker itself always appears for a
|
||||
// PIN-protected profile regardless of this. (DR-274)
|
||||
let askOnStart = $state(false);
|
||||
|
||||
onMount(async () => {
|
||||
await loadSettings();
|
||||
askOnStart = await commands.profilesGetAskOnStart();
|
||||
supportsNativeVideo = (await getPlaybackCapabilities()).supportsNativeVideo;
|
||||
|
||||
// Which update story this platform gets. Android cannot install its own
|
||||
@@ -985,6 +992,46 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Profiles. Deliberately minimal here: adding, removing and PIN changes
|
||||
all live on the picker itself, where the tiles are. What belongs in
|
||||
settings is the one device-wide preference and a way in.
|
||||
TRACES: UR-082, UR-083 | DR-274, DR-276 -->
|
||||
<div class="border-t border-gray-700 pt-6">
|
||||
<h2 class="text-2xl font-bold text-white mb-4">Profiles</h2>
|
||||
|
||||
<div class="bg-[var(--color-surface)] rounded-lg p-6 space-y-5">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-white mb-1">Ask who's watching</h3>
|
||||
<p class="text-sm text-gray-400">
|
||||
Show the profile picker when the app starts. A profile with a PIN always asks,
|
||||
whatever this is set to.
|
||||
</p>
|
||||
</div>
|
||||
<label class="relative inline-flex items-center cursor-pointer shrink-0">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={askOnStart}
|
||||
onchange={() => profiles.setAskOnStart(askOnStart)}
|
||||
class="sr-only peer"
|
||||
/>
|
||||
<div
|
||||
class="w-11 h-6 bg-gray-600 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-0.5 after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-[var(--color-jellyfin)]"
|
||||
></div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-gray-700 pt-5">
|
||||
<ProfileSecuritySettings />
|
||||
<p class="text-xs text-gray-500 mt-4">
|
||||
A PIN controls who can switch to an account on this device. What each account is
|
||||
allowed to watch is set on the Jellyfin server, not here. Switching profile lives in
|
||||
the account menu.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search Settings -->
|
||||
<div class="border-t border-gray-700 pt-6">
|
||||
<h2 class="text-2xl font-bold text-white mb-4">Search</h2>
|
||||
|
||||
Reference in New Issue
Block a user