mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 17:29:55 -05:00
Feat: Lock-Feature & Server-Authentication + Experimentals
This commit is contained in:
+1
-1
@@ -181,7 +181,7 @@ modules:
|
|||||||
path: .
|
path: .
|
||||||
- type: file
|
- type: file
|
||||||
path: packaging/frontend-dist.tar.gz
|
path: packaging/frontend-dist.tar.gz
|
||||||
sha256: 9f3e4a6b059f4236bf63fcbaa27cca6041257d39940172e5c1bb520c7cdc51e8
|
sha256: bceb301e2cf8c20576d910294bb7fd94ea9dab82e7921c7a32f81072a2654c75
|
||||||
- packaging/cargo-sources.json
|
- packaging/cargo-sources.json
|
||||||
- type: inline
|
- type: inline
|
||||||
dest: src-tauri/.cargo
|
dest: src-tauri/.cargo
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
|
|
||||||
perSystem = { system, lib, ... }:
|
perSystem = { system, lib, ... }:
|
||||||
let
|
let
|
||||||
version = "0.4.0";
|
version = "0.4.1";
|
||||||
|
|
||||||
pkgs = import inputs.nixpkgs {
|
pkgs = import inputs.nixpkgs {
|
||||||
inherit system;
|
inherit system;
|
||||||
|
|||||||
@@ -1507,6 +1507,19 @@
|
|||||||
"dest": "cargo/vendor/generic-array-0.14.7",
|
"dest": "cargo/vendor/generic-array-0.14.7",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "archive",
|
||||||
|
"archive-type": "tar-gzip",
|
||||||
|
"url": "https://static.crates.io/crates/gethostname/gethostname-1.1.0.crate",
|
||||||
|
"sha256": "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8",
|
||||||
|
"dest": "cargo/vendor/gethostname-1.1.0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "inline",
|
||||||
|
"contents": "{\"package\": \"1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8\", \"files\": {}}",
|
||||||
|
"dest": "cargo/vendor/gethostname-1.1.0",
|
||||||
|
"dest-filename": ".cargo-checksum.json"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
@@ -2651,6 +2664,19 @@
|
|||||||
"dest": "cargo/vendor/new_debug_unreachable-1.0.6",
|
"dest": "cargo/vendor/new_debug_unreachable-1.0.6",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "archive",
|
||||||
|
"archive-type": "tar-gzip",
|
||||||
|
"url": "https://static.crates.io/crates/nix/nix-0.30.1.crate",
|
||||||
|
"sha256": "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6",
|
||||||
|
"dest": "cargo/vendor/nix-0.30.1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "inline",
|
||||||
|
"contents": "{\"package\": \"74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6\", \"files\": {}}",
|
||||||
|
"dest": "cargo/vendor/nix-0.30.1",
|
||||||
|
"dest-filename": ".cargo-checksum.json"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
@@ -2820,6 +2846,19 @@
|
|||||||
"dest": "cargo/vendor/objc2-core-image-0.3.2",
|
"dest": "cargo/vendor/objc2-core-image-0.3.2",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "archive",
|
||||||
|
"archive-type": "tar-gzip",
|
||||||
|
"url": "https://static.crates.io/crates/objc2-core-location/objc2-core-location-0.3.2.crate",
|
||||||
|
"sha256": "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009",
|
||||||
|
"dest": "cargo/vendor/objc2-core-location-0.3.2"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "inline",
|
||||||
|
"contents": "{\"package\": \"ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009\", \"files\": {}}",
|
||||||
|
"dest": "cargo/vendor/objc2-core-location-0.3.2",
|
||||||
|
"dest-filename": ".cargo-checksum.json"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
@@ -2963,6 +3002,19 @@
|
|||||||
"dest": "cargo/vendor/objc2-ui-kit-0.3.2",
|
"dest": "cargo/vendor/objc2-ui-kit-0.3.2",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "archive",
|
||||||
|
"archive-type": "tar-gzip",
|
||||||
|
"url": "https://static.crates.io/crates/objc2-user-notifications/objc2-user-notifications-0.3.2.crate",
|
||||||
|
"sha256": "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e",
|
||||||
|
"dest": "cargo/vendor/objc2-user-notifications-0.3.2"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "inline",
|
||||||
|
"contents": "{\"package\": \"9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e\", \"files\": {}}",
|
||||||
|
"dest": "cargo/vendor/objc2-user-notifications-0.3.2",
|
||||||
|
"dest-filename": ".cargo-checksum.json"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
@@ -3028,6 +3080,19 @@
|
|||||||
"dest": "cargo/vendor/option-ext-0.2.0",
|
"dest": "cargo/vendor/option-ext-0.2.0",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "archive",
|
||||||
|
"archive-type": "tar-gzip",
|
||||||
|
"url": "https://static.crates.io/crates/os_info/os_info-3.14.0.crate",
|
||||||
|
"sha256": "e4022a17595a00d6a369236fdae483f0de7f0a339960a53118b818238e132224",
|
||||||
|
"dest": "cargo/vendor/os_info-3.14.0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "inline",
|
||||||
|
"contents": "{\"package\": \"e4022a17595a00d6a369236fdae483f0de7f0a339960a53118b818238e132224\", \"files\": {}}",
|
||||||
|
"dest": "cargo/vendor/os_info-3.14.0",
|
||||||
|
"dest-filename": ".cargo-checksum.json"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
@@ -4770,6 +4835,19 @@
|
|||||||
"dest": "cargo/vendor/synstructure-0.13.2",
|
"dest": "cargo/vendor/synstructure-0.13.2",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "archive",
|
||||||
|
"archive-type": "tar-gzip",
|
||||||
|
"url": "https://static.crates.io/crates/sys-locale/sys-locale-0.3.2.crate",
|
||||||
|
"sha256": "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4",
|
||||||
|
"dest": "cargo/vendor/sys-locale-0.3.2"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "inline",
|
||||||
|
"contents": "{\"package\": \"8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4\", \"files\": {}}",
|
||||||
|
"dest": "cargo/vendor/sys-locale-0.3.2",
|
||||||
|
"dest-filename": ".cargo-checksum.json"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
@@ -4965,6 +5043,19 @@
|
|||||||
"dest": "cargo/vendor/tauri-plugin-http-2.5.7",
|
"dest": "cargo/vendor/tauri-plugin-http-2.5.7",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "archive",
|
||||||
|
"archive-type": "tar-gzip",
|
||||||
|
"url": "https://static.crates.io/crates/tauri-plugin-os/tauri-plugin-os-2.3.2.crate",
|
||||||
|
"sha256": "d8f08346c8deb39e96f86973da0e2d76cbb933d7ac9b750f6dc4daf955a6f997",
|
||||||
|
"dest": "cargo/vendor/tauri-plugin-os-2.3.2"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "inline",
|
||||||
|
"contents": "{\"package\": \"d8f08346c8deb39e96f86973da0e2d76cbb933d7ac9b750f6dc4daf955a6f997\", \"files\": {}}",
|
||||||
|
"dest": "cargo/vendor/tauri-plugin-os-2.3.2",
|
||||||
|
"dest-filename": ".cargo-checksum.json"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
|
|||||||
Generated
+1
-1
@@ -1999,7 +1999,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "moku"
|
name = "moku"
|
||||||
version = "0.4.0"
|
version = "0.4.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dirs 5.0.1",
|
"dirs 5.0.1",
|
||||||
"serde",
|
"serde",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "moku"
|
name = "moku"
|
||||||
version = "0.4.0"
|
version = "0.4.1"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[lib]
|
[lib]
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "Moku",
|
"productName": "Moku",
|
||||||
"version": "0.4.0",
|
"version": "0.4.1",
|
||||||
"identifier": "dev.moku.app",
|
"identifier": "dev.moku.app",
|
||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "../dist",
|
"frontendDist": "../dist",
|
||||||
|
|||||||
+2
-2
@@ -60,8 +60,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function resetIdle() {
|
function resetIdle() {
|
||||||
if (idle) return;
|
|
||||||
if (idleTimer) clearTimeout(idleTimer);
|
if (idleTimer) clearTimeout(idleTimer);
|
||||||
|
if (idle) return; // don't re-arm while PIN screen is showing
|
||||||
const ms = (store.settings.idleTimeoutMin ?? 5) * 60 * 1000;
|
const ms = (store.settings.idleTimeoutMin ?? 5) * 60 * 1000;
|
||||||
if (ms === 0) return;
|
if (ms === 0) return;
|
||||||
idleTimer = setTimeout(() => idle = true, ms);
|
idleTimer = setTimeout(() => idle = true, ms);
|
||||||
@@ -225,7 +225,7 @@
|
|||||||
<div class="root">
|
<div class="root">
|
||||||
{#if idle && !store.activeChapter}
|
{#if idle && !store.activeChapter}
|
||||||
<SplashScreen mode="idle" showCards={store.settings.splashCards ?? true}
|
<SplashScreen mode="idle" showCards={store.settings.splashCards ?? true}
|
||||||
onDismiss={() => setTimeout(() => idle = false, 340)} />
|
onDismiss={() => { idle = false; resetIdle(); }} />
|
||||||
{/if}
|
{/if}
|
||||||
{#if !store.activeChapter && !store.isFullscreen}<TitleBar />{/if}
|
{#if !store.activeChapter && !store.isFullscreen}<TitleBar />{/if}
|
||||||
<div class="content">
|
<div class="content">
|
||||||
|
|||||||
@@ -19,6 +19,36 @@
|
|||||||
let { mode = "loading", ringFull = false, failed = false, notConfigured = false,
|
let { mode = "loading", ringFull = false, failed = false, notConfigured = false,
|
||||||
showCards = true, showFps = false, onReady, onRetry, onDismiss }: Props = $props();
|
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;
|
const EXIT_MS = 320;
|
||||||
// Server typically takes 8-20s to boot. We animate the ring through three
|
// Server typically takes 8-20s to boot. We animate the ring through three
|
||||||
// phases so it always feels like something is happening:
|
// phases so it always feels like something is happening:
|
||||||
@@ -81,7 +111,12 @@
|
|||||||
if (ringFull) {
|
if (ringFull) {
|
||||||
cancelAnimationFrame(animFrame);
|
cancelAnimationFrame(animFrame);
|
||||||
ringProg = 1;
|
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(() => {
|
onMount(() => {
|
||||||
if (mode === "idle" && onDismiss) {
|
if (mode === "idle" && onDismiss) {
|
||||||
|
if (lockEnabled) {
|
||||||
|
return () => clearInterval(dotsInterval);
|
||||||
|
}
|
||||||
const handler = () => triggerExit(onDismiss);
|
const handler = () => triggerExit(onDismiss);
|
||||||
const t = setTimeout(() => {
|
const t = setTimeout(() => {
|
||||||
window.addEventListener("keydown", handler, { once: true });
|
window.addEventListener("keydown", handler, { once: true });
|
||||||
@@ -271,6 +309,24 @@
|
|||||||
return () => { cancelAnimationFrame(raf); ro.disconnect(); };
|
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 ringR = $derived(70);
|
||||||
const ringPad = $derived(12);
|
const ringPad = $derived(12);
|
||||||
const ringSize = $derived((ringR + ringPad) * 2);
|
const ringSize = $derived((ringR + ringPad) * 2);
|
||||||
@@ -281,7 +337,7 @@
|
|||||||
const ringLeft = $derived(-((ringSize - 140) / 2));
|
const ringLeft = $derived(-((ringSize - 140) / 2));
|
||||||
</script>
|
</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}
|
{#if showCards}
|
||||||
<canvas style="position:absolute;inset:0;pointer-events:none;width:100%;height:100%" use:mountCanvas></canvas>
|
<canvas style="position:absolute;inset:0;pointer-events:none;width:100%;height:100%" use:mountCanvas></canvas>
|
||||||
{#if showFps}
|
{#if showFps}
|
||||||
@@ -289,7 +345,23 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{/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="z-index:1;display:flex;flex-direction:column;align-items:center">
|
||||||
<div style="position:relative;width:128px;height:128px;margin-bottom:32px">
|
<div style="position:relative;width:128px;height:128px;margin-bottom:32px">
|
||||||
<div class="logo-glow"></div>
|
<div class="logo-glow"></div>
|
||||||
@@ -297,38 +369,64 @@
|
|||||||
</div>
|
</div>
|
||||||
<p class="hint">press any key to continue</p>
|
<p class="hint">press any key to continue</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{:else}
|
{: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">
|
<div style="position:relative;width:140px;height:140px;margin-bottom:20px;z-index:1">
|
||||||
{#if !failed && !notConfigured}
|
{#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(--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>
|
</svg>
|
||||||
{/if}
|
{/if}
|
||||||
<img src={logoUrl} alt="Moku" style="width:140px;height:140px;border-radius:32px;display:block" />
|
<img src={logoUrl} alt="Moku" style="width:140px;height:140px;border-radius:32px;display:block" />
|
||||||
</div>
|
</div>
|
||||||
<p class="title-label">moku</p>
|
<p class="title-label">moku</p>
|
||||||
<div style="z-index:1;display:flex;flex-direction:column;align-items:center;gap:8px">
|
|
||||||
{#if notConfigured}
|
<!-- Bottom area: status text → fades out, pin dots → fades in. Same space, no DOM swap. -->
|
||||||
<div class="error-box">
|
<div class="bottom-area" style="z-index:1">
|
||||||
<p class="error-title">Server not configured</p>
|
|
||||||
<p class="error-body">Set the server path in Settings, then retry</p>
|
<!-- Status / error — fades out once pin is visible -->
|
||||||
<div style="display:flex;gap:8px;margin-top:8px">
|
<div class="status-slot" class:status-slot-hide={lockEnabled && pinVisible}>
|
||||||
<button class="retry-btn" onclick={() => { store.settingsOpen = true; }}>Settings</button>
|
{#if notConfigured}
|
||||||
<button class="retry-btn" onclick={onRetry}>Retry</button>
|
<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>
|
</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>
|
</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}
|
{/if}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -350,4 +448,32 @@
|
|||||||
.error-box--danger { border-color: rgba(220,50,50,0.5); }
|
.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-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; }
|
.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>
|
</style>
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { tick } from "svelte";
|
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 { invoke } from "@tauri-apps/api/core";
|
||||||
import { listen } from "@tauri-apps/api/event";
|
import { listen } from "@tauri-apps/api/event";
|
||||||
import { getVersion } from "@tauri-apps/api/app";
|
import { getVersion } from "@tauri-apps/api/app";
|
||||||
import { open as openUrl } from "@tauri-apps/plugin-shell";
|
import { open as openUrl } from "@tauri-apps/plugin-shell";
|
||||||
import { gql, thumbUrl } from "../../lib/client";
|
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 { store, updateSettings, resetKeybinds, addFolder, removeFolder, renameFolder, toggleFolderTab, clearHistory, wipeAllData, setSettingsOpen } from "../../store/state.svelte";
|
||||||
import { cache } from "../../lib/cache";
|
import { cache } from "../../lib/cache";
|
||||||
import { KEYBIND_LABELS, DEFAULT_KEYBINDS, eventToKeybind } from "../../lib/keybinds";
|
import { KEYBIND_LABELS, DEFAULT_KEYBINDS, eventToKeybind } from "../../lib/keybinds";
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
import type { Keybinds } from "../../lib/keybinds";
|
import type { Keybinds } from "../../lib/keybinds";
|
||||||
import type { Tracker } from "../../lib/types";
|
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 }[] = [
|
const TABS: { id: Tab; label: string; icon: any }[] = [
|
||||||
{ id: "general", label: "General", icon: Gear },
|
{ id: "general", label: "General", icon: Gear },
|
||||||
@@ -26,6 +26,7 @@
|
|||||||
{ id: "storage", label: "Storage", icon: HardDrives },
|
{ id: "storage", label: "Storage", icon: HardDrives },
|
||||||
{ id: "folders", label: "Folders", icon: FolderSimple },
|
{ id: "folders", label: "Folders", icon: FolderSimple },
|
||||||
{ id: "tracking", label: "Tracking", icon: ListChecks },
|
{ id: "tracking", label: "Tracking", icon: ListChecks },
|
||||||
|
{ id: "security", label: "Security", icon: Lock },
|
||||||
{ id: "about", label: "About", icon: Info },
|
{ id: "about", label: "About", icon: Info },
|
||||||
{ id: "devtools", label: "Dev Tools", icon: Wrench },
|
{ id: "devtools", label: "Dev Tools", icon: Wrench },
|
||||||
];
|
];
|
||||||
@@ -196,6 +197,145 @@
|
|||||||
|
|
||||||
let splashTriggered = $state(false);
|
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 ─────────────────────────────────────────────────────────────
|
// ── Tracker state ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
let trackers: Tracker[] = $state([]);
|
let trackers: Tracker[] = $state([]);
|
||||||
@@ -486,13 +626,27 @@
|
|||||||
<div class="section">
|
<div class="section">
|
||||||
<p class="section-title">Interface Scale</p>
|
<p class="section-title">Interface Scale</p>
|
||||||
<div class="scale-row">
|
<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" />
|
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>
|
<button class="step-btn" onclick={() => updateSettings({ uiScale: 100 })} disabled={store.settings.uiScale === 100} title="Reset">↺</button>
|
||||||
</div>
|
</div>
|
||||||
<p class="scale-hint">
|
<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>
|
<button class="scale-preset" class:active={store.settings.uiScale === v} onclick={() => updateSettings({ uiScale: v })}>{v}%</button>
|
||||||
{/each}
|
{/each}
|
||||||
</p>
|
</p>
|
||||||
@@ -793,11 +947,11 @@
|
|||||||
{:else if tab === "keybinds"}
|
{:else if tab === "keybinds"}
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<div class="kb-header">
|
<div class="section-title-row">
|
||||||
<p class="section-title">Keyboard shortcuts</p>
|
<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>
|
</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">
|
<div class="kb-list">
|
||||||
{#each Object.keys(KEYBIND_LABELS) as key}
|
{#each Object.keys(KEYBIND_LABELS) as key}
|
||||||
{@const k = key as keyof Keybinds}
|
{@const k = key as keyof Keybinds}
|
||||||
@@ -1052,6 +1206,230 @@
|
|||||||
|
|
||||||
</div>
|
</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">4–8 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"}
|
{:else if tab === "about"}
|
||||||
<div class="panel">
|
<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-row { display: flex; align-items: center; gap: var(--sp-3); padding: var(--sp-2) var(--sp-3); }
|
||||||
.scale-slider { flex: 1; }
|
.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-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 { 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); }
|
.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; }
|
.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 */
|
/* 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-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-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); }
|
.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-input:focus { border-color: var(--border-focus); }
|
||||||
.oauth-btns { display: flex; align-items: center; gap: var(--sp-2); }
|
.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 fadeIn { from { opacity: 0 } to { opacity: 1 } }
|
||||||
@keyframes scaleIn { from { transform: scale(0.97); opacity: 0 } to { transform: scale(1); opacity: 1 } }
|
@keyframes scaleIn { from { transform: scale(0.97); opacity: 0 } to { transform: scale(1); opacity: 1 } }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
+18
-25
@@ -1,17 +1,28 @@
|
|||||||
const DEFAULT_URL = "http://127.0.0.1:4567";
|
const DEFAULT_URL = "http://127.0.0.1:4567";
|
||||||
|
|
||||||
function getServerUrl(): string {
|
function getSettings(): Record<string, any> {
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem("moku-store");
|
const raw = localStorage.getItem("moku-store");
|
||||||
if (raw) {
|
if (raw) return JSON.parse(raw)?.settings ?? {};
|
||||||
const parsed = JSON.parse(raw);
|
|
||||||
const url = parsed?.state?.settings?.serverUrl;
|
|
||||||
if (typeof url === "string" && url.trim()) return url.replace(/\/$/, "");
|
|
||||||
}
|
|
||||||
} catch {}
|
} catch {}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getServerUrl(): string {
|
||||||
|
const url = getSettings().serverUrl;
|
||||||
|
if (typeof url === "string" && url.trim()) return url.replace(/\/$/, "");
|
||||||
return DEFAULT_URL;
|
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`; }
|
function gqlUrl(): string { return `${getServerUrl()}/api/graphql`; }
|
||||||
|
|
||||||
export function thumbUrl(path: string): string {
|
export function thumbUrl(path: string): string {
|
||||||
@@ -25,7 +36,6 @@ interface GQLResponse<T> {
|
|||||||
errors?: { message: string }[];
|
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> {
|
function abortableSleep(ms: number, signal?: AbortSignal): Promise<void> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
if (signal?.aborted) { reject(new DOMException("Aborted", "AbortError")); return; }
|
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(
|
async function fetchWithRetry(
|
||||||
url: string,
|
url: string,
|
||||||
init: RequestInit,
|
init: RequestInit,
|
||||||
@@ -50,29 +54,19 @@ async function fetchWithRetry(
|
|||||||
retries = 3,
|
retries = 3,
|
||||||
delayMs = 300,
|
delayMs = 300,
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
// Bail immediately if already aborted before we start
|
|
||||||
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
||||||
|
|
||||||
for (let i = 0; i < retries; i++) {
|
for (let i = 0; i < retries; i++) {
|
||||||
// Check abort at the top of every iteration
|
|
||||||
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(url, { ...init, signal });
|
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");
|
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
||||||
|
|
||||||
return res;
|
return res;
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
// Never retry aborted requests
|
|
||||||
const isAbort = e?.name === "AbortError" || signal?.aborted;
|
const isAbort = e?.name === "AbortError" || signal?.aborted;
|
||||||
if (isAbort) throw new DOMException("Aborted", "AbortError");
|
if (isAbort) throw new DOMException("Aborted", "AbortError");
|
||||||
|
|
||||||
// Last retry — give up
|
|
||||||
if (i === retries - 1) throw e;
|
if (i === retries - 1) throw e;
|
||||||
|
|
||||||
// Abort-aware delay between retries
|
|
||||||
await abortableSleep(delayMs * Math.pow(1.5, i), signal);
|
await abortableSleep(delayMs * Math.pow(1.5, i), signal);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -86,11 +80,10 @@ export async function gql<T>(
|
|||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const res = await fetchWithRetry(gqlUrl(), {
|
const res = await fetchWithRetry(gqlUrl(), {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json", ...getAuthHeader() },
|
||||||
body: JSON.stringify({ query, variables }),
|
body: JSON.stringify({ query, variables }),
|
||||||
}, signal);
|
}, signal);
|
||||||
|
|
||||||
// Check abort before reading the body — avoids hanging on res.json() after cancel
|
|
||||||
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
||||||
if (!res.ok) throw new Error(`Suwayomi HTTP ${res.status}`);
|
if (!res.ok) throw new Error(`Suwayomi HTTP ${res.status}`);
|
||||||
|
|
||||||
|
|||||||
@@ -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 ──────────────────────────────────────────────────────────────────
|
// ── Trackers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export const GET_TRACKERS = `
|
export const GET_TRACKERS = `
|
||||||
|
|||||||
@@ -116,6 +116,23 @@ export interface Settings {
|
|||||||
renderLimit: number;
|
renderLimit: number;
|
||||||
heroSlots: (number | null)[];
|
heroSlots: (number | null)[];
|
||||||
mangaLinks: Record<number, number[]>;
|
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 = {
|
const COMPLETED_FOLDER_DEFAULT: Folder = {
|
||||||
@@ -161,6 +178,23 @@ export const DEFAULT_SETTINGS: Settings = {
|
|||||||
renderLimit: 48,
|
renderLimit: 48,
|
||||||
heroSlots: [null, null, null, null],
|
heroSlots: [null, null, null, null],
|
||||||
mangaLinks: {},
|
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 ───────────────────────────────────────────────────────────────
|
// ── Persistence ───────────────────────────────────────────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user