From ac6b70fb3270078beaeab86a0cb81b1ba3463972 Mon Sep 17 00:00:00 2001 From: Youwes09 Date: Thu, 26 Mar 2026 23:21:39 -0500 Subject: [PATCH] Feat: Lock-Feature & Server-Authentication + Experimentals --- dev.moku.app.yml | 2 +- flake.nix | 2 +- packaging/cargo-sources.json | 91 +++++ src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/tauri.conf.json | 2 +- src/App.svelte | 4 +- src/components/layout/SplashScreen.svelte | 172 +++++++-- src/components/settings/Settings.svelte | 441 +++++++++++++++++++++- src/lib/client.ts | 45 +-- src/lib/queries.ts | 88 +++++ src/store/state.svelte.ts | 34 ++ 12 files changed, 816 insertions(+), 69 deletions(-) diff --git a/dev.moku.app.yml b/dev.moku.app.yml index 19d3e4d..8ca9da4 100644 --- a/dev.moku.app.yml +++ b/dev.moku.app.yml @@ -181,7 +181,7 @@ modules: path: . - type: file path: packaging/frontend-dist.tar.gz - sha256: 9f3e4a6b059f4236bf63fcbaa27cca6041257d39940172e5c1bb520c7cdc51e8 + sha256: bceb301e2cf8c20576d910294bb7fd94ea9dab82e7921c7a32f81072a2654c75 - packaging/cargo-sources.json - type: inline dest: src-tauri/.cargo diff --git a/flake.nix b/flake.nix index 095a810..9f008b6 100644 --- a/flake.nix +++ b/flake.nix @@ -18,7 +18,7 @@ perSystem = { system, lib, ... }: let - version = "0.4.0"; + version = "0.4.1"; pkgs = import inputs.nixpkgs { inherit system; diff --git a/packaging/cargo-sources.json b/packaging/cargo-sources.json index 6b7ef93..396da24 100644 --- a/packaging/cargo-sources.json +++ b/packaging/cargo-sources.json @@ -1507,6 +1507,19 @@ "dest": "cargo/vendor/generic-array-0.14.7", "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", "archive-type": "tar-gzip", @@ -2651,6 +2664,19 @@ "dest": "cargo/vendor/new_debug_unreachable-1.0.6", "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", "archive-type": "tar-gzip", @@ -2820,6 +2846,19 @@ "dest": "cargo/vendor/objc2-core-image-0.3.2", "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", "archive-type": "tar-gzip", @@ -2963,6 +3002,19 @@ "dest": "cargo/vendor/objc2-ui-kit-0.3.2", "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", "archive-type": "tar-gzip", @@ -3028,6 +3080,19 @@ "dest": "cargo/vendor/option-ext-0.2.0", "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", "archive-type": "tar-gzip", @@ -4770,6 +4835,19 @@ "dest": "cargo/vendor/synstructure-0.13.2", "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", "archive-type": "tar-gzip", @@ -4965,6 +5043,19 @@ "dest": "cargo/vendor/tauri-plugin-http-2.5.7", "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", "archive-type": "tar-gzip", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 5f96a02..5041cb0 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1999,7 +1999,7 @@ dependencies = [ [[package]] name = "moku" -version = "0.4.0" +version = "0.4.1" dependencies = [ "dirs 5.0.1", "serde", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index b82bc12..d733a68 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "moku" -version = "0.4.0" +version = "0.4.1" edition = "2021" [lib] diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index f44333f..621f10a 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Moku", - "version": "0.4.0", + "version": "0.4.1", "identifier": "dev.moku.app", "build": { "frontendDist": "../dist", diff --git a/src/App.svelte b/src/App.svelte index 2982725..c8e1b69 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -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 @@
{#if idle && !store.activeChapter} setTimeout(() => idle = false, 340)} /> + onDismiss={() => { idle = false; resetIdle(); }} /> {/if} {#if !store.activeChapter && !store.isFullscreen}{/if}
diff --git a/src/components/layout/SplashScreen.svelte b/src/components/layout/SplashScreen.svelte index 70967c0..c5739a3 100644 --- a/src/components/layout/SplashScreen.svelte +++ b/src/components/layout/SplashScreen.svelte @@ -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)); -
+
{#if showCards} {#if showFps} @@ -289,7 +345,23 @@ {/if} {/if} - {#if mode === "idle"} + {#if mode === "idle" && lockEnabled} +
+
+
+ Moku +
+
+
+ {#each Array(store.settings.appLockPin?.length ?? 4) as _, i} +
+ {/each} +
+ +
+
+ + {:else if mode === "idle"}
@@ -297,38 +369,64 @@

press any key to continue

+ {:else} +
{#if !failed && !notConfigured} - + - + {/if} Moku

moku

-
- {#if notConfigured} -
-

Server not configured

-

Set the server path in Settings, then retry

-
- - + + +
+ + +
+ {#if notConfigured} +
+

Server not configured

+

Set the server path in Settings, then retry

+
+ + +
+ {:else if failed} +
+

Could not reach Suwayomi

+

Make sure tachidesk-server is on your PATH

+ +
+ {:else} +

{ringFull ? "" : `Initializing server${dots}`}

+ {/if} +
+ + + {#if lockEnabled} +
+
+ {#each Array(store.settings.appLockPin?.length ?? 4) as _, i} +
+ {/each} +
+
- {:else if failed} -
-

Could not reach Suwayomi

-

Make sure tachidesk-server is on your PATH

- -
- {:else} -

- {ringFull ? "Ready" : `Initializing server${dots}`} -

{/if} +
{/if}
@@ -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; } diff --git a/src/components/settings/Settings.svelte b/src/components/settings/Settings.svelte index c657825..398017b 100644 --- a/src/components/settings/Settings.svelte +++ b/src/components/settings/Settings.svelte @@ -1,12 +1,12 @@