Fix: WebUI Auth & Tauri Auth

This commit is contained in:
Youwes09
2026-06-08 20:27:22 -05:00
parent 615fa1e92f
commit 3b8c8dea38
11 changed files with 274 additions and 364 deletions
@@ -1,7 +1,8 @@
<script lang="ts">
import { settingsState, updateSettings } from '$lib/state/settings.svelte'
import { requestManager } from '$lib/request-manager'
import { authSession, loginUI } from '$lib/core/auth'
import { retryBoot } from '$lib/state/boot.svelte'
import { authSession, configureAuth } from '$lib/core/auth'
interface Props { selectOpen: string | null; toggleSelect: (id: string) => void }
let { selectOpen, toggleSelect }: Props = $props()
@@ -13,9 +14,15 @@
let secSaved = $state<string | null>(null)
let secLoaded = $state(false)
let authMode = $state(settingsState.settings.serverAuthMode ?? 'NONE')
function normalizeForUI(mode: string | undefined): 'NONE' | 'BASIC_AUTH' | 'UI_LOGIN' {
if (mode === 'BASIC_AUTH' || mode === 'UI_LOGIN') return mode
return 'NONE'
}
let authMode = $state(normalizeForUI(settingsState.settings.serverAuthMode))
let authUsername = $state(settingsState.settings.serverAuthUser ?? '')
let authPassword = $state('')
let authDirty = $state(false)
let socksEnabled = $state(settingsState.settings.socksProxyEnabled ?? false)
let socksHost = $state(settingsState.settings.socksProxyHost ?? '')
@@ -60,28 +67,23 @@
setTimeout(() => lockSaved = false, 2000)
}
function normalizeAuthMode(mode: string): 'NONE' | 'BASIC_AUTH' | 'UI_LOGIN' {
if (mode === 'BASIC_AUTH' || mode === 'UI_LOGIN' || mode === 'NONE') return mode
return 'NONE'
}
function showSaved(key: string) {
secSaved = key; secError = null
setTimeout(() => { if (secSaved === key) secSaved = null }, 2000)
}
$effect(() => {
if (!secLoaded) { secLoaded = true; authSession.clearTokens(); loadServerSecurity() }
if (!secLoaded) { secLoaded = true; loadServerSecurity() }
})
async function loadServerSecurity() {
try {
const s = await requestManager.extensions.getServerSecurity()
const serverMode = normalizeAuthMode(s.authMode)
if (serverMode !== 'UI_LOGIN') authSession.clearTokens()
authMode = serverMode
authUsername = s.authUsername || ''
updateSettings({ serverAuthMode: serverMode, serverAuthUser: authUsername })
if (!authDirty) {
authMode = normalizeForUI(s.authMode)
authUsername = s.authUsername || ''
updateSettings({ serverAuthMode: authMode, serverAuthUser: authUsername })
}
socksEnabled = s.socksProxyEnabled; socksHost = s.socksProxyHost
socksPort = s.socksProxyPort; socksVersion = s.socksProxyVersion
socksUsername = s.socksProxyUsername
@@ -95,37 +97,28 @@
flareSolverrTimeout: flareTimeout, flareSolverrSessionName: flareSession,
flareSolverrSessionTtl: flareTtl, flareSolverrAsResponseFallback: flareFallback,
})
} catch {}
} catch (e: any) {
console.warn('[SecuritySettings] loadServerSecurity failed:', e?.message ?? e)
}
}
async function saveAuth() {
if (authMode === 'NONE') { await clearAuth(); return }
if (!authUsername.trim() || !authPassword.trim()) { secError = 'Username and password are required'; return }
secLoading = true; secError = null
const prev = { mode: settingsState.settings.serverAuthMode, user: settingsState.settings.serverAuthUser, pass: settingsState.settings.serverAuthPass }
try {
const newUser = authUsername.trim()
const newPass = authPassword.trim()
authSession.clearTokens()
if (authMode === 'UI_LOGIN') {
await loginUI(newUser, newPass)
updateSettings({ serverAuthMode: 'UI_LOGIN', serverAuthUser: newUser, serverAuthPass: '' })
} else {
updateSettings({ serverAuthMode: 'BASIC_AUTH', serverAuthUser: newUser, serverAuthPass: newPass })
}
await requestManager.extensions.setServerAuth({ authMode, authUsername: newUser, authPassword: newPass })
authSession.clearTokens()
updateSettings({ serverAuthMode: authMode as any, serverAuthUser: newUser, serverAuthPass: authMode === 'BASIC_AUTH' ? newPass : '' })
configureAuth(settingsState.settings.serverUrl ?? '', authMode as any, newUser, authMode === 'BASIC_AUTH' ? newPass : undefined)
authPassword = ''
authDirty = false
showSaved('auth')
retryBoot(authMode as any, newUser, newPass)
} catch (e: any) {
const msg = e?.message ?? 'Failed to save authentication settings'
const authMismatch = /unauthorized|unauthenticated|authentication|401/i.test(msg)
if (!authMismatch) {
authSession.clearTokens()
updateSettings({ serverAuthMode: prev.mode, serverAuthUser: prev.user, serverAuthPass: prev.pass })
}
secError = authMismatch
? 'Saved local auth settings, but the server rejected the update. Verify your new credentials with the current server configuration.'
: msg
secError = e?.message ?? 'Failed to save authentication settings'
} finally { secLoading = false }
}
@@ -134,9 +127,11 @@
const prev = { mode: settingsState.settings.serverAuthMode, user: settingsState.settings.serverAuthUser, pass: settingsState.settings.serverAuthPass }
try {
await requestManager.extensions.setServerAuth({ authMode: 'NONE', authUsername: '', authPassword: '' })
authSession.clearTokens()
configureAuth(settingsState.settings.serverUrl ?? '', 'NONE')
updateSettings({ serverAuthMode: 'NONE', serverAuthUser: '', serverAuthPass: '' })
authMode = 'NONE'; authUsername = ''; authPassword = ''
authSession.clearTokens(); showSaved('auth')
authMode = 'NONE'; authUsername = ''; authPassword = ''; authDirty = false
showSaved('auth')
} catch (e: any) {
updateSettings({ serverAuthMode: prev.mode, serverAuthUser: prev.user, serverAuthPass: prev.pass })
secError = e?.message ?? 'Failed to disable authentication'
@@ -186,6 +181,7 @@
authMode = 'NONE'
authUsername = ''
authPassword = ''
authDirty = false
updateSettings({ serverAuthMode: 'NONE', serverAuthUser: '', serverAuthPass: '' })
showSaved('auth')
}
@@ -214,23 +210,28 @@
<div class="s-segment">
{#each [{ value: 'NONE', label: 'None' }, { value: 'BASIC_AUTH', label: 'Basic' }, { value: 'UI_LOGIN', label: 'UI Login' }] as opt}
<button class="s-segment-btn" class:active={authMode === opt.value}
onclick={() => authMode = opt.value as any} disabled={secLoading}>{opt.label}</button>
onclick={() => { authMode = opt.value as any; authPassword = ''; authDirty = true }} disabled={secLoading}>{opt.label}</button>
{/each}
</div>
</div>
{#if authMode !== 'NONE'}
<div class="s-row">
<div class="s-row-info"><span class="s-label">Username</span></div>
<input class="s-input" bind:value={authUsername} placeholder="Username" autocomplete="off" spellcheck="false" disabled={secLoading} />
<input class="s-input" bind:value={authUsername} oninput={() => authDirty = true} placeholder="Username" autocomplete="off" spellcheck="false" disabled={secLoading} />
</div>
<div class="s-row">
<div class="s-row-info"><span class="s-label">Password</span></div>
<div class="s-field-wrap">
<input class="s-input" type={showAuthPass ? 'text' : 'password'} bind:value={authPassword} placeholder="Password" autocomplete="off" spellcheck="false" disabled={secLoading} />
<input class="s-input" type={showAuthPass ? 'text' : 'password'} bind:value={authPassword} oninput={() => authDirty = true} placeholder="Password" autocomplete="off" spellcheck="false" disabled={secLoading} />
<button class="s-eye-btn" onclick={() => showAuthPass = !showAuthPass} tabindex="-1" aria-label={showAuthPass ? 'Hide password' : 'Show password'}>{@html showAuthPass ? EyeClose : EyeOpen}</button>
</div>
</div>
{/if}
{#if authMode !== 'NONE' && settingsState.settings.serverAuthMode === authMode && !authPassword}
<div class="s-row">
<span class="s-desc" style="color: var(--text-muted)">Re-enter your password to update credentials.</span>
</div>
{/if}
{#if settingsState.settings.serverAuthMode === 'BASIC_AUTH'}
<div class="s-row">
<span class="s-desc">Images are proxied through Tauri when Basic Auth is active, which reduces loading speed.</span>
@@ -250,8 +251,16 @@
</button>
{/if}
<button class="s-btn s-btn-accent" onclick={saveAuth}
disabled={secLoading || ((authMode === 'BASIC_AUTH' || authMode === 'UI_LOGIN') && (!authUsername.trim() || !authPassword.trim()))}>
{secLoading ? 'Saving…' : secSaved === 'auth' ? 'Saved ✓' : settingsState.settings.serverAuthMode === 'BASIC_AUTH' ? 'Update' : authMode === 'NONE' ? 'Save' : 'Enable'}
disabled={secLoading || (authMode !== 'NONE' && (!authUsername.trim() || !authPassword.trim()))}>
{#if secLoading}
Saving…
{:else if secSaved === 'auth'}
Saved ✓
{:else if authMode === 'NONE'}
Save
{:else}
{authDirty ? 'Enable' : 'Save'}
{/if}
</button>
</div>
</div>
+58 -73
View File
@@ -1,4 +1,5 @@
const DEFAULT_URL = 'http://127.0.0.1:4567'
const SKEW_MS = 60_000 * 2
interface AuthConfig {
baseUrl: string
@@ -10,74 +11,61 @@ interface AuthConfig {
export interface UiAuthDebugStatus {
mode: 'NONE' | 'BASIC_AUTH' | 'UI_LOGIN'
hasSession: boolean
hasRefreshToken: boolean
accessExpiresAt: number | null
refreshExpiresAt: number | null
accessExpiresInMs: number | null
refreshExpiresInMs: number | null
shouldRefreshSoon: boolean
refreshInFlight: boolean
skewMs: number
}
const SKEW_MS = 60_000 * 2
let config: AuthConfig = { baseUrl: DEFAULT_URL, mode: 'NONE' }
let accessToken: string | null = null
let refreshToken: string | null = null
let accessExpiresAt: number | null = null
let refreshExpiresAt: number | null = null
let refreshInFlight = false
let accessToken: string | null = null
let refreshToken: string | null = null
let accessExpiresAt: number | null = null
let refreshInFlight = false
function parseExpiry(token: string): number | null {
try {
const payload = JSON.parse(atob(token.split('.')[1]))
return typeof payload.exp === 'number' ? payload.exp * 1000 : null
} catch {
return null
}
} catch { return null }
}
export const authSession = {
clearTokens() {
accessToken = null
refreshToken = null
accessExpiresAt = null
refreshExpiresAt = null
accessToken = null
refreshToken = null
accessExpiresAt = null
},
}
export function getUIAccessToken(): string | null {
return accessToken
}
export function getUIAccessToken(): string | null { return accessToken }
export function getUiAuthDebugStatus(): UiAuthDebugStatus {
const now = Date.now()
const accessExpiresInMs = accessExpiresAt !== null ? accessExpiresAt - now : null
const refreshExpiresInMs = refreshExpiresAt !== null ? refreshExpiresAt - now : null
const now = Date.now()
const accessExpiresInMs = accessExpiresAt !== null ? accessExpiresAt - now : null
return {
mode: config.mode,
hasSession: accessToken !== null,
hasRefreshToken: refreshToken !== null,
mode: config.mode,
hasSession: accessToken !== null,
accessExpiresAt,
refreshExpiresAt,
accessExpiresInMs,
refreshExpiresInMs,
shouldRefreshSoon: accessExpiresInMs !== null && accessExpiresInMs < SKEW_MS,
shouldRefreshSoon: accessExpiresInMs !== null && accessExpiresInMs < SKEW_MS,
refreshInFlight,
skewMs: SKEW_MS,
skewMs: SKEW_MS,
}
}
export function configureAuth(
baseUrl: string,
mode: 'NONE' | 'BASIC_AUTH' | 'UI_LOGIN',
user?: string,
pass?: string,
mode: 'NONE' | 'BASIC_AUTH' | 'UI_LOGIN',
user?: string,
pass?: string,
): void {
config = { baseUrl: baseUrl.replace(/\/$/, ''), mode, user, pass }
authSession.clearTokens()
config = { baseUrl: baseUrl.replace(/\/$/, ''), mode, user, pass }
accessToken = null
refreshToken = null
accessExpiresAt = null
}
export function authHeaders(): Record<string, string> {
@@ -90,16 +78,18 @@ export function authHeaders(): Record<string, string> {
return {}
}
async function gqlRaw(query: string, variables?: Record<string, unknown>): Promise<unknown> {
async function gql<T>(query: string, variables?: Record<string, unknown>, bare = false): Promise<T> {
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
if (!bare) Object.assign(headers, authHeaders())
const res = await fetch(`${config.baseUrl}/api/graphql`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...authHeaders() },
headers,
body: JSON.stringify({ query, variables }),
})
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const json = await res.json()
if (json.errors?.length) throw new Error(json.errors[0].message)
return json.data
return json.data as T
}
export async function probeServer(): Promise<'ok' | 'auth_required' | 'unreachable'> {
@@ -107,7 +97,7 @@ export async function probeServer(): Promise<'ok' | 'auth_required' | 'unreachab
const res = await fetch(`${config.baseUrl}/api/graphql`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...authHeaders() },
body: JSON.stringify({ query: '{ aboutServer { name } }' }),
body: JSON.stringify({ query: '{ settings { authMode } }' }),
})
if (res.status === 401 || res.status === 403) return 'auth_required'
if (!res.ok) return 'unreachable'
@@ -116,17 +106,7 @@ export async function probeServer(): Promise<'ok' | 'auth_required' | 'unreachab
/unauthorized|unauthenticated/i.test(e.message)
)
return isAuthError ? 'auth_required' : 'ok'
} catch {
return 'unreachable'
}
}
export async function loginBasic(user: string, pass: string): Promise<void> {
config.user = user
config.pass = pass
config.mode = 'BASIC_AUTH'
const probe = await probeServer()
if (probe !== 'ok') throw new Error('Invalid credentials')
} catch { return 'unreachable' }
}
const LOGIN_MUTATION = `
@@ -145,30 +125,29 @@ const REFRESH_MUTATION = `
}
`
export async function loginUI(user: string, pass: string): Promise<void> {
const data = await gqlRaw(LOGIN_MUTATION, { username: user, password: pass }) as {
login: { accessToken: string; refreshToken: string }
export async function loginBasic(user: string, pass: string): Promise<void> {
const prev = { user: config.user, pass: config.pass, mode: config.mode }
config.user = user
config.pass = pass
config.mode = 'BASIC_AUTH'
const probe = await probeServer()
if (probe !== 'ok') {
config.user = prev.user
config.pass = prev.pass
config.mode = prev.mode as typeof config.mode
throw new Error('Invalid credentials')
}
accessToken = data.login.accessToken
refreshToken = data.login.refreshToken
accessExpiresAt = parseExpiry(accessToken)
refreshExpiresAt = parseExpiry(refreshToken)
config.mode = 'UI_LOGIN'
config.user = user
}
export async function refreshAccessToken(): Promise<boolean> {
if (!refreshToken) return false
try {
const data = await gqlRaw(REFRESH_MUTATION, { refreshToken }) as {
refreshToken: { accessToken: string }
}
accessToken = data.refreshToken.accessToken
accessExpiresAt = parseExpiry(accessToken)
return true
} catch {
return false
}
export async function loginUI(user: string, pass: string): Promise<void> {
const data = await gql<{ login: { accessToken: string; refreshToken: string } }>(
LOGIN_MUTATION, { username: user, password: pass }, true
)
accessToken = data.login.accessToken
refreshToken = data.login.refreshToken
accessExpiresAt = parseExpiry(accessToken)
config.mode = 'UI_LOGIN'
config.user = user
}
export async function refreshUiAccessToken(force = false): Promise<string | null> {
@@ -179,8 +158,14 @@ export async function refreshUiAccessToken(force = false): Promise<string | null
if (refreshInFlight) return accessToken
refreshInFlight = true
try {
const ok = await refreshAccessToken()
return ok ? accessToken : null
const data = await gql<{ refreshToken: { accessToken: string } }>(
REFRESH_MUTATION, { refreshToken }
)
accessToken = data.refreshToken.accessToken
accessExpiresAt = parseExpiry(accessToken)
return accessToken
} catch {
return null
} finally {
refreshInFlight = false
}
+8 -10
View File
@@ -1,6 +1,6 @@
import { fetch as tauriFetch } from "@tauri-apps/plugin-http";
import { settingsState } from "$lib/state/settings.svelte";
import { getUIAccessToken } from "$lib/core/auth";
import { platformService } from "$lib/platform-service";
import { settingsState } from "$lib/state/settings.svelte";
import { getUIAccessToken } from "$lib/core/auth";
const cache = new Map<string, string>();
const inflight = new Map<string, Promise<string>>();
@@ -19,14 +19,14 @@ interface QueueEntry {
const queue: QueueEntry[] = [];
async function getAuthHeaders(): Promise<Record<string, string>> {
const mode = settingsState.serverAuthMode ?? "NONE";
const mode = settingsState.settings.serverAuthMode ?? "NONE";
if (mode === "UI_LOGIN") {
const token = await getUIAccessToken();
const token = getUIAccessToken();
return token ? { Authorization: `Bearer ${token}` } : {};
}
if (mode === "BASIC_AUTH") {
const user = settingsState.serverAuthUser?.trim() ?? "";
const pass = settingsState.serverAuthPass?.trim() ?? "";
const user = settingsState.settings.serverAuthUser?.trim() ?? "";
const pass = settingsState.settings.serverAuthPass?.trim() ?? "";
return user && pass ? { Authorization: `Basic ${btoa(`${user}:${pass}`)}` } : {};
}
return {};
@@ -34,9 +34,7 @@ async function getAuthHeaders(): Promise<Record<string, string>> {
async function doFetch(url: string): Promise<string> {
const headers = await getAuthHeaders();
const res = await tauriFetch(url, { method: "GET", headers });
if (!res.ok) throw new Error(`${res.status}`);
const blob = await res.blob();
const blob = await platformService.fetchImage(url, headers);
if (clearing) throw new DOMException("Cancelled", "AbortError");
const blobUrl = URL.createObjectURL(blob);
cache.set(url, blobUrl);
@@ -3,6 +3,7 @@ import { getCurrentWindow } from '@tauri-apps/api
import { listen } from '@tauri-apps/api/event'
import { open } from '@tauri-apps/plugin-dialog'
import { readFile, writeFile } from '@tauri-apps/plugin-fs'
import { fetch as tauriFetch } from '@tauri-apps/plugin-http'
import { open as openUrl } from '@tauri-apps/plugin-shell'
import { getVersion } from '@tauri-apps/api/app'
import { connect, disconnect, setActivity, clearActivity } from 'tauri-plugin-discord-rpc-api'
@@ -116,6 +117,12 @@ export class TauriAdapter implements PlatformAdapter {
return invoke('get_auto_backup_dir')
}
async fetchImage(url: string, headers: Record<string, string>): Promise<Blob> {
const res = await tauriFetch(url, { method: 'GET', headers })
if (!res.ok) throw new Error(`${res.status}`)
return res.blob()
}
async launchServer(config: ServerLaunchConfig): Promise<void> {
await invoke('spawn_server', {
binary: config.binary ?? '',
+2
View File
@@ -93,6 +93,8 @@ export interface PlatformAdapter {
migrateDownloads(src: string, dst: string): Promise<void>
getAutoBackupDir(): Promise<string>
fetchImage(url: string, headers: Record<string, string>): Promise<Blob>
launchServer(config: ServerLaunchConfig): Promise<void>
stopServer(): Promise<void>
getServerStatus(): Promise<'running' | 'stopped' | 'error'>
+6
View File
@@ -61,6 +61,12 @@ export class WebAdapter implements PlatformAdapter {
async migrateDownloads(_src: string, _dst: string): Promise<void> {}
async getAutoBackupDir(): Promise<string> { return '' }
async fetchImage(url: string, headers: Record<string, string>): Promise<Blob> {
const res = await fetch(url, { method: 'GET', headers })
if (!res.ok) throw new Error(`${res.status}`)
return res.blob()
}
async launchServer(_config: ServerLaunchConfig): Promise<void> {}
async stopServer(): Promise<void> {}
async getServerStatus(): Promise<'running' | 'stopped' | 'error'> { return 'stopped' }
+2
View File
@@ -42,6 +42,8 @@ export const platformService = {
migrateDownloads:(src: string, dst: string) => get().migrateDownloads(src, dst),
getAutoBackupDir:() => get().getAutoBackupDir(),
fetchImage: (url: string, headers: Record<string, string>) => get().fetchImage(url, headers),
launchServer: (c: ServerLaunchConfig) => get().launchServer(c),
stopServer: () => get().stopServer(),
getServerStatus: () => get().getServerStatus(),
+10 -17
View File
@@ -41,6 +41,7 @@ import {
UPDATE_STOP,
SET_MANGA_META,
DELETE_MANGA_META,
CREATE_BACKUP,
FETCH_SOURCE_MANGA,
LIBRARY_UPDATE_STATUS,
MANGAS_BY_GENRE,
@@ -114,8 +115,8 @@ import {
SET_FLARE_SOLVERR,
RESTORE_BACKUP,
VALIDATE_BACKUP,
CREATE_BACKUP,
} from './meta'
import { authHeaders } from '$lib/core/auth'
import {
type GQLResponse,
mapManga,
@@ -141,15 +142,10 @@ function mapDownloadStatus(raw: { state: string; queue: RawQueueItem[] }): Downl
}
export class SuwayomiAdapter implements ServerAdapter {
private baseUrl = 'http://127.0.0.1:4567'
private authHeader: string | null = null
private baseUrl = 'http://127.0.0.1:4567'
async connect(config: ServerConfig): Promise<void> {
this.baseUrl = config.baseUrl.replace(/\/$/, '')
if (config.credentials) {
const { username, password } = config.credentials
this.authHeader = 'Basic ' + btoa(`${username}:${password}`)
}
initPageCache(this.gql.bind(this), this.getServerUrl.bind(this))
}
@@ -160,9 +156,9 @@ export class SuwayomiAdapter implements ServerAdapter {
async getStatus(): Promise<ServerStatus> {
try {
const res = await fetch(`${this.baseUrl}/api/graphql`, {
method: 'POST',
headers: this.headers(),
body: JSON.stringify({ query: '{ aboutServer { name } }' }),
method: 'POST',
headers: { 'Content-Type': 'application/json', ...authHeaders() },
body: JSON.stringify({ query: '{ aboutServer { name } }' }),
})
return res.ok ? 'connected' : 'error'
} catch {
@@ -171,9 +167,7 @@ export class SuwayomiAdapter implements ServerAdapter {
}
private headers(): Record<string, string> {
const h: Record<string, string> = { 'Content-Type': 'application/json' }
if (this.authHeader) h['Authorization'] = this.authHeader
return h
return { 'Content-Type': 'application/json', ...authHeaders() }
}
private async gql<T>(
@@ -182,9 +176,9 @@ export class SuwayomiAdapter implements ServerAdapter {
signal?: AbortSignal,
): Promise<T> {
const res = await fetch(`${this.baseUrl}/api/graphql`, {
method: 'POST',
method: 'POST',
headers: this.headers(),
body: JSON.stringify({ query, variables }),
body: JSON.stringify({ query, variables }),
signal,
})
if (!res.ok) throw new Error(`Suwayomi HTTP ${res.status}`)
@@ -642,8 +636,7 @@ export class SuwayomiAdapter implements ServerAdapter {
form.append('operations', JSON.stringify({ query, variables: { backup: null } }))
form.append('map', JSON.stringify({ '0': ['variables.backup'] }))
form.append('0', file, file.name)
const headers: Record<string, string> = { Accept: 'application/json' }
if (this.authHeader) headers['Authorization'] = this.authHeader
const headers: Record<string, string> = { Accept: 'application/json', ...authHeaders() }
return fetch(`${this.baseUrl}/api/graphql`, { method: 'POST', headers, body: form })
.then(r => { if (!r.ok) throw new Error(`Suwayomi HTTP ${r.status}`); return r.json() })
.then((json: GQLResponse<T>) => { if (json.errors?.length) throw new Error(json.errors[0].message); return json.data })
+106 -217
View File
@@ -1,237 +1,126 @@
import type { DownloadStatus } from '$lib/types/api'
import type { Manga, Chapter, Extension, Source, Tracker, TrackRecord, Category } from '$lib/types'
export type PlatformFeature =
| 'server-management'
| 'biometric-auth'
| 'native-window'
| 'filesystem'
| 'app-updates'
| 'discord-rpc'
export interface ServerConfig {
baseUrl: string
credentials?: { username: string; password: string }
export type Platform = 'tauri' | 'capacitor' | 'web'
export interface ServerLaunchConfig {
binary?: string
binaryArgs?: string
webUiEnabled?: boolean
}
export type ServerStatus = 'connected' | 'disconnected' | 'error'
export interface MangaFilters {
inLibrary?: boolean
status?: MangaStatus
tags?: string[]
unread?: boolean
sourceId?: string
export interface DiscordAssets {
largeImage?: string
largeText?: string
smallImage?: string
smallText?: string
}
export type MangaStatus =
| 'ONGOING'
| 'COMPLETED'
| 'LICENSED'
| 'PUBLISHING_FINISHED'
| 'CANCELLED'
| 'ON_HIATUS'
export interface PaginatedResult<T> {
items: T[]
hasNextPage: boolean
total?: number
export interface DiscordButton {
label: string
url: string
}
export interface MangaMeta {
customTitle?: string
customCover?: string
notes?: string
[key: string]: unknown
export interface DiscordPresence {
state?: string
details?: string
assets?: DiscordAssets
buttons?: DiscordButton[]
timestamps?: { start?: number; end?: number }
}
export interface Page {
index: number
url: string
imageData?: string
export interface AppUpdateInfo {
version: string
url: string
notes: string
}
export interface AboutServer {
name: string
version: string
buildType: string
buildTime: number
github: string
discord: string
export interface StorageInfo {
manga_bytes: number
total_bytes: number
free_bytes: number
path: string
}
export interface AboutWebUI {
channel: string
tag: string
updateTimestamp: number
export interface MigrateProgress {
done: number
total: number
current: string
}
export interface DownloadItem {
chapterId: string
mangaId: string
chapterName: string
mangaTitle: string
thumbnailUrl?: string
progress: number
state: 'queued' | 'downloading' | 'finished' | 'error'
export interface UpdateProgress {
downloaded: number
total: number | null
}
export interface UpdateResult {
mangaId: string
newChapters: number
export interface ReleaseInfo {
tag_name: string
name: string
body: string
published_at: string
html_url: string
}
export interface LibraryUpdateProgress {
isRunning: boolean
finishedJobs: number
totalJobs: number
}
export interface ServerSecurity {
authMode: string
authUsername: string
socksProxyEnabled: boolean
socksProxyHost: string
socksProxyPort: string
socksProxyVersion: number
socksProxyUsername: string
flareSolverrEnabled: boolean
flareSolverrUrl: string
flareSolverrTimeout: number
flareSolverrSessionName: string
flareSolverrSessionTtl: number
flareSolverrAsResponseFallback: boolean
}
export interface SetServerAuthInput {
authMode: string
authUsername: string
authPassword: string
}
export interface SetSocksProxyInput {
socksProxyEnabled: boolean
socksProxyHost: string
socksProxyPort: string
socksProxyVersion: number
socksProxyUsername: string
socksProxyPassword: string
}
export interface SetFlareSolverrInput {
flareSolverrEnabled: boolean
flareSolverrUrl: string
flareSolverrTimeout: number
flareSolverrSessionName: string
flareSolverrSessionTtl: number
flareSolverrAsResponseFallback: boolean
}
export interface TrackRecordPatch {
status?: number
score?: number
lastChapterRead?: number
startDate?: string
finishDate?: string
private?: boolean
}
export interface RestoreStatus {
mangaProgress: number
state: string
totalManga: number
}
export interface ValidateBackupResult {
missingSources: { id: string; name: string }[]
missingTrackers: { name: string }[]
}
export interface ServerAdapter {
connect(config: ServerConfig): Promise<void>
getStatus(): Promise<ServerStatus>
getServerUrl(): string
getManga(id: string, signal?: AbortSignal): Promise<Manga>
getMangaList(filters: MangaFilters): Promise<PaginatedResult<Manga>>
getMangasByGenre(filter: Record<string, unknown>, first: number, offset: number, signal?: AbortSignal): Promise<{ items: Manga[]; hasNextPage: boolean; totalCount: number }>
searchManga(query: string, sourceId?: string): Promise<Manga[]>
fetchManga(id: string): Promise<Manga>
addToLibrary(mangaId: string): Promise<void>
removeFromLibrary(mangaId: string): Promise<void>
updateMangas(ids: string[], patch: { inLibrary?: boolean }): Promise<void>
updateMangaMeta(id: string, meta: Partial<MangaMeta>): Promise<void>
deleteMangaMeta(id: string, key: string): Promise<void>
getChapters(mangaId: string): Promise<Chapter[]>
getChapter(id: string): Promise<Chapter>
getChapterPages(id: string, signal?: AbortSignal): Promise<Page[]>
fetchChapters(mangaId: string): Promise<Chapter[]>
getRecentlyUpdated(): Promise<Chapter[]>
markChapterRead(id: string, read: boolean): Promise<void>
markChaptersRead(ids: string[], read: boolean): Promise<void>
updateChaptersProgress(ids: string[], patch: { isRead?: boolean; isBookmarked?: boolean; lastPageRead?: number }): Promise<void>
deleteDownloadedChapters(ids: string[]): Promise<void>
setChapterMeta(chapterId: string, key: string, value: string): Promise<void>
deleteChapterMeta(chapterId: string, key: string): Promise<void>
getAboutServer(): Promise<AboutServer>
getAboutWebUI(): Promise<AboutWebUI>
getDownloads(): Promise<DownloadItem[]>
getDownloadStatus(): Promise<DownloadStatus>
enqueueDownload(chapterId: string): Promise<void>
enqueueDownloads(chapterIds: string[]): Promise<void>
dequeueDownload(chapterId: string): Promise<void>
dequeueDownloads(chapterIds: string[]): Promise<void>
reorderDownload(chapterId: string, to: number): Promise<DownloadStatus | null>
clearDownloads(): Promise<void>
startDownloader(): Promise<DownloadStatus | null>
stopDownloader(): Promise<DownloadStatus | null>
getExtensions(): Promise<Extension[]>
installExtension(id: string): Promise<void>
uninstallExtension(id: string): Promise<void>
updateExtension(id: string): Promise<void>
updateExtensions(ids: string[]): Promise<void>
installExternalExtension(url: string): Promise<void>
getExtensionRepos(): Promise<string[]>
setExtensionRepos(repos: string[]): Promise<string[]>
getSources(): Promise<Source[]>
browseSource(sourceId: string, page: number): Promise<PaginatedResult<Manga>>
getSourceSettings(sourceId: string): Promise<unknown[]>
updateSourcePreference(sourceId: string, position: number, changeType: string, value: unknown): Promise<unknown[]>
getCategories(): Promise<Category[]>
createCategory(name: string): Promise<Category>
deleteCategory(id: number): Promise<void>
updateCategoryOrder(id: number, position: number): Promise<Category[]>
updateMangaCategories(mangaId: string, addTo: number[], removeFrom: number[]): Promise<void>
updateMangasCategories(mangaIds: string[], addTo: number[], removeFrom: number[]): Promise<void>
updateCategoryManga(categoryId: number): Promise<void>
getTrackers(): Promise<Tracker[]>
getAllTrackerRecords(): Promise<unknown[]>
getMangaTrackRecords(mangaId: string): Promise<unknown[]>
searchTracker(trackerId: string, query: string): Promise<unknown[]>
linkTracker(mangaId: string, trackerId: string, remoteId: string): Promise<void>
unlinkTracker(recordId: string): Promise<void>
updateTrackRecord(recordId: string, patch: TrackRecordPatch): Promise<TrackRecord>
fetchTrackRecord(recordId: string): Promise<TrackRecord>
syncTracking(mangaId: string): Promise<void>
loginTrackerOAuth(trackerId: string, callbackUrl: string): Promise<void>
loginTrackerCredentials(trackerId: string, username: string, password: string): Promise<void>
logoutTracker(trackerId: string): Promise<void>
getServerSecurity(): Promise<ServerSecurity>
setServerAuth(input: SetServerAuthInput): Promise<void>
setSocksProxy(input: SetSocksProxyInput): Promise<void>
setFlareSolverr(input: SetFlareSolverrInput): Promise<void>
getDownloadsPath(): Promise<{ downloadsPath: string; localSourcePath: string }>
setDownloadsPath(path: string): Promise<void>
setLocalSourcePath(path: string): Promise<void>
createBackup(): Promise<{ url: string }>
restoreBackup(file: File): Promise<{ id: string; status: RestoreStatus }>
validateBackup(file: File): Promise<ValidateBackupResult>
pollRestoreStatus(id: string): Promise<RestoreStatus>
clearCachedImages(opts: { cachedPages: boolean; cachedThumbnails: boolean; downloadedThumbnails: boolean }): Promise<void>
checkForUpdates(mangaIds?: string[]): Promise<UpdateResult[]>
stopLibraryUpdate(): Promise<void>
getLibraryUpdateStatus(): Promise<LibraryUpdateProgress>
clearPageCache(chapterId?: number): void
export interface PlatformAdapter {
readonly platform: Platform
init(): Promise<void>
destroy(): Promise<void>
isSupported(feature: PlatformFeature): boolean
getAppDir(): Promise<string>
loadStore(key: string): Promise<unknown>
saveStore(key: string, value: unknown): Promise<void>
storeCredential(key: string, value: string): Promise<void>
getCredential(key: string): Promise<string | null>
authenticateBiometric(): Promise<boolean>
readFile(path: string): Promise<Uint8Array>
writeFile(path: string, data: Uint8Array): Promise<void>
pickFolder(): Promise<string | null>
checkPathExists(path: string): Promise<boolean>
createDirectory(path: string): Promise<void>
openPath(path: string): Promise<void>
getDefaultDownloadsPath(): Promise<string>
getStorageInfo(downloadsPath: string): Promise<StorageInfo>
migrateDownloads(src: string, dst: string): Promise<void>
getAutoBackupDir(): Promise<string>
fetchImage(url: string, headers: Record<string, string>): Promise<Blob>
launchServer(config: ServerLaunchConfig): Promise<void>
stopServer(): Promise<void>
getServerStatus(): Promise<'running' | 'stopped' | 'error'>
setTitle(title: string): Promise<void>
minimize(): Promise<void>
maximize(): Promise<void>
close(): Promise<void>
toggleFullscreen(): Promise<void>
setDiscordPresence(presence: DiscordPresence): Promise<void>
clearDiscordPresence(): Promise<void>
getVersion(): Promise<string>
openExternal(url: string): Promise<void>
checkForAppUpdate(): Promise<AppUpdateInfo | null>
installAppUpdate(tag: string): Promise<void>
restartApp(): Promise<void>
exitApp(): Promise<void>
listReleases(): Promise<ReleaseInfo[]>
clearMokuCache(): Promise<void>
clearSuwayomiCache(): Promise<void>
resetSuwayomiData(): Promise<void>
onUpdateProgress(cb: (p: UpdateProgress) => void): Promise<() => void>
onUpdateLaunching(cb: () => void): Promise<() => void>
onMigrateProgress(cb: (p: MigrateProgress) => void): Promise<() => void>
}
+14 -9
View File
@@ -1,9 +1,9 @@
import { detectAdapter } from '$lib/platform-adapters'
import { initPlatformService } from '$lib/platform-service'
import { platformService } from '$lib/platform-service'
import { probeServer, loginBasic, loginUI } from '$lib/core/auth'
import { probeServer, loginBasic, loginUI, configureAuth } from '$lib/core/auth'
import { appState } from '$lib/state/app.svelte'
import { settingsState } from '$lib/state/settings.svelte'
import { settingsState } from '$lib/state/settings.svelte'
const MAX_ATTEMPTS = 40
const BG_MAX_ATTEMPTS = 120
@@ -42,9 +42,9 @@ function pinLockEnabled(): boolean {
function handleProbeSuccess(gen: number) {
if (gen !== probeGeneration) return
boot.failed = false
boot.skipped = false
boot.serverProbeOk = true
boot.failed = false
boot.skipped = false
boot.serverProbeOk = true
appState.authenticated = true
appState.status = pinLockEnabled() ? 'locked' : 'ready'
}
@@ -56,7 +56,8 @@ function handleAuthRequired(
pass: string,
) {
if (gen !== probeGeneration) return
boot.failed = false
boot.failed = false
appState.authMode = authMode
if (authMode === 'BASIC_AUTH' && user && pass) {
loginBasic(user, pass)
@@ -75,21 +76,25 @@ function handleAuthRequired(
appState.status = 'auth'
}
export function startProbe(
export async function startProbe(
authMode: 'NONE' | 'BASIC_AUTH' | 'UI_LOGIN' = 'NONE',
user = '',
pass = '',
initialDelay = 100,
) {
): Promise<void> {
const gen = ++probeGeneration
boot.failed = false
boot.loginRequired = false
boot.skipped = false
boot.serverProbeOk = false
appState.status = 'booting'
appState.authMode = authMode
const baseUrl = settingsState.settings.serverUrl ?? 'http://127.0.0.1:4567'
configureAuth(baseUrl, authMode, user || undefined, pass || undefined)
if (appState.platform === 'web') {
boot.failed = true
boot.failed = true
appState.status = 'error'
startBackgroundProbe(gen, authMode, user, pass)
return
+25 -11
View File
@@ -34,19 +34,32 @@
const isTauri = typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window
let splashVisible = $state(true)
let bypassed = $state(false)
let themeEditorOpen = $state(false)
let themeEditorId = $state<string | null>(null)
let _splashDismissed = $state(false)
let bypassed = $state(false)
let themeEditorOpen = $state(false)
let themeEditorId = $state<string | null>(null)
const splashVisible = $derived(
!_splashDismissed ||
appState.status === 'booting' ||
appState.status === 'locked' ||
appState.status === 'error' ||
appState.status === 'auth'
)
const ringFull = $derived(appState.status === 'ready')
const showApp = $derived(
appState.status === 'ready' ||
appState.status === 'auth' ||
bypassed
!splashVisible && (
appState.status === 'ready' ||
bypassed
)
)
function onSplashReady() { _splashDismissed = true }
function onSplashUnlock() { appState.status = 'ready'; _splashDismissed = true }
function onSplashBypass() { bypassed = true; _splashDismissed = true }
const isReaderRoute = $derived($page.url.pathname.startsWith('/reader'))
const readerContainerized = $derived(settingsState.settings.readerContainerized ?? false)
const strippedLayout = $derived(isReaderRoute && !readerContainerized)
@@ -127,10 +140,11 @@
)
})
function onSplashReady() { splashVisible = false }
function onSplashUnlock() { appState.status = 'ready'; splashVisible = false }
function onSplashBypass() { bypassed = true; splashVisible = false }
function onIdleDismiss() { appState.idleSplash = false }
$effect(() => {
if (appState.status === 'booting') _splashDismissed = false
})
function onIdleDismiss() { appState.idleSplash = false }
function onSplashRetry() {
import('$lib/state/boot.svelte').then(({ retryBoot }) => {