mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 01:09:56 -05:00
Fix: WebUI Auth & Tauri Auth
This commit is contained in:
@@ -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: authMode, serverAuthUser: authUsername })
|
||||||
updateSettings({ serverAuthMode: serverMode, 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 })
|
||||||
|
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 = ''
|
authPassword = ''
|
||||||
|
authDirty = false
|
||||||
showSaved('auth')
|
showSaved('auth')
|
||||||
|
retryBoot(authMode as any, newUser, newPass)
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
const msg = e?.message ?? 'Failed to save authentication settings'
|
secError = 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
|
|
||||||
} 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>
|
||||||
|
|||||||
+59
-74
@@ -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,74 +11,61 @@ 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 = {
|
||||||
clearTokens() {
|
clearTokens() {
|
||||||
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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function configureAuth(
|
export function configureAuth(
|
||||||
baseUrl: string,
|
baseUrl: string,
|
||||||
mode: 'NONE' | 'BASIC_AUTH' | 'UI_LOGIN',
|
mode: 'NONE' | 'BASIC_AUTH' | 'UI_LOGIN',
|
||||||
user?: string,
|
user?: string,
|
||||||
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,30 +125,29 @@ 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')
|
||||||
}
|
}
|
||||||
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> {
|
export async function loginUI(user: string, pass: string): Promise<void> {
|
||||||
if (!refreshToken) return false
|
const data = await gql<{ login: { accessToken: string; refreshToken: string } }>(
|
||||||
try {
|
LOGIN_MUTATION, { username: user, password: pass }, true
|
||||||
const data = await gqlRaw(REFRESH_MUTATION, { refreshToken }) as {
|
)
|
||||||
refreshToken: { accessToken: string }
|
accessToken = data.login.accessToken
|
||||||
}
|
refreshToken = data.login.refreshToken
|
||||||
accessToken = data.refreshToken.accessToken
|
accessExpiresAt = parseExpiry(accessToken)
|
||||||
accessExpiresAt = parseExpiry(accessToken)
|
config.mode = 'UI_LOGIN'
|
||||||
return true
|
config.user = user
|
||||||
} catch {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function refreshUiAccessToken(force = false): Promise<string | null> {
|
export async function refreshUiAccessToken(force = false): Promise<string | null> {
|
||||||
@@ -179,9 +158,15 @@ 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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Vendored
+8
-10
@@ -1,6 +1,6 @@
|
|||||||
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";
|
||||||
|
|
||||||
const cache = new Map<string, string>();
|
const cache = new Map<string, string>();
|
||||||
const inflight = new Map<string, Promise<string>>();
|
const inflight = new Map<string, Promise<string>>();
|
||||||
@@ -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 ?? '',
|
||||||
|
|||||||
@@ -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'>
|
||||||
|
|||||||
@@ -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' }
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -141,15 +142,10 @@ 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))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,9 +156,9 @@ export class SuwayomiAdapter implements ServerAdapter {
|
|||||||
async getStatus(): Promise<ServerStatus> {
|
async getStatus(): Promise<ServerStatus> {
|
||||||
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'
|
||||||
} catch {
|
} catch {
|
||||||
@@ -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>(
|
||||||
@@ -182,9 +176,9 @@ export class SuwayomiAdapter implements ServerAdapter {
|
|||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const res = await fetch(`${this.baseUrl}/api/graphql`, {
|
const res = await fetch(`${this.baseUrl}/api/graphql`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: this.headers(),
|
headers: this.headers(),
|
||||||
body: JSON.stringify({ query, variables }),
|
body: JSON.stringify({ query, variables }),
|
||||||
signal,
|
signal,
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error(`Suwayomi HTTP ${res.status}`)
|
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('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 })
|
||||||
|
|||||||
@@ -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'
|
url: string
|
||||||
| 'LICENSED'
|
|
||||||
| 'PUBLISHING_FINISHED'
|
|
||||||
| 'CANCELLED'
|
|
||||||
| 'ON_HIATUS'
|
|
||||||
|
|
||||||
export interface PaginatedResult<T> {
|
|
||||||
items: T[]
|
|
||||||
hasNextPage: boolean
|
|
||||||
total?: number
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MangaMeta {
|
export interface DiscordPresence {
|
||||||
customTitle?: string
|
state?: string
|
||||||
customCover?: string
|
details?: string
|
||||||
notes?: string
|
assets?: DiscordAssets
|
||||||
[key: string]: unknown
|
buttons?: DiscordButton[]
|
||||||
|
timestamps?: { start?: number; end?: number }
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Page {
|
export interface AppUpdateInfo {
|
||||||
index: number
|
version: string
|
||||||
url: string
|
url: string
|
||||||
imageData?: string
|
notes: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AboutServer {
|
export interface StorageInfo {
|
||||||
name: string
|
manga_bytes: number
|
||||||
version: string
|
total_bytes: number
|
||||||
buildType: string
|
free_bytes: number
|
||||||
buildTime: number
|
path: string
|
||||||
github: string
|
|
||||||
discord: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AboutWebUI {
|
export interface MigrateProgress {
|
||||||
channel: string
|
done: number
|
||||||
tag: string
|
total: number
|
||||||
updateTimestamp: number
|
current: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DownloadItem {
|
export interface UpdateProgress {
|
||||||
chapterId: string
|
downloaded: number
|
||||||
mangaId: string
|
total: number | null
|
||||||
chapterName: string
|
|
||||||
mangaTitle: string
|
|
||||||
thumbnailUrl?: string
|
|
||||||
progress: number
|
|
||||||
state: 'queued' | 'downloading' | 'finished' | 'error'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateResult {
|
export interface ReleaseInfo {
|
||||||
mangaId: string
|
tag_name: string
|
||||||
newChapters: number
|
name: string
|
||||||
|
body: string
|
||||||
|
published_at: string
|
||||||
|
html_url: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LibraryUpdateProgress {
|
export interface PlatformAdapter {
|
||||||
isRunning: boolean
|
readonly platform: Platform
|
||||||
finishedJobs: number
|
|
||||||
totalJobs: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ServerSecurity {
|
init(): Promise<void>
|
||||||
authMode: string
|
destroy(): Promise<void>
|
||||||
authUsername: string
|
isSupported(feature: PlatformFeature): boolean
|
||||||
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 {
|
getAppDir(): Promise<string>
|
||||||
authMode: string
|
|
||||||
authUsername: string
|
|
||||||
authPassword: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SetSocksProxyInput {
|
loadStore(key: string): Promise<unknown>
|
||||||
socksProxyEnabled: boolean
|
saveStore(key: string, value: unknown): Promise<void>
|
||||||
socksProxyHost: string
|
|
||||||
socksProxyPort: string
|
|
||||||
socksProxyVersion: number
|
|
||||||
socksProxyUsername: string
|
|
||||||
socksProxyPassword: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SetFlareSolverrInput {
|
storeCredential(key: string, value: string): Promise<void>
|
||||||
flareSolverrEnabled: boolean
|
getCredential(key: string): Promise<string | null>
|
||||||
flareSolverrUrl: string
|
authenticateBiometric(): Promise<boolean>
|
||||||
flareSolverrTimeout: number
|
|
||||||
flareSolverrSessionName: string
|
|
||||||
flareSolverrSessionTtl: number
|
|
||||||
flareSolverrAsResponseFallback: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TrackRecordPatch {
|
readFile(path: string): Promise<Uint8Array>
|
||||||
status?: number
|
writeFile(path: string, data: Uint8Array): Promise<void>
|
||||||
score?: number
|
pickFolder(): Promise<string | null>
|
||||||
lastChapterRead?: number
|
checkPathExists(path: string): Promise<boolean>
|
||||||
startDate?: string
|
createDirectory(path: string): Promise<void>
|
||||||
finishDate?: string
|
openPath(path: string): Promise<void>
|
||||||
private?: boolean
|
getDefaultDownloadsPath(): Promise<string>
|
||||||
}
|
getStorageInfo(downloadsPath: string): Promise<StorageInfo>
|
||||||
|
migrateDownloads(src: string, dst: string): Promise<void>
|
||||||
|
getAutoBackupDir(): Promise<string>
|
||||||
|
|
||||||
export interface RestoreStatus {
|
fetchImage(url: string, headers: Record<string, string>): Promise<Blob>
|
||||||
mangaProgress: number
|
|
||||||
state: string
|
|
||||||
totalManga: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ValidateBackupResult {
|
launchServer(config: ServerLaunchConfig): Promise<void>
|
||||||
missingSources: { id: string; name: string }[]
|
stopServer(): Promise<void>
|
||||||
missingTrackers: { name: string }[]
|
getServerStatus(): Promise<'running' | 'stopped' | 'error'>
|
||||||
}
|
|
||||||
|
|
||||||
export interface ServerAdapter {
|
setTitle(title: string): Promise<void>
|
||||||
connect(config: ServerConfig): Promise<void>
|
minimize(): Promise<void>
|
||||||
getStatus(): Promise<ServerStatus>
|
maximize(): Promise<void>
|
||||||
getServerUrl(): string
|
close(): Promise<void>
|
||||||
|
toggleFullscreen(): Promise<void>
|
||||||
|
|
||||||
getManga(id: string, signal?: AbortSignal): Promise<Manga>
|
setDiscordPresence(presence: DiscordPresence): Promise<void>
|
||||||
getMangaList(filters: MangaFilters): Promise<PaginatedResult<Manga>>
|
clearDiscordPresence(): Promise<void>
|
||||||
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[]>
|
getVersion(): Promise<string>
|
||||||
getChapter(id: string): Promise<Chapter>
|
openExternal(url: string): Promise<void>
|
||||||
getChapterPages(id: string, signal?: AbortSignal): Promise<Page[]>
|
checkForAppUpdate(): Promise<AppUpdateInfo | null>
|
||||||
fetchChapters(mangaId: string): Promise<Chapter[]>
|
installAppUpdate(tag: string): Promise<void>
|
||||||
getRecentlyUpdated(): Promise<Chapter[]>
|
restartApp(): Promise<void>
|
||||||
markChapterRead(id: string, read: boolean): Promise<void>
|
exitApp(): Promise<void>
|
||||||
markChaptersRead(ids: string[], read: boolean): Promise<void>
|
listReleases(): Promise<ReleaseInfo[]>
|
||||||
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>
|
clearMokuCache(): Promise<void>
|
||||||
getAboutWebUI(): Promise<AboutWebUI>
|
clearSuwayomiCache(): Promise<void>
|
||||||
|
resetSuwayomiData(): Promise<void>
|
||||||
|
|
||||||
getDownloads(): Promise<DownloadItem[]>
|
onUpdateProgress(cb: (p: UpdateProgress) => void): Promise<() => void>
|
||||||
getDownloadStatus(): Promise<DownloadStatus>
|
onUpdateLaunching(cb: () => void): Promise<() => void>
|
||||||
enqueueDownload(chapterId: string): Promise<void>
|
onMigrateProgress(cb: (p: MigrateProgress) => void): 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
|
|
||||||
}
|
}
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
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'
|
||||||
|
|
||||||
const MAX_ATTEMPTS = 40
|
const MAX_ATTEMPTS = 40
|
||||||
const BG_MAX_ATTEMPTS = 120
|
const BG_MAX_ATTEMPTS = 120
|
||||||
@@ -42,9 +42,9 @@ function pinLockEnabled(): boolean {
|
|||||||
|
|
||||||
function handleProbeSuccess(gen: number) {
|
function handleProbeSuccess(gen: number) {
|
||||||
if (gen !== probeGeneration) return
|
if (gen !== probeGeneration) return
|
||||||
boot.failed = false
|
boot.failed = false
|
||||||
boot.skipped = false
|
boot.skipped = false
|
||||||
boot.serverProbeOk = true
|
boot.serverProbeOk = true
|
||||||
appState.authenticated = true
|
appState.authenticated = true
|
||||||
appState.status = pinLockEnabled() ? 'locked' : 'ready'
|
appState.status = pinLockEnabled() ? 'locked' : 'ready'
|
||||||
}
|
}
|
||||||
@@ -56,7 +56,8 @@ function handleAuthRequired(
|
|||||||
pass: string,
|
pass: string,
|
||||||
) {
|
) {
|
||||||
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,21 +76,25 @@ 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
|
||||||
appState.status = 'error'
|
appState.status = 'error'
|
||||||
startBackgroundProbe(gen, authMode, user, pass)
|
startBackgroundProbe(gen, authMode, user, pass)
|
||||||
return
|
return
|
||||||
|
|||||||
+25
-11
@@ -34,19 +34,32 @@
|
|||||||
|
|
||||||
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(
|
||||||
appState.status === 'ready' ||
|
!splashVisible && (
|
||||||
appState.status === 'auth' ||
|
appState.status === 'ready' ||
|
||||||
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)
|
||||||
const strippedLayout = $derived(isReaderRoute && !readerContainerized)
|
const strippedLayout = $derived(isReaderRoute && !readerContainerized)
|
||||||
@@ -127,10 +140,11 @@
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
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() {
|
||||||
import('$lib/state/boot.svelte').then(({ retryBoot }) => {
|
import('$lib/state/boot.svelte').then(({ retryBoot }) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user