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}
+
+
+
+

+
+
+
+ {#each Array(store.settings.appLockPin?.length ?? 4) as _, i}
+
+ {/each}
+
+
+
+
+
+ {:else if mode === "idle"}
press any key to continue
+
{:else}
+
{#if !failed && !notConfigured}
-
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 @@