Feat: Lock-Feature & Server-Authentication + Experimentals

This commit is contained in:
Youwes09
2026-03-26 23:21:39 -05:00
parent 2c93d8743d
commit ac6b70fb32
12 changed files with 816 additions and 69 deletions
+2 -2
View File
@@ -60,8 +60,8 @@
}
function resetIdle() {
if (idle) return;
if (idleTimer) clearTimeout(idleTimer);
if (idle) return; // don't re-arm while PIN screen is showing
const ms = (store.settings.idleTimeoutMin ?? 5) * 60 * 1000;
if (ms === 0) return;
idleTimer = setTimeout(() => idle = true, ms);
@@ -225,7 +225,7 @@
<div class="root">
{#if idle && !store.activeChapter}
<SplashScreen mode="idle" showCards={store.settings.splashCards ?? true}
onDismiss={() => setTimeout(() => idle = false, 340)} />
onDismiss={() => { idle = false; resetIdle(); }} />
{/if}
{#if !store.activeChapter && !store.isFullscreen}<TitleBar />{/if}
<div class="content">
+149 -23
View File
@@ -19,6 +19,36 @@
let { mode = "loading", ringFull = false, failed = false, notConfigured = false,
showCards = true, showFps = false, onReady, onRetry, onDismiss }: Props = $props();
const lockEnabled = $derived(
store.settings.appLockEnabled && (store.settings.appLockPin?.length ?? 0) >= 4
);
let pinEntry = $state("");
let pinShake = $state(false);
let pinUnlocked = $state(false);
let pinVisible = $state(false); // delayed so the pin block fades in after the ring completes
function submitPin() {
if (pinEntry === store.settings.appLockPin) {
pinUnlocked = true;
pinEntry = "";
if (mode === "idle") triggerExit(onDismiss);
} else {
pinShake = true;
pinEntry = "";
setTimeout(() => pinShake = false, 500);
}
}
function onPinKey(e: KeyboardEvent) {
if (e.key === "Enter") { submitPin(); return; }
if (e.key === "Backspace") { pinEntry = pinEntry.slice(0, -1); return; }
if (/^\d$/.test(e.key)) {
pinEntry = (pinEntry + e.key).slice(0, 8);
if (pinEntry.length >= (store.settings.appLockPin?.length ?? 4)) submitPin();
}
}
const EXIT_MS = 320;
// Server typically takes 8-20s to boot. We animate the ring through three
// phases so it always feels like something is happening:
@@ -81,7 +111,12 @@
if (ringFull) {
cancelAnimationFrame(animFrame);
ringProg = 1;
setTimeout(() => triggerExit(onReady), 650);
if (lockEnabled && !pinUnlocked) {
// Short pause after ring completes, then fade the PIN block in
setTimeout(() => { pinVisible = true; }, 400);
} else {
setTimeout(() => triggerExit(onReady), 650);
}
}
});
@@ -91,6 +126,9 @@
onMount(() => {
if (mode === "idle" && onDismiss) {
if (lockEnabled) {
return () => clearInterval(dotsInterval);
}
const handler = () => triggerExit(onDismiss);
const t = setTimeout(() => {
window.addEventListener("keydown", handler, { once: true });
@@ -271,6 +309,24 @@
return () => { cancelAnimationFrame(raf); ro.disconnect(); };
}
// Attach PIN keydown to the window so it fires regardless of which element has
// focus — the pin-block div is not natively focusable and would silently drop
// key events otherwise.
$effect(() => {
const needsPin =
(mode === "idle" && lockEnabled) ||
(mode === "loading" && lockEnabled && ringFull && !pinUnlocked);
if (!needsPin) return;
window.addEventListener("keydown", onPinKey);
return () => window.removeEventListener("keydown", onPinKey);
});
$effect(() => {
if (pinUnlocked && mode !== "idle") {
triggerExit(onReady);
}
});
const ringR = $derived(70);
const ringPad = $derived(12);
const ringSize = $derived((ringR + ringPad) * 2);
@@ -281,7 +337,7 @@
const ringLeft = $derived(-((ringSize - 140) / 2));
</script>
<div class="splash" class:exiting style="cursor: {mode === 'idle' ? 'pointer' : 'default'}">
<div class="splash" class:exiting style="cursor: {mode === 'idle' && !lockEnabled ? 'pointer' : 'default'}">
{#if showCards}
<canvas style="position:absolute;inset:0;pointer-events:none;width:100%;height:100%" use:mountCanvas></canvas>
{#if showFps}
@@ -289,7 +345,23 @@
{/if}
{/if}
{#if mode === "idle"}
{#if mode === "idle" && lockEnabled}
<div style="z-index:1;display:flex;flex-direction:column;align-items:center;gap:var(--sp-6)">
<div style="position:relative;width:96px;height:96px">
<div class="logo-glow"></div>
<img src={logoUrl} alt="Moku" class="logo-breathe" style="width:96px;height:96px;border-radius:22px;display:block;position:relative" />
</div>
<div class="pin-block">
<div class="pin-dots" class:pin-shake={pinShake}>
{#each Array(store.settings.appLockPin?.length ?? 4) as _, i}
<div class="pin-dot" class:pin-dot-filled={i < pinEntry.length}></div>
{/each}
</div>
<button class="pin-submit-btn" onclick={submitPin} tabindex="-1" aria-label="Submit PIN">Unlock</button>
</div>
</div>
{:else if mode === "idle"}
<div style="z-index:1;display:flex;flex-direction:column;align-items:center">
<div style="position:relative;width:128px;height:128px;margin-bottom:32px">
<div class="logo-glow"></div>
@@ -297,38 +369,64 @@
</div>
<p class="hint">press any key to continue</p>
</div>
{:else}
<!-- Logo + ring — always present, ring fades out when pin takes over -->
<div style="position:relative;width:140px;height:140px;margin-bottom:20px;z-index:1">
{#if !failed && !notConfigured}
<svg width={ringSize} height={ringSize} style="position:absolute;pointer-events:none;top:{ringTop}px;left:{ringLeft}px">
<svg width={ringSize} height={ringSize}
class="loading-ring"
class:ring-hide={lockEnabled && pinVisible}
style="position:absolute;pointer-events:none;top:{ringTop}px;left:{ringLeft}px">
<circle cx={ringC} cy={ringC} r={ringR} fill="none" stroke="var(--border-base)" stroke-width="2" />
<circle cx={ringC} cy={ringC} r={ringR} fill="none" stroke="var(--accent)" stroke-width="2" stroke-linecap="round" stroke-dasharray="{ringArc} {ringCirc}" transform="rotate(-90 {ringC} {ringC})" style="transition: stroke-dasharray 0.4s cubic-bezier(0.4,0,0.2,1)" />
<circle cx={ringC} cy={ringC} r={ringR} fill="none" stroke="var(--accent)" stroke-width="2"
stroke-linecap="round"
stroke-dasharray="{ringArc} {ringCirc}"
transform="rotate(-90 {ringC} {ringC})"
style="transition: stroke-dasharray 0.4s cubic-bezier(0.4,0,0.2,1)" />
</svg>
{/if}
<img src={logoUrl} alt="Moku" style="width:140px;height:140px;border-radius:32px;display:block" />
</div>
<p class="title-label">moku</p>
<div style="z-index:1;display:flex;flex-direction:column;align-items:center;gap:8px">
{#if notConfigured}
<div class="error-box">
<p class="error-title">Server not configured</p>
<p class="error-body">Set the server path in Settings, then retry</p>
<div style="display:flex;gap:8px;margin-top:8px">
<button class="retry-btn" onclick={() => { store.settingsOpen = true; }}>Settings</button>
<button class="retry-btn" onclick={onRetry}>Retry</button>
<!-- Bottom area: status text → fades out, pin dots → fades in. Same space, no DOM swap. -->
<div class="bottom-area" style="z-index:1">
<!-- Status / error — fades out once pin is visible -->
<div class="status-slot" class:status-slot-hide={lockEnabled && pinVisible}>
{#if notConfigured}
<div class="error-box">
<p class="error-title">Server not configured</p>
<p class="error-body">Set the server path in Settings, then retry</p>
<div style="display:flex;gap:8px;margin-top:8px">
<button class="retry-btn" onclick={() => { store.settingsOpen = true; }}>Settings</button>
<button class="retry-btn" onclick={onRetry}>Retry</button>
</div>
</div>
{:else if failed}
<div class="error-box error-box--danger">
<p class="error-title" style="color:var(--color-error)">Could not reach Suwayomi</p>
<p class="error-body">Make sure tachidesk-server is on your PATH</p>
<button class="retry-btn" style="margin-top:8px" onclick={onRetry}>Retry</button>
</div>
{:else}
<p class="status-text">{ringFull ? "" : `Initializing server${dots}`}</p>
{/if}
</div>
<!-- PIN dots — fades in after ring completes, same position as status text -->
{#if lockEnabled}
<div class="pin-slot" class:pin-slot-visible={pinVisible}>
<div class="pin-dots" class:pin-shake={pinShake}>
{#each Array(store.settings.appLockPin?.length ?? 4) as _, i}
<div class="pin-dot" class:pin-dot-filled={i < pinEntry.length}></div>
{/each}
</div>
<button class="pin-submit-btn" onclick={submitPin} tabindex="-1" aria-label="Submit PIN">Unlock</button>
</div>
{:else if failed}
<div class="error-box error-box--danger">
<p class="error-title" style="color:var(--color-error)">Could not reach Suwayomi</p>
<p class="error-body">Make sure tachidesk-server is on your PATH</p>
<button class="retry-btn" style="margin-top:8px" onclick={onRetry}>Retry</button>
</div>
{:else}
<p style="font-family:var(--font-ui);font-size:10px;color:var(--text-faint);letter-spacing:0.12em;margin:0;min-width:160px;text-align:center">
{ringFull ? "Ready" : `Initializing server${dots}`}
</p>
{/if}
</div>
{/if}
</div>
@@ -350,4 +448,32 @@
.error-box--danger { border-color: rgba(220,50,50,0.5); }
.error-title { font-family: var(--font-ui); font-size: 11px; font-weight: 500; color: var(--text-muted); letter-spacing: 0.1em; margin: 0; }
.error-body { font-family: var(--font-ui); font-size: 10px; color: var(--text-faint); letter-spacing: 0.05em; margin: 0; line-height: 1.6; }
/* ── Loading → PIN unified bottom area ───────────────────────────────────── */
/* Fixed-height container so logo/title never move during the swap */
.bottom-area { display: flex; align-items: center; justify-content: center; height: 48px; position: relative; }
/* Status text slot */
.status-slot { display: flex; align-items: center; justify-content: center; transition: opacity 0.35s ease; position: absolute; }
.status-slot-hide { opacity: 0; pointer-events: none; }
.status-text { font-family: var(--font-ui); font-size: 10px; color: var(--text-faint); letter-spacing: 0.12em; margin: 0; min-width: 160px; text-align: center; }
/* Ring fades out as PIN takes over */
.loading-ring { transition: opacity 0.5s ease; }
.ring-hide { opacity: 0; }
/* PIN dots slot — starts invisible, fades in */
.pin-slot { display: flex; flex-direction: column; align-items: center; gap: var(--sp-3); position: absolute; opacity: 0; transform: translateY(4px); transition: opacity 0.4s ease, transform 0.4s ease; pointer-events: none; }
.pin-slot-visible { opacity: 1; transform: translateY(0); pointer-events: auto; }
/* PIN dots shared between loading and idle modes */
.pin-block { display: flex; flex-direction: column; align-items: center; gap: var(--sp-3); position: relative; }
.pin-dots { display: flex; gap: 12px; align-items: center; }
.pin-dot { width: 10px; height: 10px; border-radius: 50%; border: 1px solid var(--border-strong); background: transparent; transition: background 0.12s, border-color 0.12s; }
.pin-dot-filled { background: var(--accent); border-color: var(--accent); }
@keyframes pinShake { 0%,100% { transform:translateX(0) } 20%,60% { transform:translateX(-6px) } 40%,80% { transform:translateX(6px) } }
.pin-shake { animation: pinShake 0.42s ease; }
/* Visually hidden submit button — tappable, invisible */
.pin-submit-btn { opacity: 0; width: 1px; height: 1px; overflow: hidden; padding: 0; border: none; background: none; cursor: pointer; pointer-events: auto; position: absolute; }
</style>
+428 -13
View File
@@ -1,12 +1,12 @@
<script lang="ts">
import { tick } from "svelte";
import { X, Book, Image, Sliders, Info, Keyboard, Gear, HardDrives, FolderSimple, Plus, Pencil, Trash, Wrench, PaintBrush, ListChecks } from "phosphor-svelte";
import { X, Book, Image, Sliders, Info, Keyboard, Gear, HardDrives, FolderSimple, Plus, Pencil, Trash, Wrench, PaintBrush, ListChecks, Lock } from "phosphor-svelte";
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { getVersion } from "@tauri-apps/api/app";
import { open as openUrl } from "@tauri-apps/plugin-shell";
import { gql, thumbUrl } from "../../lib/client";
import { GET_DOWNLOADS_PATH, GET_TRACKERS, LOGIN_TRACKER_OAUTH, LOGIN_TRACKER_CREDENTIALS, LOGOUT_TRACKER, GET_TRACKER_RECORDS } from "../../lib/queries";
import { GET_DOWNLOADS_PATH, GET_TRACKERS, LOGIN_TRACKER_OAUTH, LOGIN_TRACKER_CREDENTIALS, LOGOUT_TRACKER, GET_TRACKER_RECORDS, GET_SERVER_SECURITY, SET_SERVER_AUTH, SET_SOCKS_PROXY, SET_FLARESOLVERR } from "../../lib/queries";
import { store, updateSettings, resetKeybinds, addFolder, removeFolder, renameFolder, toggleFolderTab, clearHistory, wipeAllData, setSettingsOpen } from "../../store/state.svelte";
import { cache } from "../../lib/cache";
import { KEYBIND_LABELS, DEFAULT_KEYBINDS, eventToKeybind } from "../../lib/keybinds";
@@ -14,7 +14,7 @@
import type { Keybinds } from "../../lib/keybinds";
import type { Tracker } from "../../lib/types";
type Tab = "general" | "appearance" | "reader" | "library" | "performance" | "keybinds" | "storage" | "folders" | "tracking" | "about" | "devtools";
type Tab = "general" | "appearance" | "reader" | "library" | "performance" | "keybinds" | "storage" | "folders" | "tracking" | "security" | "about" | "devtools";
const TABS: { id: Tab; label: string; icon: any }[] = [
{ id: "general", label: "General", icon: Gear },
@@ -26,6 +26,7 @@
{ id: "storage", label: "Storage", icon: HardDrives },
{ id: "folders", label: "Folders", icon: FolderSimple },
{ id: "tracking", label: "Tracking", icon: ListChecks },
{ id: "security", label: "Security", icon: Lock },
{ id: "about", label: "About", icon: Info },
{ id: "devtools", label: "Dev Tools", icon: Wrench },
];
@@ -196,6 +197,145 @@
let splashTriggered = $state(false);
let showAuthPass = $state(false);
let showSocksPass = $state(false);
let pinInput = $state(store.settings.appLockPin ?? "");
let pinError = $state("");
let secLoading = $state(false);
let secError = $state<string | null>(null);
let secSaved = $state<string | null>(null);
let authUsername = $state(store.settings.serverAuthUser ?? "");
let authPassword = $state(store.settings.serverAuthPass ?? "");
let socksEnabled = $state(store.settings.socksProxyEnabled ?? false);
let socksHost = $state(store.settings.socksProxyHost ?? "");
let socksPort = $state(store.settings.socksProxyPort ?? "1080");
let socksVersion = $state(store.settings.socksProxyVersion ?? 5);
let socksUsername = $state(store.settings.socksProxyUsername ?? "");
let socksPassword = $state(store.settings.socksProxyPassword ?? "");
let flareEnabled = $state(store.settings.flareSolverrEnabled ?? false);
let flareUrl = $state(store.settings.flareSolverrUrl ?? "http://localhost:8191");
let flareTimeout = $state(store.settings.flareSolverrTimeout ?? 60);
let flareSession = $state(store.settings.flareSolverrSessionName ?? "moku");
let flareTtl = $state(store.settings.flareSolverrSessionTtl ?? 15);
let flareFallback = $state(store.settings.flareSolverrFallback ?? false);
function showSaved(key: string) {
secSaved = key; secError = null;
setTimeout(() => { if (secSaved === key) secSaved = null; }, 2000);
}
async function loadServerSecurity() {
try {
const res = await gql<{ settings: {
authMode: string; authUsername: string;
socksProxyEnabled: boolean; socksProxyHost: string; socksProxyPort: string;
socksProxyVersion: number; socksProxyUsername: string;
flareSolverrEnabled: boolean; flareSolverrUrl: string; flareSolverrTimeout: number;
flareSolverrSessionName: string; flareSolverrSessionTtl: number;
flareSolverrAsResponseFallback: boolean;
}}>(GET_SERVER_SECURITY);
const s = res.settings;
const authOn = s.authMode === "BASIC_AUTH";
updateSettings({ serverAuthEnabled: authOn, serverAuthUser: s.authUsername });
authUsername = s.authUsername;
socksEnabled = s.socksProxyEnabled; socksHost = s.socksProxyHost;
socksPort = s.socksProxyPort; socksVersion = s.socksProxyVersion;
socksUsername = s.socksProxyUsername;
flareEnabled = s.flareSolverrEnabled; flareUrl = s.flareSolverrUrl;
flareTimeout = s.flareSolverrTimeout; flareSession = s.flareSolverrSessionName;
flareTtl = s.flareSolverrSessionTtl; flareFallback = s.flareSolverrAsResponseFallback;
updateSettings({
socksProxyEnabled: socksEnabled, socksProxyHost: socksHost, socksProxyPort: socksPort,
socksProxyVersion: socksVersion, socksProxyUsername: socksUsername,
flareSolverrEnabled: flareEnabled, flareSolverrUrl: flareUrl,
flareSolverrTimeout: flareTimeout, flareSolverrSessionName: flareSession,
flareSolverrSessionTtl: flareTtl, flareSolverrFallback: flareFallback,
});
} catch {}
}
$effect(() => { if (tab === "security") loadServerSecurity(); });
async function enableAuth() {
if (!authUsername.trim() || !authPassword.trim()) {
secError = "Username and password are required"; return;
}
secLoading = true; secError = null;
updateSettings({ serverAuthEnabled: true, serverAuthUser: authUsername, serverAuthPass: authPassword });
try {
await gql(SET_SERVER_AUTH, { authMode: "BASIC_AUTH", authUsername: authUsername.trim(), authPassword: authPassword.trim() });
showSaved("auth");
} catch (e: any) {
updateSettings({ serverAuthEnabled: false });
secError = e?.message ?? "Failed to enable authentication";
} finally { secLoading = false; }
}
async function disableAuth() {
secLoading = true; secError = null;
try {
await gql(SET_SERVER_AUTH, { authMode: "NONE", authUsername: "", authPassword: "" });
updateSettings({ serverAuthEnabled: false, serverAuthUser: "", serverAuthPass: "" });
authUsername = ""; authPassword = "";
showSaved("auth");
} catch (e: any) {
secError = e?.message ?? "Failed to disable authentication";
} finally { secLoading = false; }
}
async function saveSocksProxy() {
secLoading = true; secError = null;
try {
await gql(SET_SOCKS_PROXY, {
socksProxyEnabled: socksEnabled, socksProxyHost: socksHost.trim(),
socksProxyPort: socksPort.trim(), socksProxyVersion: socksVersion,
socksProxyUsername: socksUsername.trim(), socksProxyPassword: socksPassword.trim(),
});
updateSettings({
socksProxyEnabled: socksEnabled, socksProxyHost: socksHost,
socksProxyPort: socksPort, socksProxyVersion: socksVersion,
socksProxyUsername: socksUsername, socksProxyPassword: socksPassword,
});
showSaved("socks");
} catch (e: any) {
secError = e?.message ?? "Failed to save SOCKS proxy";
} finally { secLoading = false; }
}
async function saveFlareSolverr() {
secLoading = true; secError = null;
try {
await gql(SET_FLARESOLVERR, {
flareSolverrEnabled: flareEnabled, flareSolverrUrl: flareUrl.trim(),
flareSolverrTimeout: flareTimeout, flareSolverrSessionName: flareSession.trim(),
flareSolverrSessionTtl: flareTtl, flareSolverrAsResponseFallback: flareFallback,
});
updateSettings({
flareSolverrEnabled: flareEnabled, flareSolverrUrl: flareUrl,
flareSolverrTimeout: flareTimeout, flareSolverrSessionName: flareSession,
flareSolverrSessionTtl: flareTtl, flareSolverrFallback: flareFallback,
});
showSaved("flare");
} catch (e: any) {
secError = e?.message ?? "Failed to save FlareSolverr";
} finally { secLoading = false; }
}
function commitPin() {
const cleaned = pinInput.replace(/\D/g, "").slice(0, 8);
pinInput = cleaned;
if (cleaned.length >= 4) {
updateSettings({ appLockPin: cleaned }); pinError = "";
} else if (cleaned.length > 0) {
pinError = "PIN must be at least 4 digits";
} else {
updateSettings({ appLockPin: "" }); pinError = "";
}
}
// ── Tracker state ─────────────────────────────────────────────────────────────
let trackers: Tracker[] = $state([]);
@@ -486,13 +626,27 @@
<div class="section">
<p class="section-title">Interface Scale</p>
<div class="scale-row">
<input type="range" min={70} max={200} step={5} value={store.settings.uiScale}
<input type="range" min={50} max={200} step={5} value={store.settings.uiScale}
oninput={(e) => updateSettings({ uiScale: Number(e.currentTarget.value) })} class="scale-slider" />
<span class="scale-val">{store.settings.uiScale}%</span>
<input
type="number" min={50} max={200} step={1}
class="scale-val-input"
value={store.settings.uiScale}
oninput={(e) => {
const n = parseInt(e.currentTarget.value, 10);
if (!isNaN(n) && n >= 50 && n <= 200) updateSettings({ uiScale: n });
}}
onblur={(e) => {
const n = parseInt(e.currentTarget.value, 10);
if (isNaN(n) || n < 50) { updateSettings({ uiScale: 50 }); e.currentTarget.value = "50"; }
else if (n > 200) { updateSettings({ uiScale: 200 }); e.currentTarget.value = "200"; }
}}
/>
<span class="scale-pct">%</span>
<button class="step-btn" onclick={() => updateSettings({ uiScale: 100 })} disabled={store.settings.uiScale === 100} title="Reset"></button>
</div>
<p class="scale-hint">
{#each [70,80,90,100,110,125,150,175,200] as v}
{#each [50,60,70,80,90,100,110,125,150,175,200] as v}
<button class="scale-preset" class:active={store.settings.uiScale === v} onclick={() => updateSettings({ uiScale: v })}>{v}%</button>
{/each}
</p>
@@ -793,11 +947,11 @@
{:else if tab === "keybinds"}
<div class="panel">
<div class="section">
<div class="kb-header">
<div class="section-title-row">
<p class="section-title">Keyboard shortcuts</p>
<button class="reset-all-btn" onclick={resetKeybinds}>Reset all</button>
<button class="sec-action-btn" onclick={resetKeybinds}>Reset all</button>
</div>
<p class="kb-hint">Click a key to rebind, then press the new combination.</p>
<p class="kb-hint">Click a binding to rebind, then press the new key combination.</p>
<div class="kb-list">
{#each Object.keys(KEYBIND_LABELS) as key}
{@const k = key as keyof Keybinds}
@@ -1052,6 +1206,230 @@
</div>
{:else if tab === "security"}
<div class="panel">
{#if secError}
<div class="sec-banner sec-banner-error">{secError}</div>
{/if}
<div class="section">
<div class="section-title-row">
<p class="section-title">Server Authentication</p>
<span class="sec-status-pill" class:sec-pill-on={store.settings.serverAuthEnabled}>
{store.settings.serverAuthEnabled ? "Enabled" : "Disabled"}
</span>
</div>
<div class="step-row">
<div class="toggle-info">
<span class="toggle-label">Username</span>
</div>
<input class="text-input" bind:value={authUsername} placeholder="Username" autocomplete="off" spellcheck="false" disabled={secLoading} />
</div>
<div class="step-row">
<div class="toggle-info">
<span class="toggle-label">Password</span>
</div>
<div class="sec-field-wrap">
<input class="text-input" type={showAuthPass ? "text" : "password"} bind:value={authPassword} placeholder="Password" autocomplete="off" spellcheck="false" disabled={secLoading} />
<button class="sec-eye-btn" onclick={() => showAuthPass = !showAuthPass} title={showAuthPass ? "Hide password" : "Show password"} tabindex="-1">
{#if showAuthPass}
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94"/><path d="M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19"/><line x1="1" y1="1" x2="23" y2="23"/></svg>
{:else}
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
{/if}
</button>
</div>
</div>
<div class="step-row">
<div class="toggle-info"></div>
<div class="sec-btn-row">
{#if store.settings.serverAuthEnabled}
<button class="sec-action-btn sec-action-danger" onclick={disableAuth} disabled={secLoading}>
{secLoading ? "Saving…" : "Disable"}
</button>
{/if}
<button class="sec-action-btn sec-action-primary" onclick={enableAuth} disabled={secLoading || !authUsername.trim() || !authPassword.trim()}>
{secLoading ? "Saving…" : secSaved === "auth" ? "Saved ✓" : store.settings.serverAuthEnabled ? "Update" : "Enable"}
</button>
</div>
</div>
</div>
<div class="section">
<div class="section-title-row">
<p class="section-title">App Lock</p>
</div>
<label class="toggle-row">
<div class="toggle-info">
<span class="toggle-label">PIN lock</span>
<span class="toggle-desc">Require a PIN on launch and after idle timeout</span>
</div>
<button role="switch" aria-checked={store.settings.appLockEnabled ?? false} aria-label="Enable PIN lock" class="toggle" class:on={store.settings.appLockEnabled} onclick={() => updateSettings({ appLockEnabled: !store.settings.appLockEnabled })}><span class="toggle-thumb"></span></button>
</label>
{#if store.settings.appLockEnabled}
<div class="step-row">
<div class="toggle-info">
<span class="toggle-label">PIN</span>
<span class="toggle-desc">48 digits</span>
</div>
<div class="sec-pin-wrap">
<div class="sec-pin-row">
<input class="text-input sec-pin-input" type="password" inputmode="numeric" maxlength={8} value={pinInput}
oninput={(e) => { pinInput = e.currentTarget.value.replace(/\D/g, "").slice(0, 8); pinError = ""; }}
onkeydown={(e) => e.key === "Enter" && commitPin()} placeholder="••••" autocomplete="off" />
<button class="sec-action-btn sec-action-primary"
onclick={commitPin}
disabled={pinInput.length > 0 && pinInput.length < 4}>
{store.settings.appLockPin && pinInput === store.settings.appLockPin ? "Saved ✓" : "Save"}
</button>
</div>
{#if pinError}<span class="sec-pin-error">{pinError}</span>{/if}
</div>
</div>
{/if}
</div>
<div class="section">
<div class="section-title-row">
<p class="section-title">SOCKS Proxy</p>
</div>
<label class="toggle-row">
<div class="toggle-info">
<span class="toggle-label">Enable SOCKS proxy</span>
<span class="toggle-desc">Route Suwayomi traffic through a SOCKS4/5 proxy</span>
</div>
<button role="switch" aria-checked={socksEnabled} aria-label="Enable SOCKS proxy" class="toggle" class:on={socksEnabled} onclick={() => socksEnabled = !socksEnabled}><span class="toggle-thumb"></span></button>
</label>
{#if socksEnabled}
<div class="step-row">
<div class="toggle-info">
<span class="toggle-label">Version</span>
</div>
<div class="select-wrap" id="socks-ver">
<button class="select-btn" onclick={() => toggleSelect("socks-ver")}>
<span>SOCKS{socksVersion}</span>
<svg class="select-caret" class:open={selectOpen === "socks-ver"} width="10" height="6" viewBox="0 0 10 6"><path d="M0 0l5 6 5-6" fill="currentColor"/></svg>
</button>
{#if selectOpen === "socks-ver"}
<div class="select-menu">
{#each [[4,"SOCKS4"],[5,"SOCKS5"]] as [v, l]}
<button class="select-option" class:active={socksVersion === v} onclick={() => { socksVersion = v as number; selectOpen = null; }}>{l}</button>
{/each}
</div>
{/if}
</div>
</div>
<div class="step-row">
<div class="toggle-info">
<span class="toggle-label">Host</span>
</div>
<input class="text-input" bind:value={socksHost} placeholder="127.0.0.1" autocomplete="off" spellcheck="false" />
</div>
<div class="step-row">
<div class="toggle-info">
<span class="toggle-label">Port</span>
</div>
<input class="text-input sec-port-input" bind:value={socksPort} placeholder="1080" autocomplete="off" spellcheck="false" />
</div>
<div class="step-row">
<div class="toggle-info">
<span class="toggle-label">Username</span>
<span class="toggle-desc">Optional</span>
</div>
<input class="text-input" bind:value={socksUsername} placeholder="Username" autocomplete="off" spellcheck="false" />
</div>
<div class="step-row">
<div class="toggle-info">
<span class="toggle-label">Password</span>
<span class="toggle-desc">Optional</span>
</div>
<div class="sec-field-wrap">
<input class="text-input" type={showSocksPass ? "text" : "password"} bind:value={socksPassword} placeholder="Password" autocomplete="off" spellcheck="false" />
<button class="sec-eye-btn" onclick={() => showSocksPass = !showSocksPass} title={showSocksPass ? "Hide password" : "Show password"} tabindex="-1">
{#if showSocksPass}
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94"/><path d="M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19"/><line x1="1" y1="1" x2="23" y2="23"/></svg>
{:else}
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
{/if}
</button>
</div>
</div>
<div class="step-row">
<div class="toggle-info"></div>
<button class="sec-action-btn sec-action-primary" onclick={saveSocksProxy} disabled={secLoading}>
{secLoading ? "Saving…" : secSaved === "socks" ? "Saved ✓" : "Save"}
</button>
</div>
{/if}
</div>
<div class="section">
<div class="section-title-row">
<p class="section-title">FlareSolverr</p>
</div>
<label class="toggle-row">
<div class="toggle-info">
<span class="toggle-label">Enable FlareSolverr</span>
<span class="toggle-desc">Bypass Cloudflare challenges for sources that require it</span>
</div>
<button role="switch" aria-checked={flareEnabled} aria-label="Enable FlareSolverr" class="toggle" class:on={flareEnabled} onclick={() => flareEnabled = !flareEnabled}><span class="toggle-thumb"></span></button>
</label>
{#if flareEnabled}
<div class="step-row">
<div class="toggle-info">
<span class="toggle-label">URL</span>
<span class="toggle-desc">FlareSolverr instance address</span>
</div>
<input class="text-input" bind:value={flareUrl} placeholder="http://localhost:8191" autocomplete="off" spellcheck="false" />
</div>
<div class="step-row">
<div class="toggle-info">
<span class="toggle-label">Timeout</span>
<span class="toggle-desc">Max wait per request, in seconds</span>
</div>
<div class="step-controls">
<button class="step-btn" onclick={() => flareTimeout = Math.max(10, flareTimeout - 10)}></button>
<span class="step-val">{flareTimeout}s</span>
<button class="step-btn" onclick={() => flareTimeout = Math.min(300, flareTimeout + 10)}>+</button>
</div>
</div>
<div class="step-row">
<div class="toggle-info">
<span class="toggle-label">Session name</span>
<span class="toggle-desc">Reuse browser session across requests</span>
</div>
<input class="text-input" bind:value={flareSession} placeholder="moku" autocomplete="off" spellcheck="false" />
</div>
<div class="step-row">
<div class="toggle-info">
<span class="toggle-label">Session TTL</span>
<span class="toggle-desc">Minutes before session is refreshed</span>
</div>
<div class="step-controls">
<button class="step-btn" onclick={() => flareTtl = Math.max(1, flareTtl - 1)}></button>
<span class="step-val">{flareTtl}m</span>
<button class="step-btn" onclick={() => flareTtl = Math.min(60, flareTtl + 1)}>+</button>
</div>
</div>
<label class="toggle-row">
<div class="toggle-info">
<span class="toggle-label">Response fallback</span>
<span class="toggle-desc">Use FlareSolverr's response when the direct request fails</span>
</div>
<button role="switch" aria-checked={flareFallback} aria-label="Use as response fallback" class="toggle" class:on={flareFallback} onclick={() => flareFallback = !flareFallback}><span class="toggle-thumb"></span></button>
</label>
<div class="step-row">
<div class="toggle-info"></div>
<button class="sec-action-btn sec-action-primary" onclick={saveFlareSolverr} disabled={secLoading}>
{secLoading ? "Saving…" : secSaved === "flare" ? "Saved ✓" : "Save"}
</button>
</div>
{/if}
</div>
</div>
{:else if tab === "about"}
<div class="panel">
@@ -1287,7 +1665,18 @@
.scale-row { display: flex; align-items: center; gap: var(--sp-3); padding: var(--sp-2) var(--sp-3); }
.scale-slider { flex: 1; }
.scale-val { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-secondary); min-width: 40px; text-align: center; }
.scale-val-input {
font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-secondary);
width: 42px; text-align: center; padding: 3px 4px;
background: var(--bg-raised); border: 1px solid var(--border-dim);
border-radius: var(--radius-sm); outline: none;
transition: border-color var(--t-base);
-moz-appearance: textfield;
}
.scale-val-input::-webkit-inner-spin-button,
.scale-val-input::-webkit-outer-spin-button { -webkit-appearance: none; margin: 0; }
.scale-val-input:focus { border-color: var(--border-strong); }
.scale-pct { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); margin-left: calc(var(--sp-1) * -1); }
.scale-hint { padding: 0 var(--sp-3) var(--sp-2); display: flex; gap: var(--sp-1); flex-wrap: wrap; }
.scale-preset { font-family: var(--font-ui); font-size: var(--text-2xs); padding: 2px 7px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: none; color: var(--text-faint); cursor: pointer; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
.scale-preset:hover { color: var(--text-muted); border-color: var(--border-strong); }
@@ -1310,9 +1699,6 @@
.theme-card-check { position: absolute; top: 6px; right: 6px; font-size: 10px; color: var(--accent-fg); background: var(--accent-muted); border: 1px solid var(--accent-dim); border-radius: var(--radius-sm); padding: 1px 4px; }
/* Keybinds */
.kb-header { display: flex; align-items: center; justify-content: space-between; padding: 0 var(--sp-3) var(--sp-2); }
.reset-all-btn { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 4px 10px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: none; color: var(--text-muted); cursor: pointer; transition: color var(--t-base), border-color var(--t-base); }
.reset-all-btn:hover { color: var(--text-secondary); border-color: var(--border-strong); }
.kb-hint { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); padding: 0 var(--sp-3) var(--sp-3); }
.kb-list { display: flex; flex-direction: column; gap: 1px; }
.kb-row { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-2) var(--sp-3); border-radius: var(--radius-md); transition: background var(--t-fast); }
@@ -1477,6 +1863,35 @@
.oauth-input:focus { border-color: var(--border-focus); }
.oauth-btns { display: flex; align-items: center; gap: var(--sp-2); }
/* ── Security tab ───────────────────────────────────────────────────── */
.sec-banner { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: var(--sp-2) var(--sp-3); margin: 0 0 var(--sp-2); border-radius: var(--radius-sm); }
.sec-banner-error { color: var(--color-error); background: var(--color-error-bg); border: 1px solid var(--color-error); }
.section-title-row { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-3) var(--sp-3) var(--sp-2); }
.section-title-row .section-title { padding: 0; }
.sec-status-pill { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wider); text-transform: uppercase; padding: 2px 8px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); color: var(--text-faint); background: var(--bg-overlay); flex-shrink: 0; cursor: default; }
.sec-pill-on { border-color: var(--accent-dim); color: var(--accent-fg); background: var(--accent-muted); }
.sec-field-wrap { position: relative; flex-shrink: 0; }
.sec-field-wrap .text-input { padding-right: 34px; }
.sec-eye-btn { position: absolute; right: 8px; top: 50%; transform: translateY(-50%); display: flex; align-items: center; justify-content: center; padding: 0; border: none; background: none; color: var(--text-faint); cursor: pointer; transition: color var(--t-base); }
.sec-eye-btn:hover { color: var(--text-muted); }
.sec-btn-row { display: flex; align-items: center; gap: var(--sp-2); flex-shrink: 0; }
.sec-action-btn { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 5px 14px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: none; color: var(--text-muted); cursor: pointer; flex-shrink: 0; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
.sec-action-btn:hover:not(:disabled) { color: var(--text-secondary); border-color: var(--border-strong); }
.sec-action-btn:disabled { opacity: 0.35; cursor: default; }
.sec-action-primary { background: var(--accent-muted); border-color: var(--accent-dim); color: var(--accent-fg); }
.sec-action-primary:hover:not(:disabled) { filter: brightness(1.1); }
.sec-action-danger { border-color: var(--color-error); color: var(--color-error); }
.sec-action-danger:hover:not(:disabled) { background: var(--color-error-bg); }
.sec-pin-wrap { display: flex; flex-direction: column; align-items: flex-end; gap: 4px; }
.sec-pin-wrap .sec-pin-row { display: flex; align-items: center; gap: var(--sp-2); }
.sec-pin-input { width: 96px; text-align: center; letter-spacing: 0.25em; }
.sec-port-input { width: 88px; }
.sec-pin-error { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--color-error); letter-spacing: var(--tracking-wide); }
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
@keyframes scaleIn { from { transform: scale(0.97); opacity: 0 } to { transform: scale(1); opacity: 1 } }
</style>
+19 -26
View File
@@ -1,17 +1,28 @@
const DEFAULT_URL = "http://127.0.0.1:4567";
function getServerUrl(): string {
function getSettings(): Record<string, any> {
try {
const raw = localStorage.getItem("moku-store");
if (raw) {
const parsed = JSON.parse(raw);
const url = parsed?.state?.settings?.serverUrl;
if (typeof url === "string" && url.trim()) return url.replace(/\/$/, "");
}
if (raw) return JSON.parse(raw)?.settings ?? {};
} catch {}
return {};
}
function getServerUrl(): string {
const url = getSettings().serverUrl;
if (typeof url === "string" && url.trim()) return url.replace(/\/$/, "");
return DEFAULT_URL;
}
function getAuthHeader(): Record<string, string> {
const s = getSettings();
if (!s.serverAuthEnabled) return {};
const user = typeof s.serverAuthUser === "string" ? s.serverAuthUser.trim() : "";
const pass = typeof s.serverAuthPass === "string" ? s.serverAuthPass.trim() : "";
if (user && pass) return { Authorization: `Basic ${btoa(`${user}:${pass}`)}` };
return {};
}
function gqlUrl(): string { return `${getServerUrl()}/api/graphql`; }
export function thumbUrl(path: string): string {
@@ -25,7 +36,6 @@ interface GQLResponse<T> {
errors?: { message: string }[];
}
/** Sleep that resolves early if the signal is aborted — never blocks a cancelled request. */
function abortableSleep(ms: number, signal?: AbortSignal): Promise<void> {
return new Promise((resolve, reject) => {
if (signal?.aborted) { reject(new DOMException("Aborted", "AbortError")); return; }
@@ -37,12 +47,6 @@ function abortableSleep(ms: number, signal?: AbortSignal): Promise<void> {
});
}
/**
* Retry wrapper with these guarantees:
* 1. AbortErrors always propagate immediately no retry, no delay.
* 2. Retry delays are abort-aware closing a manga mid-delay doesn't hang.
* 3. If the signal is already aborted before we even start, we bail instantly.
*/
async function fetchWithRetry(
url: string,
init: RequestInit,
@@ -50,29 +54,19 @@ async function fetchWithRetry(
retries = 3,
delayMs = 300,
): Promise<Response> {
// Bail immediately if already aborted before we start
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
for (let i = 0; i < retries; i++) {
// Check abort at the top of every iteration
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
try {
const res = await fetch(url, { ...init, signal });
// Check abort again — fetch can return a response even after abort in some runtimes
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
return res;
} catch (e: any) {
// Never retry aborted requests
const isAbort = e?.name === "AbortError" || signal?.aborted;
if (isAbort) throw new DOMException("Aborted", "AbortError");
// Last retry — give up
if (i === retries - 1) throw e;
// Abort-aware delay between retries
await abortableSleep(delayMs * Math.pow(1.5, i), signal);
}
}
@@ -86,11 +80,10 @@ export async function gql<T>(
): Promise<T> {
const res = await fetchWithRetry(gqlUrl(), {
method: "POST",
headers: { "Content-Type": "application/json" },
headers: { "Content-Type": "application/json", ...getAuthHeader() },
body: JSON.stringify({ query, variables }),
}, signal);
// Check abort before reading the body — avoids hanging on res.json() after cancel
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
if (!res.ok) throw new Error(`Suwayomi HTTP ${res.status}`);
@@ -100,4 +93,4 @@ export async function gql<T>(
if (json.errors?.length) throw new Error(json.errors[0].message);
return json.data;
}
}
+88
View File
@@ -457,6 +457,94 @@ export const SET_EXTENSION_REPOS = `
}
`;
export const GET_SERVER_SECURITY = `
query GetServerSecurity {
settings {
authMode
authUsername
socksProxyEnabled
socksProxyHost
socksProxyPort
socksProxyVersion
socksProxyUsername
flareSolverrEnabled
flareSolverrUrl
flareSolverrTimeout
flareSolverrSessionName
flareSolverrSessionTtl
flareSolverrAsResponseFallback
}
}
`;
export const SET_SERVER_AUTH = `
mutation SetServerAuth($authMode: AuthMode!, $authUsername: String!, $authPassword: String!) {
setSettings(input: { settings: { authMode: $authMode, authUsername: $authUsername, authPassword: $authPassword } }) {
settings {
authMode
authUsername
}
}
}
`;
export const SET_SOCKS_PROXY = `
mutation SetSocksProxy(
$socksProxyEnabled: Boolean!
$socksProxyHost: String!
$socksProxyPort: String!
$socksProxyVersion: Int!
$socksProxyUsername: String!
$socksProxyPassword: String!
) {
setSettings(input: { settings: {
socksProxyEnabled: $socksProxyEnabled
socksProxyHost: $socksProxyHost
socksProxyPort: $socksProxyPort
socksProxyVersion: $socksProxyVersion
socksProxyUsername: $socksProxyUsername
socksProxyPassword: $socksProxyPassword
}}) {
settings {
socksProxyEnabled
socksProxyHost
socksProxyPort
socksProxyVersion
socksProxyUsername
}
}
}
`;
export const SET_FLARESOLVERR = `
mutation SetFlareSolverr(
$flareSolverrEnabled: Boolean!
$flareSolverrUrl: String!
$flareSolverrTimeout: Int!
$flareSolverrSessionName: String!
$flareSolverrSessionTtl: Int!
$flareSolverrAsResponseFallback: Boolean!
) {
setSettings(input: { settings: {
flareSolverrEnabled: $flareSolverrEnabled
flareSolverrUrl: $flareSolverrUrl
flareSolverrTimeout: $flareSolverrTimeout
flareSolverrSessionName: $flareSolverrSessionName
flareSolverrSessionTtl: $flareSolverrSessionTtl
flareSolverrAsResponseFallback: $flareSolverrAsResponseFallback
}}) {
settings {
flareSolverrEnabled
flareSolverrUrl
flareSolverrTimeout
flareSolverrSessionName
flareSolverrSessionTtl
flareSolverrAsResponseFallback
}
}
}
`;
// ── Trackers ──────────────────────────────────────────────────────────────────
export const GET_TRACKERS = `
+34
View File
@@ -116,6 +116,23 @@ export interface Settings {
renderLimit: number;
heroSlots: (number | null)[];
mangaLinks: Record<number, number[]>;
serverAuthUser: string;
serverAuthPass: string;
serverAuthEnabled: boolean;
socksProxyEnabled: boolean;
socksProxyHost: string;
socksProxyPort: string;
socksProxyVersion: number;
socksProxyUsername: string;
socksProxyPassword: string;
flareSolverrEnabled: boolean;
flareSolverrUrl: string;
flareSolverrTimeout: number;
flareSolverrSessionName: string;
flareSolverrSessionTtl: number;
flareSolverrFallback: boolean;
appLockEnabled: boolean;
appLockPin: string;
}
const COMPLETED_FOLDER_DEFAULT: Folder = {
@@ -161,6 +178,23 @@ export const DEFAULT_SETTINGS: Settings = {
renderLimit: 48,
heroSlots: [null, null, null, null],
mangaLinks: {},
serverAuthUser: "",
serverAuthPass: "",
serverAuthEnabled: false,
socksProxyEnabled: false,
socksProxyHost: "",
socksProxyPort: "1080",
socksProxyVersion: 5,
socksProxyUsername: "",
socksProxyPassword: "",
flareSolverrEnabled: false,
flareSolverrUrl: "http://localhost:8191",
flareSolverrTimeout: 60,
flareSolverrSessionName: "moku",
flareSolverrSessionTtl: 15,
flareSolverrFallback: false,
appLockEnabled: false,
appLockPin: "",
};
// ── Persistence ───────────────────────────────────────────────────────────────