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"> <script lang="ts">
import { settingsState, updateSettings } from '$lib/state/settings.svelte' import { settingsState, updateSettings } from '$lib/state/settings.svelte'
import { requestManager } from '$lib/request-manager' 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 } interface Props { selectOpen: string | null; toggleSelect: (id: string) => void }
let { selectOpen, toggleSelect }: Props = $props() let { selectOpen, toggleSelect }: Props = $props()
@@ -13,9 +14,15 @@
let secSaved = $state<string | null>(null) let secSaved = $state<string | null>(null)
let secLoaded = $state(false) 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 authUsername = $state(settingsState.settings.serverAuthUser ?? '')
let authPassword = $state('') let authPassword = $state('')
let authDirty = $state(false)
let socksEnabled = $state(settingsState.settings.socksProxyEnabled ?? false) let socksEnabled = $state(settingsState.settings.socksProxyEnabled ?? false)
let socksHost = $state(settingsState.settings.socksProxyHost ?? '') let socksHost = $state(settingsState.settings.socksProxyHost ?? '')
@@ -60,28 +67,23 @@
setTimeout(() => lockSaved = false, 2000) 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) { function showSaved(key: string) {
secSaved = key; secError = null secSaved = key; secError = null
setTimeout(() => { if (secSaved === key) secSaved = null }, 2000) setTimeout(() => { if (secSaved === key) secSaved = null }, 2000)
} }
$effect(() => { $effect(() => {
if (!secLoaded) { secLoaded = true; authSession.clearTokens(); loadServerSecurity() } if (!secLoaded) { secLoaded = true; loadServerSecurity() }
}) })
async function loadServerSecurity() { async function loadServerSecurity() {
try { try {
const s = await requestManager.extensions.getServerSecurity() const s = await requestManager.extensions.getServerSecurity()
const serverMode = normalizeAuthMode(s.authMode) if (!authDirty) {
if (serverMode !== 'UI_LOGIN') authSession.clearTokens() authMode = normalizeForUI(s.authMode)
authMode = serverMode
authUsername = s.authUsername || '' authUsername = s.authUsername || ''
updateSettings({ serverAuthMode: serverMode, serverAuthUser: authUsername }) updateSettings({ serverAuthMode: authMode, serverAuthUser: authUsername })
}
socksEnabled = s.socksProxyEnabled; socksHost = s.socksProxyHost socksEnabled = s.socksProxyEnabled; socksHost = s.socksProxyHost
socksPort = s.socksProxyPort; socksVersion = s.socksProxyVersion socksPort = s.socksProxyPort; socksVersion = s.socksProxyVersion
socksUsername = s.socksProxyUsername socksUsername = s.socksProxyUsername
@@ -95,37 +97,28 @@
flareSolverrTimeout: flareTimeout, flareSolverrSessionName: flareSession, flareSolverrTimeout: flareTimeout, flareSolverrSessionName: flareSession,
flareSolverrSessionTtl: flareTtl, flareSolverrAsResponseFallback: flareFallback, flareSolverrSessionTtl: flareTtl, flareSolverrAsResponseFallback: flareFallback,
}) })
} catch {} } catch (e: any) {
console.warn('[SecuritySettings] loadServerSecurity failed:', e?.message ?? e)
}
} }
async function saveAuth() { async function saveAuth() {
if (authMode === 'NONE') { await clearAuth(); return } if (authMode === 'NONE') { await clearAuth(); return }
if (!authUsername.trim() || !authPassword.trim()) { secError = 'Username and password are required'; return } if (!authUsername.trim() || !authPassword.trim()) { secError = 'Username and password are required'; return }
secLoading = true; secError = null secLoading = true; secError = null
const prev = { mode: settingsState.settings.serverAuthMode, user: settingsState.settings.serverAuthUser, pass: settingsState.settings.serverAuthPass }
try { try {
const newUser = authUsername.trim() const newUser = authUsername.trim()
const newPass = authPassword.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 }) await requestManager.extensions.setServerAuth({ authMode, authUsername: newUser, authPassword: newPass })
authPassword = ''
showSaved('auth')
} 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() authSession.clearTokens()
updateSettings({ serverAuthMode: prev.mode, serverAuthUser: prev.user, serverAuthPass: prev.pass }) 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)
secError = authMismatch authPassword = ''
? 'Saved local auth settings, but the server rejected the update. Verify your new credentials with the current server configuration.' authDirty = false
: msg showSaved('auth')
retryBoot(authMode as any, newUser, newPass)
} catch (e: any) {
secError = e?.message ?? 'Failed to save authentication settings'
} finally { secLoading = false } } finally { secLoading = false }
} }
@@ -134,9 +127,11 @@
const prev = { mode: settingsState.settings.serverAuthMode, user: settingsState.settings.serverAuthUser, pass: settingsState.settings.serverAuthPass } const prev = { mode: settingsState.settings.serverAuthMode, user: settingsState.settings.serverAuthUser, pass: settingsState.settings.serverAuthPass }
try { try {
await requestManager.extensions.setServerAuth({ authMode: 'NONE', authUsername: '', authPassword: '' }) await requestManager.extensions.setServerAuth({ authMode: 'NONE', authUsername: '', authPassword: '' })
authSession.clearTokens()
configureAuth(settingsState.settings.serverUrl ?? '', 'NONE')
updateSettings({ serverAuthMode: 'NONE', serverAuthUser: '', serverAuthPass: '' }) updateSettings({ serverAuthMode: 'NONE', serverAuthUser: '', serverAuthPass: '' })
authMode = 'NONE'; authUsername = ''; authPassword = '' authMode = 'NONE'; authUsername = ''; authPassword = ''; authDirty = false
authSession.clearTokens(); showSaved('auth') showSaved('auth')
} catch (e: any) { } catch (e: any) {
updateSettings({ serverAuthMode: prev.mode, serverAuthUser: prev.user, serverAuthPass: prev.pass }) updateSettings({ serverAuthMode: prev.mode, serverAuthUser: prev.user, serverAuthPass: prev.pass })
secError = e?.message ?? 'Failed to disable authentication' secError = e?.message ?? 'Failed to disable authentication'
@@ -186,6 +181,7 @@
authMode = 'NONE' authMode = 'NONE'
authUsername = '' authUsername = ''
authPassword = '' authPassword = ''
authDirty = false
updateSettings({ serverAuthMode: 'NONE', serverAuthUser: '', serverAuthPass: '' }) updateSettings({ serverAuthMode: 'NONE', serverAuthUser: '', serverAuthPass: '' })
showSaved('auth') showSaved('auth')
} }
@@ -214,23 +210,28 @@
<div class="s-segment"> <div class="s-segment">
{#each [{ value: 'NONE', label: 'None' }, { value: 'BASIC_AUTH', label: 'Basic' }, { value: 'UI_LOGIN', label: 'UI Login' }] as opt} {#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} <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} {/each}
</div> </div>
</div> </div>
{#if authMode !== 'NONE'} {#if authMode !== 'NONE'}
<div class="s-row"> <div class="s-row">
<div class="s-row-info"><span class="s-label">Username</span></div> <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>
<div class="s-row"> <div class="s-row">
<div class="s-row-info"><span class="s-label">Password</span></div> <div class="s-row-info"><span class="s-label">Password</span></div>
<div class="s-field-wrap"> <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> <button class="s-eye-btn" onclick={() => showAuthPass = !showAuthPass} tabindex="-1" aria-label={showAuthPass ? 'Hide password' : 'Show password'}>{@html showAuthPass ? EyeClose : EyeOpen}</button>
</div> </div>
</div> </div>
{/if} {/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'} {#if settingsState.settings.serverAuthMode === 'BASIC_AUTH'}
<div class="s-row"> <div class="s-row">
<span class="s-desc">Images are proxied through Tauri when Basic Auth is active, which reduces loading speed.</span> <span class="s-desc">Images are proxied through Tauri when Basic Auth is active, which reduces loading speed.</span>
@@ -250,8 +251,16 @@
</button> </button>
{/if} {/if}
<button class="s-btn s-btn-accent" onclick={saveAuth} <button class="s-btn s-btn-accent" onclick={saveAuth}
disabled={secLoading || ((authMode === 'BASIC_AUTH' || authMode === 'UI_LOGIN') && (!authUsername.trim() || !authPassword.trim()))}> disabled={secLoading || (authMode !== 'NONE' && (!authUsername.trim() || !authPassword.trim()))}>
{secLoading ? 'Saving…' : secSaved === 'auth' ? 'Saved ✓' : settingsState.settings.serverAuthMode === 'BASIC_AUTH' ? 'Update' : authMode === 'NONE' ? 'Save' : 'Enable'} {#if secLoading}
Saving…
{:else if secSaved === 'auth'}
Saved ✓
{:else if authMode === 'NONE'}
Save
{:else}
{authDirty ? 'Enable' : 'Save'}
{/if}
</button> </button>
</div> </div>
</div> </div>
+38 -53
View File
@@ -1,4 +1,5 @@
const DEFAULT_URL = 'http://127.0.0.1:4567' const DEFAULT_URL = 'http://127.0.0.1:4567'
const SKEW_MS = 60_000 * 2
interface AuthConfig { interface AuthConfig {
baseUrl: string baseUrl: string
@@ -10,33 +11,25 @@ interface AuthConfig {
export interface UiAuthDebugStatus { export interface UiAuthDebugStatus {
mode: 'NONE' | 'BASIC_AUTH' | 'UI_LOGIN' mode: 'NONE' | 'BASIC_AUTH' | 'UI_LOGIN'
hasSession: boolean hasSession: boolean
hasRefreshToken: boolean
accessExpiresAt: number | null accessExpiresAt: number | null
refreshExpiresAt: number | null
accessExpiresInMs: number | null accessExpiresInMs: number | null
refreshExpiresInMs: number | null
shouldRefreshSoon: boolean shouldRefreshSoon: boolean
refreshInFlight: boolean refreshInFlight: boolean
skewMs: number skewMs: number
} }
const SKEW_MS = 60_000 * 2
let config: AuthConfig = { baseUrl: DEFAULT_URL, mode: 'NONE' } let config: AuthConfig = { baseUrl: DEFAULT_URL, mode: 'NONE' }
let accessToken: string | null = null let accessToken: string | null = null
let refreshToken: string | null = null let refreshToken: string | null = null
let accessExpiresAt: number | null = null let accessExpiresAt: number | null = null
let refreshExpiresAt: number | null = null
let refreshInFlight = false let refreshInFlight = false
function parseExpiry(token: string): number | null { function parseExpiry(token: string): number | null {
try { try {
const payload = JSON.parse(atob(token.split('.')[1])) const payload = JSON.parse(atob(token.split('.')[1]))
return typeof payload.exp === 'number' ? payload.exp * 1000 : null return typeof payload.exp === 'number' ? payload.exp * 1000 : null
} catch { } catch { return null }
return null
}
} }
export const authSession = { export const authSession = {
@@ -44,26 +37,19 @@ export const authSession = {
accessToken = null accessToken = null
refreshToken = null refreshToken = null
accessExpiresAt = null accessExpiresAt = null
refreshExpiresAt = null
}, },
} }
export function getUIAccessToken(): string | null { export function getUIAccessToken(): string | null { return accessToken }
return accessToken
}
export function getUiAuthDebugStatus(): UiAuthDebugStatus { export function getUiAuthDebugStatus(): UiAuthDebugStatus {
const now = Date.now() const now = Date.now()
const accessExpiresInMs = accessExpiresAt !== null ? accessExpiresAt - now : null const accessExpiresInMs = accessExpiresAt !== null ? accessExpiresAt - now : null
const refreshExpiresInMs = refreshExpiresAt !== null ? refreshExpiresAt - now : null
return { return {
mode: config.mode, mode: config.mode,
hasSession: accessToken !== null, hasSession: accessToken !== null,
hasRefreshToken: refreshToken !== null,
accessExpiresAt, accessExpiresAt,
refreshExpiresAt,
accessExpiresInMs, accessExpiresInMs,
refreshExpiresInMs,
shouldRefreshSoon: accessExpiresInMs !== null && accessExpiresInMs < SKEW_MS, shouldRefreshSoon: accessExpiresInMs !== null && accessExpiresInMs < SKEW_MS,
refreshInFlight, refreshInFlight,
skewMs: SKEW_MS, skewMs: SKEW_MS,
@@ -77,7 +63,9 @@ export function configureAuth(
pass?: string, pass?: string,
): void { ): void {
config = { baseUrl: baseUrl.replace(/\/$/, ''), mode, user, pass } config = { baseUrl: baseUrl.replace(/\/$/, ''), mode, user, pass }
authSession.clearTokens() accessToken = null
refreshToken = null
accessExpiresAt = null
} }
export function authHeaders(): Record<string, string> { export function authHeaders(): Record<string, string> {
@@ -90,16 +78,18 @@ export function authHeaders(): Record<string, string> {
return {} 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`, { const res = await fetch(`${config.baseUrl}/api/graphql`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json', ...authHeaders() }, headers,
body: JSON.stringify({ query, variables }), body: JSON.stringify({ query, variables }),
}) })
if (!res.ok) throw new Error(`HTTP ${res.status}`) if (!res.ok) throw new Error(`HTTP ${res.status}`)
const json = await res.json() const json = await res.json()
if (json.errors?.length) throw new Error(json.errors[0].message) 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'> { 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`, { const res = await fetch(`${config.baseUrl}/api/graphql`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json', ...authHeaders() }, 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.status === 401 || res.status === 403) return 'auth_required'
if (!res.ok) return 'unreachable' if (!res.ok) return 'unreachable'
@@ -116,17 +106,7 @@ export async function probeServer(): Promise<'ok' | 'auth_required' | 'unreachab
/unauthorized|unauthenticated/i.test(e.message) /unauthorized|unauthenticated/i.test(e.message)
) )
return isAuthError ? 'auth_required' : 'ok' return isAuthError ? 'auth_required' : 'ok'
} catch { } catch { return 'unreachable' }
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')
} }
const LOGIN_MUTATION = ` const LOGIN_MUTATION = `
@@ -145,32 +125,31 @@ const REFRESH_MUTATION = `
} }
` `
export async function loginUI(user: string, pass: string): Promise<void> { export async function loginBasic(user: string, pass: string): Promise<void> {
const data = await gqlRaw(LOGIN_MUTATION, { username: user, password: pass }) as { const prev = { user: config.user, pass: config.pass, mode: config.mode }
login: { accessToken: string; refreshToken: string } 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')
} }
}
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 accessToken = data.login.accessToken
refreshToken = data.login.refreshToken refreshToken = data.login.refreshToken
accessExpiresAt = parseExpiry(accessToken) accessExpiresAt = parseExpiry(accessToken)
refreshExpiresAt = parseExpiry(refreshToken)
config.mode = 'UI_LOGIN' config.mode = 'UI_LOGIN'
config.user = user 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 refreshUiAccessToken(force = false): Promise<string | null> { export async function refreshUiAccessToken(force = false): Promise<string | null> {
if (config.mode !== 'UI_LOGIN') return null if (config.mode !== 'UI_LOGIN') return null
if (!refreshToken) return null if (!refreshToken) return null
@@ -179,8 +158,14 @@ export async function refreshUiAccessToken(force = false): Promise<string | null
if (refreshInFlight) return accessToken if (refreshInFlight) return accessToken
refreshInFlight = true refreshInFlight = true
try { try {
const ok = await refreshAccessToken() const data = await gql<{ refreshToken: { accessToken: string } }>(
return ok ? accessToken : null REFRESH_MUTATION, { refreshToken }
)
accessToken = data.refreshToken.accessToken
accessExpiresAt = parseExpiry(accessToken)
return accessToken
} catch {
return null
} finally { } finally {
refreshInFlight = false refreshInFlight = false
} }
+6 -8
View File
@@ -1,4 +1,4 @@
import { fetch as tauriFetch } from "@tauri-apps/plugin-http"; import { platformService } from "$lib/platform-service";
import { settingsState } from "$lib/state/settings.svelte"; import { settingsState } from "$lib/state/settings.svelte";
import { getUIAccessToken } from "$lib/core/auth"; import { getUIAccessToken } from "$lib/core/auth";
@@ -19,14 +19,14 @@ interface QueueEntry {
const queue: QueueEntry[] = []; const queue: QueueEntry[] = [];
async function getAuthHeaders(): Promise<Record<string, string>> { async function getAuthHeaders(): Promise<Record<string, string>> {
const mode = settingsState.serverAuthMode ?? "NONE"; const mode = settingsState.settings.serverAuthMode ?? "NONE";
if (mode === "UI_LOGIN") { if (mode === "UI_LOGIN") {
const token = await getUIAccessToken(); const token = getUIAccessToken();
return token ? { Authorization: `Bearer ${token}` } : {}; return token ? { Authorization: `Bearer ${token}` } : {};
} }
if (mode === "BASIC_AUTH") { if (mode === "BASIC_AUTH") {
const user = settingsState.serverAuthUser?.trim() ?? ""; const user = settingsState.settings.serverAuthUser?.trim() ?? "";
const pass = settingsState.serverAuthPass?.trim() ?? ""; const pass = settingsState.settings.serverAuthPass?.trim() ?? "";
return user && pass ? { Authorization: `Basic ${btoa(`${user}:${pass}`)}` } : {}; return user && pass ? { Authorization: `Basic ${btoa(`${user}:${pass}`)}` } : {};
} }
return {}; return {};
@@ -34,9 +34,7 @@ async function getAuthHeaders(): Promise<Record<string, string>> {
async function doFetch(url: string): Promise<string> { async function doFetch(url: string): Promise<string> {
const headers = await getAuthHeaders(); const headers = await getAuthHeaders();
const res = await tauriFetch(url, { method: "GET", headers }); const blob = await platformService.fetchImage(url, headers);
if (!res.ok) throw new Error(`${res.status}`);
const blob = await res.blob();
if (clearing) throw new DOMException("Cancelled", "AbortError"); if (clearing) throw new DOMException("Cancelled", "AbortError");
const blobUrl = URL.createObjectURL(blob); const blobUrl = URL.createObjectURL(blob);
cache.set(url, blobUrl); cache.set(url, blobUrl);
@@ -3,6 +3,7 @@ import { getCurrentWindow } from '@tauri-apps/api
import { listen } from '@tauri-apps/api/event' import { listen } from '@tauri-apps/api/event'
import { open } from '@tauri-apps/plugin-dialog' import { open } from '@tauri-apps/plugin-dialog'
import { readFile, writeFile } from '@tauri-apps/plugin-fs' 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 { open as openUrl } from '@tauri-apps/plugin-shell'
import { getVersion } from '@tauri-apps/api/app' import { getVersion } from '@tauri-apps/api/app'
import { connect, disconnect, setActivity, clearActivity } from 'tauri-plugin-discord-rpc-api' 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') 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> { async launchServer(config: ServerLaunchConfig): Promise<void> {
await invoke('spawn_server', { await invoke('spawn_server', {
binary: config.binary ?? '', binary: config.binary ?? '',
+2
View File
@@ -93,6 +93,8 @@ export interface PlatformAdapter {
migrateDownloads(src: string, dst: string): Promise<void> migrateDownloads(src: string, dst: string): Promise<void>
getAutoBackupDir(): Promise<string> getAutoBackupDir(): Promise<string>
fetchImage(url: string, headers: Record<string, string>): Promise<Blob>
launchServer(config: ServerLaunchConfig): Promise<void> launchServer(config: ServerLaunchConfig): Promise<void>
stopServer(): Promise<void> stopServer(): Promise<void>
getServerStatus(): Promise<'running' | 'stopped' | 'error'> 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 migrateDownloads(_src: string, _dst: string): Promise<void> {}
async getAutoBackupDir(): Promise<string> { return '' } 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 launchServer(_config: ServerLaunchConfig): Promise<void> {}
async stopServer(): Promise<void> {} async stopServer(): Promise<void> {}
async getServerStatus(): Promise<'running' | 'stopped' | 'error'> { return 'stopped' } 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), migrateDownloads:(src: string, dst: string) => get().migrateDownloads(src, dst),
getAutoBackupDir:() => get().getAutoBackupDir(), getAutoBackupDir:() => get().getAutoBackupDir(),
fetchImage: (url: string, headers: Record<string, string>) => get().fetchImage(url, headers),
launchServer: (c: ServerLaunchConfig) => get().launchServer(c), launchServer: (c: ServerLaunchConfig) => get().launchServer(c),
stopServer: () => get().stopServer(), stopServer: () => get().stopServer(),
getServerStatus: () => get().getServerStatus(), getServerStatus: () => get().getServerStatus(),
+5 -12
View File
@@ -41,6 +41,7 @@ import {
UPDATE_STOP, UPDATE_STOP,
SET_MANGA_META, SET_MANGA_META,
DELETE_MANGA_META, DELETE_MANGA_META,
CREATE_BACKUP,
FETCH_SOURCE_MANGA, FETCH_SOURCE_MANGA,
LIBRARY_UPDATE_STATUS, LIBRARY_UPDATE_STATUS,
MANGAS_BY_GENRE, MANGAS_BY_GENRE,
@@ -114,8 +115,8 @@ import {
SET_FLARE_SOLVERR, SET_FLARE_SOLVERR,
RESTORE_BACKUP, RESTORE_BACKUP,
VALIDATE_BACKUP, VALIDATE_BACKUP,
CREATE_BACKUP,
} from './meta' } from './meta'
import { authHeaders } from '$lib/core/auth'
import { import {
type GQLResponse, type GQLResponse,
mapManga, mapManga,
@@ -142,14 +143,9 @@ function mapDownloadStatus(raw: { state: string; queue: RawQueueItem[] }): Downl
export class SuwayomiAdapter implements ServerAdapter { export class SuwayomiAdapter implements ServerAdapter {
private baseUrl = 'http://127.0.0.1:4567' private baseUrl = 'http://127.0.0.1:4567'
private authHeader: string | null = null
async connect(config: ServerConfig): Promise<void> { async connect(config: ServerConfig): Promise<void> {
this.baseUrl = config.baseUrl.replace(/\/$/, '') 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)) initPageCache(this.gql.bind(this), this.getServerUrl.bind(this))
} }
@@ -161,7 +157,7 @@ export class SuwayomiAdapter implements ServerAdapter {
try { try {
const res = await fetch(`${this.baseUrl}/api/graphql`, { const res = await fetch(`${this.baseUrl}/api/graphql`, {
method: 'POST', method: 'POST',
headers: this.headers(), headers: { 'Content-Type': 'application/json', ...authHeaders() },
body: JSON.stringify({ query: '{ aboutServer { name } }' }), body: JSON.stringify({ query: '{ aboutServer { name } }' }),
}) })
return res.ok ? 'connected' : 'error' return res.ok ? 'connected' : 'error'
@@ -171,9 +167,7 @@ export class SuwayomiAdapter implements ServerAdapter {
} }
private headers(): Record<string, string> { private headers(): Record<string, string> {
const h: Record<string, string> = { 'Content-Type': 'application/json' } return { 'Content-Type': 'application/json', ...authHeaders() }
if (this.authHeader) h['Authorization'] = this.authHeader
return h
} }
private async gql<T>( private async gql<T>(
@@ -642,8 +636,7 @@ export class SuwayomiAdapter implements ServerAdapter {
form.append('operations', JSON.stringify({ query, variables: { backup: null } })) form.append('operations', JSON.stringify({ query, variables: { backup: null } }))
form.append('map', JSON.stringify({ '0': ['variables.backup'] })) form.append('map', JSON.stringify({ '0': ['variables.backup'] }))
form.append('0', file, file.name) form.append('0', file, file.name)
const headers: Record<string, string> = { Accept: 'application/json' } const headers: Record<string, string> = { Accept: 'application/json', ...authHeaders() }
if (this.authHeader) headers['Authorization'] = this.authHeader
return fetch(`${this.baseUrl}/api/graphql`, { method: 'POST', headers, body: form }) 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(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 }) .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' export type PlatformFeature =
import type { Manga, Chapter, Extension, Source, Tracker, TrackRecord, Category } from '$lib/types' | 'server-management'
| 'biometric-auth'
| 'native-window'
| 'filesystem'
| 'app-updates'
| 'discord-rpc'
export interface ServerConfig { export type Platform = 'tauri' | 'capacitor' | 'web'
baseUrl: string
credentials?: { username: string; password: string } export interface ServerLaunchConfig {
binary?: string
binaryArgs?: string
webUiEnabled?: boolean
} }
export type ServerStatus = 'connected' | 'disconnected' | 'error' export interface DiscordAssets {
largeImage?: string
export interface MangaFilters { largeText?: string
inLibrary?: boolean smallImage?: string
status?: MangaStatus smallText?: string
tags?: string[]
unread?: boolean
sourceId?: string
} }
export type MangaStatus = export interface DiscordButton {
| 'ONGOING' label: string
| 'COMPLETED'
| 'LICENSED'
| 'PUBLISHING_FINISHED'
| 'CANCELLED'
| 'ON_HIATUS'
export interface PaginatedResult<T> {
items: T[]
hasNextPage: boolean
total?: number
}
export interface MangaMeta {
customTitle?: string
customCover?: string
notes?: string
[key: string]: unknown
}
export interface Page {
index: number
url: string url: string
imageData?: string
} }
export interface AboutServer { export interface DiscordPresence {
name: string state?: string
details?: string
assets?: DiscordAssets
buttons?: DiscordButton[]
timestamps?: { start?: number; end?: number }
}
export interface AppUpdateInfo {
version: string version: string
buildType: string url: string
buildTime: number notes: string
github: string
discord: string
} }
export interface AboutWebUI { export interface StorageInfo {
channel: string manga_bytes: number
tag: string total_bytes: number
updateTimestamp: number free_bytes: number
path: string
} }
export interface DownloadItem { export interface MigrateProgress {
chapterId: string done: number
mangaId: string total: number
chapterName: string current: string
mangaTitle: string
thumbnailUrl?: string
progress: number
state: 'queued' | 'downloading' | 'finished' | 'error'
} }
export interface UpdateResult { export interface UpdateProgress {
mangaId: string downloaded: number
newChapters: number total: number | null
} }
export interface LibraryUpdateProgress { export interface ReleaseInfo {
isRunning: boolean tag_name: string
finishedJobs: number name: string
totalJobs: number body: string
published_at: string
html_url: string
} }
export interface ServerSecurity { export interface PlatformAdapter {
authMode: string readonly platform: Platform
authUsername: string
socksProxyEnabled: boolean init(): Promise<void>
socksProxyHost: string destroy(): Promise<void>
socksProxyPort: string isSupported(feature: PlatformFeature): boolean
socksProxyVersion: number
socksProxyUsername: string getAppDir(): Promise<string>
flareSolverrEnabled: boolean
flareSolverrUrl: string loadStore(key: string): Promise<unknown>
flareSolverrTimeout: number saveStore(key: string, value: unknown): Promise<void>
flareSolverrSessionName: string
flareSolverrSessionTtl: number storeCredential(key: string, value: string): Promise<void>
flareSolverrAsResponseFallback: boolean getCredential(key: string): Promise<string | null>
} authenticateBiometric(): Promise<boolean>
export interface SetServerAuthInput { readFile(path: string): Promise<Uint8Array>
authMode: string writeFile(path: string, data: Uint8Array): Promise<void>
authUsername: string pickFolder(): Promise<string | null>
authPassword: string checkPathExists(path: string): Promise<boolean>
} createDirectory(path: string): Promise<void>
openPath(path: string): Promise<void>
export interface SetSocksProxyInput { getDefaultDownloadsPath(): Promise<string>
socksProxyEnabled: boolean getStorageInfo(downloadsPath: string): Promise<StorageInfo>
socksProxyHost: string migrateDownloads(src: string, dst: string): Promise<void>
socksProxyPort: string getAutoBackupDir(): Promise<string>
socksProxyVersion: number
socksProxyUsername: string fetchImage(url: string, headers: Record<string, string>): Promise<Blob>
socksProxyPassword: string
} launchServer(config: ServerLaunchConfig): Promise<void>
stopServer(): Promise<void>
export interface SetFlareSolverrInput { getServerStatus(): Promise<'running' | 'stopped' | 'error'>
flareSolverrEnabled: boolean
flareSolverrUrl: string setTitle(title: string): Promise<void>
flareSolverrTimeout: number minimize(): Promise<void>
flareSolverrSessionName: string maximize(): Promise<void>
flareSolverrSessionTtl: number close(): Promise<void>
flareSolverrAsResponseFallback: boolean toggleFullscreen(): Promise<void>
}
setDiscordPresence(presence: DiscordPresence): Promise<void>
export interface TrackRecordPatch { clearDiscordPresence(): Promise<void>
status?: number
score?: number getVersion(): Promise<string>
lastChapterRead?: number openExternal(url: string): Promise<void>
startDate?: string checkForAppUpdate(): Promise<AppUpdateInfo | null>
finishDate?: string installAppUpdate(tag: string): Promise<void>
private?: boolean restartApp(): Promise<void>
} exitApp(): Promise<void>
listReleases(): Promise<ReleaseInfo[]>
export interface RestoreStatus {
mangaProgress: number clearMokuCache(): Promise<void>
state: string clearSuwayomiCache(): Promise<void>
totalManga: number resetSuwayomiData(): Promise<void>
}
onUpdateProgress(cb: (p: UpdateProgress) => void): Promise<() => void>
export interface ValidateBackupResult { onUpdateLaunching(cb: () => void): Promise<() => void>
missingSources: { id: string; name: string }[] onMigrateProgress(cb: (p: MigrateProgress) => void): Promise<() => void>
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
} }
+8 -3
View File
@@ -1,7 +1,7 @@
import { detectAdapter } from '$lib/platform-adapters' import { detectAdapter } from '$lib/platform-adapters'
import { initPlatformService } from '$lib/platform-service' import { initPlatformService } from '$lib/platform-service'
import { platformService } 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 { appState } from '$lib/state/app.svelte'
import { settingsState } from '$lib/state/settings.svelte' import { settingsState } from '$lib/state/settings.svelte'
@@ -57,6 +57,7 @@ function handleAuthRequired(
) { ) {
if (gen !== probeGeneration) return if (gen !== probeGeneration) return
boot.failed = false boot.failed = false
appState.authMode = authMode
if (authMode === 'BASIC_AUTH' && user && pass) { if (authMode === 'BASIC_AUTH' && user && pass) {
loginBasic(user, pass) loginBasic(user, pass)
@@ -75,18 +76,22 @@ function handleAuthRequired(
appState.status = 'auth' appState.status = 'auth'
} }
export function startProbe( export async function startProbe(
authMode: 'NONE' | 'BASIC_AUTH' | 'UI_LOGIN' = 'NONE', authMode: 'NONE' | 'BASIC_AUTH' | 'UI_LOGIN' = 'NONE',
user = '', user = '',
pass = '', pass = '',
initialDelay = 100, initialDelay = 100,
) { ): Promise<void> {
const gen = ++probeGeneration const gen = ++probeGeneration
boot.failed = false boot.failed = false
boot.loginRequired = false boot.loginRequired = false
boot.skipped = false boot.skipped = false
boot.serverProbeOk = false boot.serverProbeOk = false
appState.status = 'booting' 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') { if (appState.platform === 'web') {
boot.failed = true boot.failed = true
+19 -5
View File
@@ -34,18 +34,31 @@
const isTauri = typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window const isTauri = typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window
let splashVisible = $state(true) let _splashDismissed = $state(false)
let bypassed = $state(false) let bypassed = $state(false)
let themeEditorOpen = $state(false) let themeEditorOpen = $state(false)
let themeEditorId = $state<string | null>(null) 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 ringFull = $derived(appState.status === 'ready')
const showApp = $derived( const showApp = $derived(
!splashVisible && (
appState.status === 'ready' || appState.status === 'ready' ||
appState.status === 'auth' ||
bypassed 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 isReaderRoute = $derived($page.url.pathname.startsWith('/reader'))
const readerContainerized = $derived(settingsState.settings.readerContainerized ?? false) const readerContainerized = $derived(settingsState.settings.readerContainerized ?? false)
@@ -127,9 +140,10 @@
) )
}) })
function onSplashReady() { splashVisible = false } $effect(() => {
function onSplashUnlock() { appState.status = 'ready'; splashVisible = false } if (appState.status === 'booting') _splashDismissed = false
function onSplashBypass() { bypassed = true; splashVisible = false } })
function onIdleDismiss() { appState.idleSplash = false } function onIdleDismiss() { appState.idleSplash = false }
function onSplashRetry() { function onSplashRetry() {