mpv now decodes video on Linux, drawn into a framebuffer we own and blitted
into the default vbox's draw handler. Tauri's widget tree is untouched, so an
upgrade that assumes its own layout cannot invalidate this. Direct play means
the original file, hardware decoding, and no server transcode at all — where
previously every desktop video was re-encoded to h264 for the browser engine,
whatever the file actually was. Off by default: JELLYTAU_NATIVE_VIDEO=1.
That settles finding 2 of playback-backend-unification.md — "native video
cannot be composited with a Tauri webview" — by demonstration rather than
argument, on X11 and Wayland both.
Turning it on exposed nine defects, none of them mpv's. Each was the same
mistake in a different place: a capability written down as a compile-time fact
about the platform, or a state asserted instead of confirmed.
DR-238/246 a seek routed by the stream's container rather than by what the
engine could do with it - correct only while one player handled
those streams, silent the moment another did
DR-239 a property handled but never observed, so the play/pause button
waited for an event that could not arrive
DR-240 fullscreen expanding the document while the window stayed put
DR-241 a seek issued before the engine had a file, failed, and discarded
- which is why resume began at zero
DR-247 a Linux-only gate outliving the caller that made it Linux-only,
breaking the Android build outright
DR-250 a stop aimed at whichever renderer bookkeeping believed was in
charge, missing the one actually making sound
DR-251 a duration of zero believed, leaving the seek bar no scale
DR-252 a junk float converted to a Duration, panicking the backend the
instant a length-less stream appeared
So the MediaPlayer contract (DR-242 … DR-247): `open` carries a start position,
so no caller sequences load-then-seek and none can race an engine's load;
`seek` states a destination and leaves in-place-versus-re-open to the engine;
`snapshot` is one coherent read; and `Phase::Opening` names the window where
intent used to be lost. One conformance suite runs against every engine —
FakePlayer and mpv under cargo test, ExoPlayer instrumented on a device — so an
engine is either correct or visibly failing.
Two of the nine were introduced during this work and caught on hardware, not by
any suite: an over-broad capability that grouped ExoPlayer with mpv, and the
Duration panic. The suites test engines that behave. That is recorded in
docs/native-player-verification.md, which asks for the exact action sequences
that found them.
Verified: all automated gates, conformance (mpv 9/9, legacy 8/9 by design,
ExoPlayer 7/7 on device), and manual desktop and Android passes on real
hardware.
Known open and deliberately shipped: resume reads local progress and never the
server's; the background-audio handoff still declares a state swap it does not
confirm (the symptom is now impossible, the race is not); and `bun run
android:dev` builds an APK carrying the release application id, whose failure
message advises an uninstall that would destroy app data. Fix that last one
before anyone else builds for Android.
Squashed from worktree-linux-native-video, which keeps the per-defect history.
638 lines
20 KiB
TypeScript
638 lines
20 KiB
TypeScript
// Authentication state store with Rust backend
|
|
//
|
|
// All business logic (session management, verification, credential storage) is handled by Rust.
|
|
// This file is a thin Svelte store wrapper that calls Rust commands and listens to events.
|
|
//
|
|
// TRACES: UR-009, UR-012 | IR-009, IR-014
|
|
|
|
import { writable, derived, get } from "svelte/store";
|
|
import { listen } from "@tauri-apps/api/event";
|
|
import { commands } from "$lib/api/bindings";
|
|
import { RepositoryClient } from "$lib/api/repository-client";
|
|
import type { User, AuthResult } from "$lib/api/types";
|
|
import type { Session, AuthServerInfo as ServerInfo } from "$lib/api/bindings";
|
|
import { connectivity } from "./connectivity";
|
|
import { getDeviceId, clearCache as clearDeviceIdCache } from "$lib/services/deviceId";
|
|
import { createLogger } from "$lib/utils/logger";
|
|
|
|
const log = createLogger("Auth");
|
|
|
|
interface AuthState {
|
|
isAuthenticated: boolean;
|
|
isLoading: boolean;
|
|
user: User | null;
|
|
serverUrl: string | null;
|
|
serverName: string | null;
|
|
error: string | null;
|
|
securityWarning: string | null;
|
|
/** Whether session needs re-authentication (e.g., token expired) */
|
|
needsReauth: boolean;
|
|
/** Whether session verification is in progress */
|
|
isVerifying: boolean;
|
|
/** Whether the session is known to be valid (verified with server) */
|
|
sessionVerified: boolean;
|
|
}
|
|
|
|
function createAuthStore() {
|
|
const initialState: AuthState = {
|
|
isAuthenticated: false,
|
|
isLoading: true,
|
|
user: null,
|
|
serverUrl: null,
|
|
serverName: null,
|
|
error: null,
|
|
securityWarning: null,
|
|
needsReauth: false,
|
|
isVerifying: false,
|
|
sessionVerified: false,
|
|
};
|
|
|
|
const { subscribe, set, update } = writable<AuthState>(initialState);
|
|
|
|
// RepositoryClient provides cache-first access with automatic background refresh via Rust
|
|
let repository: RepositoryClient | null = null;
|
|
|
|
// Store unlisten functions for cleanup
|
|
let unlistenSessionVerified: (() => void) | null = null;
|
|
let unlistenNeedsReauth: (() => void) | null = null;
|
|
let unlistenNetworkError: (() => void) | null = null;
|
|
|
|
function getRepository(): RepositoryClient {
|
|
if (!repository) {
|
|
throw new Error("Not connected to a server");
|
|
}
|
|
return repository;
|
|
}
|
|
|
|
/**
|
|
* The repository, waiting for session restore rather than failing the instant
|
|
* it is asked.
|
|
*
|
|
* `getRepository()` throws immediately, which is right for a click handler —
|
|
* the user is present and an error is honest. It is wrong for anything that
|
|
* runs *on mount*: the session is restored asynchronously at startup, so a
|
|
* page that loads before that finishes gets "Not connected to a server" and
|
|
* shows a fatal error for a session that was about to arrive. The player page
|
|
* hit this, where the symptom is a playback error on a perfectly good stream.
|
|
*
|
|
* Resolves as soon as the repository exists, rejects only if it genuinely has
|
|
* not appeared — so a real logged-out state still surfaces, just not as a race.
|
|
*
|
|
* TRACES: UR-002 | DR-013
|
|
*/
|
|
async function waitForRepository(timeoutMs = 5000): Promise<RepositoryClient> {
|
|
if (repository) return repository;
|
|
|
|
return new Promise<RepositoryClient>((resolve, reject) => {
|
|
let settled = false;
|
|
const finish = (fn: () => void) => {
|
|
if (settled) return;
|
|
settled = true;
|
|
clearTimeout(timer);
|
|
unsubscribe();
|
|
fn();
|
|
};
|
|
|
|
// Every store change is a chance the session landed. `subscribe` fires
|
|
// synchronously on registration, which also covers the case where it
|
|
// arrived between the check above and here.
|
|
const unsubscribe = subscribe(() => {
|
|
if (repository) finish(() => resolve(repository as RepositoryClient));
|
|
});
|
|
|
|
const timer = setTimeout(
|
|
() => finish(() => reject(new Error("Not connected to a server"))),
|
|
timeoutMs,
|
|
);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Initialize event listeners from Rust backend.
|
|
* These should be called once during app initialization.
|
|
*/
|
|
async function initializeEventListeners(): Promise<void> {
|
|
if (typeof window === "undefined") return;
|
|
|
|
try {
|
|
unlistenSessionVerified = await listen<{ user: User }>("auth:session-verified", (event) => {
|
|
log.debug("Session verified:", event.payload.user.name);
|
|
update((s) => ({
|
|
...s,
|
|
sessionVerified: true,
|
|
needsReauth: false,
|
|
isVerifying: false,
|
|
user: event.payload.user,
|
|
}));
|
|
});
|
|
} catch (e) {
|
|
log.error("Failed to listen to session-verified event:", e);
|
|
}
|
|
|
|
try {
|
|
unlistenNeedsReauth = await listen<{ reason: string }>("auth:needs-reauth", (event) => {
|
|
log.debug("Session needs re-authentication:", event.payload.reason);
|
|
update((s) => ({
|
|
...s,
|
|
sessionVerified: false,
|
|
needsReauth: true,
|
|
isVerifying: false,
|
|
error: event.payload.reason,
|
|
}));
|
|
});
|
|
} catch (e) {
|
|
log.error("Failed to listen to needs-reauth event:", e);
|
|
}
|
|
|
|
try {
|
|
unlistenNetworkError = await listen<{ message: string }>("auth:network-error", (event) => {
|
|
log.debug("Network error during verification:", event.payload.message);
|
|
// Network errors don't trigger re-auth - just log them
|
|
update((s) => ({ ...s, isVerifying: false }));
|
|
});
|
|
} catch (e) {
|
|
log.error("Failed to listen to network-error event:", e);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Cleanup event listeners.
|
|
* Should be called when the app is destroyed.
|
|
*/
|
|
function cleanupEventListeners(): void {
|
|
if (unlistenSessionVerified) {
|
|
unlistenSessionVerified();
|
|
unlistenSessionVerified = null;
|
|
}
|
|
if (unlistenNeedsReauth) {
|
|
unlistenNeedsReauth();
|
|
unlistenNeedsReauth = null;
|
|
}
|
|
if (unlistenNetworkError) {
|
|
unlistenNetworkError();
|
|
unlistenNetworkError = null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Initialize auth state from Rust backend.
|
|
* This function does NOT require network access - session is restored immediately.
|
|
*/
|
|
async function initialize() {
|
|
// Initialize event listeners first
|
|
await initializeEventListeners();
|
|
|
|
update((s) => ({ ...s, isLoading: true, error: null }));
|
|
|
|
try {
|
|
// Check security status — fire-and-forget. It only sets a warning banner,
|
|
// so it must not sit in front of session restore (and thus first paint).
|
|
void (async () => {
|
|
try {
|
|
const securityStatus = await commands.storageGetSecurityStatus();
|
|
log.debug("Security status:", securityStatus);
|
|
if (!securityStatus.usingKeyring) {
|
|
update((s) => ({
|
|
...s,
|
|
securityWarning:
|
|
"Credentials are stored with reduced security (encrypted file instead of system keyring).",
|
|
}));
|
|
}
|
|
} catch (error) {
|
|
log.warn("Failed to get security status:", error);
|
|
}
|
|
})();
|
|
|
|
// Initialize auth manager and get session
|
|
log.debug("Initializing auth manager...");
|
|
const session = await commands.authInitialize();
|
|
log.debug("Session retrieval result:", session ? "Session found" : "No session found");
|
|
|
|
if (session) {
|
|
log.debug("Restoring session for user:", session.username, "on server:", session.serverUrl);
|
|
|
|
// Create RepositoryClient for cache-first access. This IS required before
|
|
// we mark authenticated — the first screen (library overview) reads
|
|
// through it — so keep it awaited.
|
|
repository = new RepositoryClient();
|
|
await repository.create(
|
|
session.serverUrl,
|
|
session.userId,
|
|
session.accessToken,
|
|
session.serverId,
|
|
);
|
|
|
|
// Configure the Rust player for playback reporting. This is NOT needed to
|
|
// render the first screen (it only matters once playback starts), so run
|
|
// it fire-and-forget instead of blocking first paint on two more IPC
|
|
// round-trips (getDeviceId + playerConfigureJellyfin).
|
|
void (async () => {
|
|
try {
|
|
const deviceId = await getDeviceId();
|
|
await commands.playerConfigureJellyfin(
|
|
session.serverUrl,
|
|
session.accessToken,
|
|
session.userId,
|
|
deviceId,
|
|
);
|
|
log.debug("Rust player configured for automatic playback reporting");
|
|
} catch (error) {
|
|
log.error("Failed to configure Rust player:", error);
|
|
}
|
|
})();
|
|
|
|
// Set authenticated immediately (offline-first)
|
|
set({
|
|
isAuthenticated: true,
|
|
isLoading: false,
|
|
user: { id: session.userId, name: session.username, serverId: session.serverId } as User,
|
|
serverUrl: session.serverUrl,
|
|
serverName: session.serverName,
|
|
error: null,
|
|
securityWarning: initialState.securityWarning,
|
|
needsReauth: session.needsReauth,
|
|
isVerifying: false,
|
|
sessionVerified: session.verified,
|
|
});
|
|
|
|
// Start connectivity monitoring early to avoid appearing offline on startup
|
|
log.debug("Starting early connectivity monitoring...");
|
|
connectivity
|
|
.startMonitoring(session.serverUrl, {
|
|
onServerReconnected: () => {
|
|
// Retry session verification when server becomes reachable
|
|
retryVerification();
|
|
// Resume downloads queued while offline, then refresh the catalog.
|
|
// Lazy import to avoid an auth <-> offlineCatalog import cycle.
|
|
import("$lib/services/offlineCatalog")
|
|
.then((m) => m.onReconnected())
|
|
.catch((err) => log.warn("Catalog reconnect failed:", err));
|
|
},
|
|
})
|
|
.catch((error) => {
|
|
log.error("Failed to start connectivity monitoring:", error);
|
|
});
|
|
|
|
// Start background session verification — fire-and-forget. This is
|
|
// already asynchronous work (results arrive via the auth:* events wired
|
|
// above), so awaiting getDeviceId + authStartVerification here only
|
|
// delayed first paint by two IPC round-trips for no UI benefit.
|
|
void (async () => {
|
|
try {
|
|
const verifyDeviceId = await getDeviceId();
|
|
await commands.authStartVerification(verifyDeviceId);
|
|
log.debug("Background verification started");
|
|
} catch (error) {
|
|
log.error("Failed to start verification:", error);
|
|
}
|
|
})();
|
|
} else {
|
|
// No stored session
|
|
log.debug("No active session found");
|
|
set({
|
|
isAuthenticated: false,
|
|
isLoading: false,
|
|
user: null,
|
|
serverUrl: null,
|
|
serverName: null,
|
|
error: null,
|
|
securityWarning: initialState.securityWarning,
|
|
needsReauth: false,
|
|
isVerifying: false,
|
|
sessionVerified: false,
|
|
});
|
|
}
|
|
} catch (error) {
|
|
log.error("Failed to initialize:", error);
|
|
update((s) => ({
|
|
...s,
|
|
isLoading: false,
|
|
error: error instanceof Error ? error.message : String(error),
|
|
}));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Connect to a Jellyfin server and retrieve server info.
|
|
* Rust will normalize the URL (add https:// if missing, remove trailing slash).
|
|
*
|
|
* TRACES: UR-009 | IR-009
|
|
*/
|
|
async function connectToServer(serverUrl: string): Promise<ServerInfo> {
|
|
update((s) => ({ ...s, isLoading: true, error: null }));
|
|
|
|
try {
|
|
log.debug("Connecting to server:", serverUrl);
|
|
const serverInfo = await commands.authConnectToServer(serverUrl);
|
|
log.debug("Connected to server:", serverInfo.name, serverInfo.version);
|
|
log.debug("Normalized URL:", serverInfo.normalizedUrl);
|
|
|
|
update((s) => ({ ...s, isLoading: false }));
|
|
return serverInfo;
|
|
} catch (error) {
|
|
log.error("Failed to connect to server:", error);
|
|
update((s) => ({
|
|
...s,
|
|
isLoading: false,
|
|
error: error instanceof Error ? error.message : String(error),
|
|
}));
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Login with username and password.
|
|
*
|
|
* TRACES: UR-009, UR-012 | IR-009, IR-014
|
|
*/
|
|
async function login(username: string, password: string, serverUrl: string, serverName: string) {
|
|
update((s) => ({ ...s, isLoading: true, error: null }));
|
|
|
|
try {
|
|
const deviceId = await getDeviceId();
|
|
log.debug("Logging in as:", username);
|
|
|
|
const authResult = await commands.authLogin(serverUrl, username, password, deviceId);
|
|
|
|
log.debug("Login successful:", authResult.user);
|
|
|
|
// Save to storage
|
|
await commands.storageSaveServer(authResult.serverId, serverName, serverUrl, null);
|
|
|
|
await commands.storageSaveUser(
|
|
authResult.user.id,
|
|
authResult.serverId,
|
|
authResult.user.name,
|
|
authResult.accessToken,
|
|
);
|
|
|
|
await commands.storageSetActiveUser(authResult.user.id, authResult.serverId);
|
|
|
|
// Set session in auth manager with server name
|
|
await commands.authSetSession({
|
|
userId: authResult.user.id,
|
|
username: authResult.user.name,
|
|
serverId: authResult.serverId,
|
|
serverUrl,
|
|
serverName,
|
|
accessToken: authResult.accessToken,
|
|
verified: true,
|
|
needsReauth: false,
|
|
});
|
|
|
|
// Create RepositoryClient
|
|
repository = new RepositoryClient();
|
|
await repository.create(
|
|
serverUrl,
|
|
authResult.user.id,
|
|
authResult.accessToken,
|
|
authResult.serverId,
|
|
);
|
|
|
|
// Configure Rust player
|
|
try {
|
|
const playerDeviceId = await getDeviceId();
|
|
await commands.playerConfigureJellyfin(
|
|
serverUrl,
|
|
authResult.accessToken,
|
|
authResult.user.id,
|
|
playerDeviceId,
|
|
);
|
|
log.debug("Rust player configured for playback reporting");
|
|
} catch (error) {
|
|
log.error("Failed to configure Rust player:", error);
|
|
}
|
|
|
|
// Update state
|
|
set({
|
|
isAuthenticated: true,
|
|
isLoading: false,
|
|
user: authResult.user,
|
|
serverUrl,
|
|
serverName,
|
|
error: null,
|
|
securityWarning: initialState.securityWarning,
|
|
needsReauth: false,
|
|
isVerifying: false,
|
|
sessionVerified: true,
|
|
});
|
|
|
|
// Start background verification
|
|
try {
|
|
const verifyDeviceId = await getDeviceId();
|
|
await commands.authStartVerification(verifyDeviceId);
|
|
} catch (error) {
|
|
log.error("Failed to start verification:", error);
|
|
}
|
|
|
|
return authResult;
|
|
} catch (error) {
|
|
log.error("Login failed:", error);
|
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
update((s) => ({ ...s, isLoading: false, error: errorMessage }));
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Re-authenticate with password (when session expired).
|
|
*/
|
|
async function reauthenticate(password: string) {
|
|
update((s) => ({ ...s, isLoading: true, error: null, needsReauth: false }));
|
|
|
|
try {
|
|
const deviceId = await getDeviceId();
|
|
log.debug("Re-authenticating...");
|
|
|
|
const authResult = await commands.authReauthenticate(password, deviceId);
|
|
|
|
log.debug("Re-authentication successful");
|
|
|
|
// Update storage
|
|
await commands.storageSaveUser(
|
|
authResult.user.id,
|
|
authResult.serverId,
|
|
authResult.user.name,
|
|
authResult.accessToken,
|
|
);
|
|
|
|
// Recreate repository with new credentials
|
|
if (repository) {
|
|
await repository.destroy();
|
|
const session = await commands.authGetSession();
|
|
if (session) {
|
|
await repository.create(
|
|
session.serverUrl,
|
|
authResult.user.id,
|
|
authResult.accessToken,
|
|
authResult.serverId,
|
|
);
|
|
}
|
|
}
|
|
|
|
// Reconfigure player
|
|
try {
|
|
const playerDeviceId = await getDeviceId();
|
|
await commands.playerConfigureJellyfin(
|
|
repository ? await getCurrentSessionServerUrl() : "",
|
|
authResult.accessToken,
|
|
authResult.user.id,
|
|
playerDeviceId,
|
|
);
|
|
} catch (error) {
|
|
log.error("Failed to reconfigure player:", error);
|
|
}
|
|
|
|
// Update state
|
|
update((s) => ({
|
|
...s,
|
|
isLoading: false,
|
|
needsReauth: false,
|
|
sessionVerified: true,
|
|
user: authResult.user,
|
|
error: null,
|
|
}));
|
|
|
|
return authResult;
|
|
} catch (error) {
|
|
log.error("Re-authentication failed:", error);
|
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
update((s) => ({ ...s, isLoading: false, error: errorMessage }));
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Logout and clear session.
|
|
*
|
|
* TRACES: UR-012 | IR-014
|
|
*/
|
|
async function logout() {
|
|
try {
|
|
const session = await commands.authGetSession();
|
|
if (session) {
|
|
const deviceId = await getDeviceId();
|
|
await commands.authLogout(session.serverUrl, session.accessToken, deviceId);
|
|
}
|
|
|
|
// Disable Jellyfin reporting in player
|
|
try {
|
|
await commands.playerDisableJellyfin();
|
|
} catch (error) {
|
|
log.error("Failed to disable player reporting:", error);
|
|
}
|
|
|
|
// Clear repository
|
|
if (repository) {
|
|
await repository.destroy();
|
|
}
|
|
repository = null;
|
|
|
|
set({
|
|
isAuthenticated: false,
|
|
isLoading: false,
|
|
user: null,
|
|
serverUrl: null,
|
|
serverName: null,
|
|
error: null,
|
|
securityWarning: null,
|
|
needsReauth: false,
|
|
isVerifying: false,
|
|
sessionVerified: false,
|
|
});
|
|
|
|
// Clear device ID cache on logout
|
|
clearDeviceIdCache();
|
|
} catch (error) {
|
|
log.error("Logout error (continuing anyway):", error);
|
|
set(initialState);
|
|
clearDeviceIdCache();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Clear error state.
|
|
*/
|
|
function clearError() {
|
|
update((s) => ({ ...s, error: null }));
|
|
}
|
|
|
|
/**
|
|
* Get current session from Rust backend.
|
|
*/
|
|
async function getCurrentSession() {
|
|
try {
|
|
return await commands.authGetSession();
|
|
} catch (error) {
|
|
log.error("Failed to get current session:", error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get current user ID.
|
|
*/
|
|
function getUserId(): string | null {
|
|
const state = get({ subscribe });
|
|
return state.user?.id || null;
|
|
}
|
|
|
|
/**
|
|
* Get server URL.
|
|
*/
|
|
function getServerUrl(): string | null {
|
|
const state = get({ subscribe });
|
|
return state.serverUrl;
|
|
}
|
|
|
|
/**
|
|
* Helper to get server URL from current session.
|
|
*/
|
|
async function getCurrentSessionServerUrl(): Promise<string> {
|
|
const session = await commands.authGetSession();
|
|
return session?.serverUrl || "";
|
|
}
|
|
|
|
/**
|
|
* Retry session verification (called when server becomes reachable again).
|
|
*/
|
|
async function retryVerification() {
|
|
try {
|
|
const deviceId = await getDeviceId();
|
|
log.debug("Retrying session verification after reconnection");
|
|
await commands.authStartVerification(deviceId);
|
|
} catch (error) {
|
|
log.error("Failed to retry verification:", error);
|
|
}
|
|
}
|
|
|
|
return {
|
|
subscribe,
|
|
initialize,
|
|
connectToServer,
|
|
login,
|
|
reauthenticate,
|
|
logout,
|
|
clearError,
|
|
getRepository,
|
|
waitForRepository,
|
|
getCurrentSession,
|
|
getUserId,
|
|
getServerUrl,
|
|
retryVerification,
|
|
cleanupEventListeners,
|
|
};
|
|
}
|
|
|
|
export const auth = createAuthStore();
|
|
export const isAuthenticated = derived(auth, ($auth) => $auth.isAuthenticated);
|
|
export const isLoading = derived(auth, ($auth) => $auth.isLoading);
|
|
export const currentUser = derived(auth, ($auth) => $auth.user);
|
|
export const needsReauth = derived(auth, ($auth) => $auth.needsReauth);
|
|
export const securityWarning = derived(auth, ($auth) => $auth.securityWarning);
|
|
export const authError = derived(auth, ($auth) => $auth.error);
|
|
export const isVerifying = derived(auth, ($auth) => $auth.isVerifying);
|
|
export const sessionVerified = derived(auth, ($auth) => $auth.sessionVerified);
|
|
export const serverName = derived(auth, ($auth) => $auth.serverName);
|
|
export const serverUrl = derived(auth, ($auth) => $auth.serverUrl);
|