feat(login): show the backend's server-version verdict, and drop a dead route builder
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 29m23s
🏗️ Build and Test JellyTau / Supply Chain (push) Successful in 44s
📱 Test APK / Build test APK (push) Successful in 43m47s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 6m33s
Traceability Validation / Check Requirement Traces (push) Successful in 14s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 5m21s
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 29m23s
🏗️ Build and Test JellyTau / Supply Chain (push) Successful in 44s
📱 Test APK / Build test APK (push) Successful in 43m47s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 6m33s
Traceability Validation / Check Requirement Traces (push) Successful in 14s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 5m21s
Two frontend halves of the version-compatibility work.
The login flow now renders ServerCompatibility. A server below the floor blocks
with a message naming the minimum; a server newer than this build gets a
non-blocking note and proceeds; an unreadable version says nothing at all,
because refusing — or even warning — on a version string we could not parse would
punish the user for a limitation of ours.
The frontend never receives a version number to reason about, only the opaque
verdict, for the same reason it never receives an item-type list. Rust decides
whether the server is usable; the frontend decides only how that reads.
Separately, imageCache.getCachedImageUrl is deleted. It built
${serverUrl}/Items/${itemId}/Images/${imageType} in Svelte — a Jellyfin route in
the presentation layer, which is domain logic by this project's own litmus test
(would it change if Jellyfin changed its API?). check:boundary does not catch it:
the tripwire flags item-type array literals, not route strings.
It was also entirely unused. Nothing outside its own file and test ever called
it; the live path is CachedImage.svelte -> commands.imageGetUrl -> Rust, which
was already correct. So the leak was in dead code and the fix is a deletion
rather than a migration.
One consequence left deliberately unacted: that function was the last
convertFileSrc caller, so the asset-protocol grant narrowed to
$APPDATA/thumbnails/** under DR-198 now has no caller at all. Dropping a
capability grant is a security change that deserves its own commit and its own
testing on Android, not a side effect of deleting dead code. Noted in the file.
TRACES: UR-012, UR-085 | DR-285, DR-286
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+43
-1
@@ -2135,7 +2135,18 @@ export type AuthServerInfo = { name: string; version: string; id: string;
|
||||
/**
|
||||
* Normalized server URL with protocol and no trailing slash
|
||||
*/
|
||||
normalizedUrl: string }
|
||||
normalizedUrl: string;
|
||||
/**
|
||||
* Whether this build can talk to this server, as an **opaque state**.
|
||||
*
|
||||
* The version string above is informational — for display and for the log.
|
||||
* This is the judgement, made in Rust, because deciding whether an API
|
||||
* version is usable is domain reasoning: the frontend must never compare a
|
||||
* version number, for the same reason it never receives an item-type list.
|
||||
*
|
||||
* TRACES: UR-085 | DR-286
|
||||
*/
|
||||
compatibility: ServerCompatibility }
|
||||
/**
|
||||
* Autoplay settings (controls next episode behavior)
|
||||
*/
|
||||
@@ -3428,6 +3439,37 @@ export type SecurityStatus = { usingKeyring: boolean; storageType: string }
|
||||
* Audio track preference for a series
|
||||
*/
|
||||
export type SeriesAudioPreference = { seriesId: string; audioTrackDisplayTitle: string | null; audioTrackLanguage: string | null; audioTrackIndex: number | null }
|
||||
/**
|
||||
* The verdict on a server's version.
|
||||
*
|
||||
* Deliberately three states rather than a boolean. "Unrecognised" is not a
|
||||
* failure: a server newer than this build resolves forward and works, and
|
||||
* refusing it would make every JellyTau release expire the moment the server
|
||||
* upgrades. Only a server below the supported floor is refused, where failure
|
||||
* is certain rather than merely likely.
|
||||
*
|
||||
* TRACES: UR-085 | DR-286
|
||||
*/
|
||||
export type ServerCompatibility =
|
||||
/**
|
||||
* A generation this build knows and was tested against.
|
||||
*/
|
||||
{ type: "supported" } |
|
||||
/**
|
||||
* Parsed, but newer than anything this build knows. Treated as the newest
|
||||
* known generation; everything works, and this exists so the UI *may*
|
||||
* mention it rather than so it must.
|
||||
*/
|
||||
{ type: "newerThanKnown" } |
|
||||
/**
|
||||
* The version string could not be parsed. Treated as supported — we do not
|
||||
* refuse a server on the strength of not understanding its version string.
|
||||
*/
|
||||
{ type: "unknownVersion" } |
|
||||
/**
|
||||
* Below the supported floor. This one is a refusal.
|
||||
*/
|
||||
{ type: "tooOld"; minimum: string }
|
||||
/**
|
||||
* Server info returned to frontend
|
||||
*/
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import {
|
||||
getCachedImageUrl,
|
||||
getCacheStats,
|
||||
setCacheLimit,
|
||||
clearCache,
|
||||
@@ -50,43 +49,6 @@ describe("image cache service", () => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("getCachedImageUrl", () => {
|
||||
it("should build server URL with default image type", async () => {
|
||||
const url = await getCachedImageUrl("http://server.local:8096", "item-123");
|
||||
expect(url).toContain("http://server.local:8096/Items/item-123/Images/Primary");
|
||||
});
|
||||
|
||||
it("should build server URL with custom image type", async () => {
|
||||
const url = await getCachedImageUrl("http://server.local:8096", "item-123", "Backdrop");
|
||||
expect(url).toContain("Backdrop");
|
||||
});
|
||||
|
||||
it("should include image options in URL", async () => {
|
||||
const url = await getCachedImageUrl("http://server.local:8096", "item-123", "Primary", {
|
||||
maxWidth: 300,
|
||||
maxHeight: 400,
|
||||
quality: 90,
|
||||
tag: "abc123",
|
||||
});
|
||||
expect(url).toContain("maxWidth=300");
|
||||
expect(url).toContain("maxHeight=400");
|
||||
expect(url).toContain("quality=90");
|
||||
expect(url).toContain("tag=abc123");
|
||||
});
|
||||
|
||||
it("should trigger background caching", async () => {
|
||||
const { invoke } = await import("@tauri-apps/api/core");
|
||||
const invokeSpy = vi.mocked(invoke);
|
||||
|
||||
await getCachedImageUrl("http://server.local:8096", "item-123");
|
||||
|
||||
const saveCall = invokeSpy.mock.calls.find((call) => call[0] === "thumbnail_save");
|
||||
expect(saveCall).toBeDefined();
|
||||
expect(saveCall![1]).toHaveProperty("itemId", "item-123");
|
||||
expect(saveCall![1]).toHaveProperty("imageType", "Primary");
|
||||
});
|
||||
});
|
||||
|
||||
describe("cache statistics", () => {
|
||||
it("should get cache statistics", async () => {
|
||||
const stats = await getCacheStats();
|
||||
|
||||
@@ -1,11 +1,23 @@
|
||||
// Image cache service - Handles lazy caching of thumbnails with LRU eviction
|
||||
// TRACES: UR-007 | DR-016
|
||||
// Image cache service — cache statistics, limits and eviction.
|
||||
//
|
||||
// This module used to also export `getCachedImageUrl`, which built
|
||||
// `${serverUrl}/Items/${itemId}/Images/${imageType}` in the frontend. That was a
|
||||
// Jellyfin route in the presentation layer — domain logic by this project's own
|
||||
// litmus test (would it change if Jellyfin changed its API?) — and it was
|
||||
// **dead**: nothing outside this file and its test ever called it. The live path
|
||||
// is CachedImage.svelte -> commands.imageGetUrl -> Rust, which was already
|
||||
// correct. It was deleted rather than migrated (DR-285).
|
||||
//
|
||||
// Note for whoever touches the CSP next: that function was the last
|
||||
// `convertFileSrc` caller, so the asset-protocol grant narrowed to
|
||||
// `$APPDATA/thumbnails/**` under DR-198 now has no caller at all and is a
|
||||
// candidate for removal. Left in place here deliberately — dropping a capability
|
||||
// grant is a security change that deserves its own commit and its own testing on
|
||||
// Android, not a side effect of deleting dead code.
|
||||
//
|
||||
// TRACES: UR-007, UR-085 | DR-016, DR-285
|
||||
|
||||
import { convertFileSrc } from "@tauri-apps/api/core";
|
||||
import { commands } from "$lib/api/bindings";
|
||||
import { createLogger } from "$lib/utils/logger";
|
||||
|
||||
const log = createLogger("ImageCache");
|
||||
|
||||
/**
|
||||
* Statistics about the thumbnail cache
|
||||
@@ -16,63 +28,6 @@ export interface ImageCacheStats {
|
||||
limitBytes: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an image URL, checking cache first then falling back to server.
|
||||
* Triggers background caching if not cached.
|
||||
*
|
||||
* @param serverUrl - The Jellyfin server base URL
|
||||
* @param itemId - The Jellyfin item ID
|
||||
* @param imageType - The image type (Primary, Backdrop, etc.)
|
||||
* @param options - Image options (maxWidth, maxHeight, quality, tag)
|
||||
* @returns The image URL (local asset URL if cached, server URL otherwise)
|
||||
*/
|
||||
export async function getCachedImageUrl(
|
||||
serverUrl: string,
|
||||
itemId: string,
|
||||
imageType: string = "Primary",
|
||||
options: {
|
||||
maxWidth?: number;
|
||||
maxHeight?: number;
|
||||
quality?: number;
|
||||
tag?: string;
|
||||
} = {},
|
||||
): Promise<string> {
|
||||
const tag = options.tag || "default";
|
||||
|
||||
// Try to get cached version
|
||||
try {
|
||||
const cachedPath = await commands.thumbnailGetCached(itemId, imageType, tag);
|
||||
|
||||
if (cachedPath) {
|
||||
// Convert file path to asset URL for Tauri. This is the only remaining
|
||||
// convertFileSrc caller, which is why the asset-protocol scope is narrowed
|
||||
// to $APPDATA/thumbnails/** — a path outside it resolves to nothing.
|
||||
// TRACES: UR-012 | DR-134, DR-198
|
||||
return convertFileSrc(cachedPath);
|
||||
}
|
||||
} catch (e) {
|
||||
log.debug("Failed to check thumbnail cache:", e);
|
||||
}
|
||||
|
||||
// Build server URL
|
||||
const params = new URLSearchParams();
|
||||
if (options.maxWidth) params.set("maxWidth", options.maxWidth.toString());
|
||||
if (options.maxHeight) params.set("maxHeight", options.maxHeight.toString());
|
||||
if (options.quality) params.set("quality", options.quality.toString());
|
||||
if (options.tag) params.set("tag", options.tag);
|
||||
|
||||
const serverImageUrl = `${serverUrl}/Items/${itemId}/Images/${imageType}?${params.toString()}`;
|
||||
|
||||
// Trigger background caching (fire and forget)
|
||||
commands.thumbnailSave(itemId, imageType, tag, serverImageUrl).catch((e) => {
|
||||
// Silently fail - caching is best-effort
|
||||
log.debug("Background thumbnail cache failed:", e);
|
||||
});
|
||||
|
||||
// Return server URL for immediate display
|
||||
return serverImageUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get thumbnail cache statistics
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* TRACES: UR-085 | DR-286
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { compatibilityNotice } from "./serverCompatibility";
|
||||
|
||||
describe("server compatibility notice", () => {
|
||||
it("says nothing about a supported server", () => {
|
||||
expect(compatibilityNotice({ type: "supported" }, "12.0.0")).toBeNull();
|
||||
});
|
||||
|
||||
it("does not interrupt anyone over a version it could not parse", () => {
|
||||
// Refusing, or even warning, on an unreadable version string would punish
|
||||
// the user for a parsing limitation of ours.
|
||||
expect(compatibilityNotice({ type: "unknownVersion" }, "weird-build")).toBeNull();
|
||||
});
|
||||
|
||||
it("mentions a newer-than-known server without blocking it", () => {
|
||||
const notice = compatibilityNotice({ type: "newerThanKnown" }, "13.0.0");
|
||||
expect(notice).not.toBeNull();
|
||||
expect(notice!.blocking).toBe(false);
|
||||
expect(notice!.tone).toBe("warning");
|
||||
expect(notice!.message).toContain("13.0.0");
|
||||
});
|
||||
|
||||
it("blocks a server below the floor and names the floor", () => {
|
||||
const notice = compatibilityNotice({ type: "tooOld", minimum: "10.10" }, "10.9.11");
|
||||
expect(notice).not.toBeNull();
|
||||
expect(notice!.blocking).toBe(true);
|
||||
expect(notice!.tone).toBe("error");
|
||||
expect(notice!.message).toContain("10.9.11");
|
||||
expect(notice!.message).toContain("10.10");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
// Presentation of the backend's server-compatibility verdict.
|
||||
//
|
||||
// The decision is Rust's — see `ServerCompatibility` in `auth/mod.rs`. This file
|
||||
// decides only how it *reads*, which is presentation and changes only if the UI
|
||||
// is redesigned. Nothing here compares a version number, and nothing here may
|
||||
// start to: the backend sends an opaque state precisely so the frontend cannot.
|
||||
//
|
||||
// TRACES: UR-085 | DR-286
|
||||
|
||||
import type { ServerCompatibility } from "$lib/api/bindings";
|
||||
|
||||
export interface CompatibilityNotice {
|
||||
/** Blocks going on to the login step. Only a server below the floor does. */
|
||||
blocking: boolean;
|
||||
tone: "error" | "warning";
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* What to show the user about a server's version, or `null` when there is
|
||||
* nothing worth saying — which is the common case.
|
||||
*/
|
||||
export function compatibilityNotice(
|
||||
compatibility: ServerCompatibility,
|
||||
serverVersion: string,
|
||||
): CompatibilityNotice | null {
|
||||
switch (compatibility.type) {
|
||||
case "supported":
|
||||
return null;
|
||||
|
||||
case "unknownVersion":
|
||||
// Not worth interrupting anyone over: the server almost certainly works,
|
||||
// and we simply could not read what it called itself.
|
||||
return null;
|
||||
|
||||
case "newerThanKnown":
|
||||
return {
|
||||
blocking: false,
|
||||
tone: "warning",
|
||||
message:
|
||||
`This server (${serverVersion}) is newer than this version of JellyTau. ` +
|
||||
`It should work normally — update the app if anything looks wrong.`,
|
||||
};
|
||||
|
||||
case "tooOld":
|
||||
return {
|
||||
blocking: true,
|
||||
tone: "error",
|
||||
message:
|
||||
`This server runs Jellyfin ${serverVersion}. JellyTau needs ` +
|
||||
`${compatibility.minimum} or newer.`,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { goto } from "$app/navigation";
|
||||
import { auth, isAuthenticated, isLoading, authError } from "$lib/stores/auth";
|
||||
import { compatibilityNotice } from "$lib/utils/serverCompatibility";
|
||||
|
||||
let step = $state<"server" | "login">("server");
|
||||
let serverUrl = $state("");
|
||||
@@ -11,6 +12,8 @@
|
||||
let connecting = $state(false);
|
||||
let loggingIn = $state(false);
|
||||
let localError = $state<string | null>(null);
|
||||
/// Non-blocking note about the server version (e.g. newer than this build).
|
||||
let serverNotice = $state<string | null>(null);
|
||||
|
||||
// Redirect to library if already authenticated
|
||||
$effect(() => {
|
||||
@@ -36,6 +39,16 @@
|
||||
|
||||
try {
|
||||
const info = await auth.connectToServer(serverUrl);
|
||||
|
||||
// The backend decided whether this server's version is usable; we only
|
||||
// render its verdict. TRACES: UR-085 | DR-286
|
||||
const notice = compatibilityNotice(info.compatibility, info.version);
|
||||
if (notice?.blocking) {
|
||||
localError = notice.message;
|
||||
return;
|
||||
}
|
||||
serverNotice = notice?.message ?? null;
|
||||
|
||||
serverName = info.name;
|
||||
serverUrl = info.normalizedUrl; // Use normalized URL with https://
|
||||
step = "login";
|
||||
@@ -224,6 +237,14 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if serverNotice}
|
||||
<div
|
||||
class="p-3 bg-amber-900/40 border border-amber-700 rounded-lg text-amber-200 text-sm"
|
||||
>
|
||||
{serverNotice}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if localError || $authError}
|
||||
<div class="p-3 bg-red-900/50 border border-red-700 rounded-lg text-red-200 text-sm">
|
||||
{localError || $authError}
|
||||
|
||||
Reference in New Issue
Block a user