Chore: README Update

This commit is contained in:
Youwes09
2026-03-23 19:18:27 -05:00
parent 6d85be751a
commit b23292cff5
15 changed files with 218 additions and 55 deletions
+2
View File
@@ -37,5 +37,7 @@ src-tauri/gen/
# --- Flatpak build artifacts --- # --- Flatpak build artifacts ---
build-dir/ build-dir/
repo/ repo/
dist/
packaging/frontend-dist.tar.gz
*.flatpak *.flatpak
.flatpak-builder/\n# --- Staged sidecar binaries (platform-specific, never commit) ---\nsrc-tauri/binaries/ .flatpak-builder/\n# --- Staged sidecar binaries (platform-specific, never commit) ---\nsrc-tauri/binaries/
+93 -18
View File
@@ -1,46 +1,113 @@
<div align="center"> <div align="center">
<img src="src/assets/moku-icon.svg" width="96" /> <img src="docs/banner.svg" width="100%" alt="Moku" />
<h1>Moku</h1> </div>
<p>A fast, minimal manga reader for <a href="https://github.com/Suwayomi/Suwayomi-Server">Suwayomi-Server</a>.<br/>Built with Tauri v2 and Svelte.</p>
<div align="center">
[![release](https://img.shields.io/github/v/release/Youwes09/Moku?style=flat&color=a8c4a8&labelColor=151515)](https://github.com/Youwes09/Moku/releases/latest)
[![downloads](https://img.shields.io/github/downloads/Youwes09/Moku/total?style=flat&color=a8c4a8&labelColor=151515)](https://github.com/Youwes09/Moku/releases/latest)
[![license](https://img.shields.io/github/license/Youwes09/Moku?style=flat&color=a8c4a8&labelColor=151515)](./LICENSE)
[![discord](https://img.shields.io/discord/1485151759511978077?style=flat&color=a8c4a8&labelColor=151515&label=discord)](https://discord.gg/cfncTbJ2)
</div>
<br/>
Moku is a fast, minimal manga reader frontend for [Suwayomi-Server](https://github.com/Suwayomi/Suwayomi-Server). It wraps Suwayomi's GraphQL API in a native desktop app — no browser required, no Electron overhead.
---
## Screenshots
<div align="center">
<img src="docs/screenshots/Moku-Home.png" width="49%" alt="Home" />
<img src="docs/screenshots/Moku-Discover.png" width="49%" alt="Discover" />
<img src="docs/screenshots/Moku-Reader.png" width="49%" alt="Reader" />
<img src="docs/screenshots/Moku-Preview.png" width="49%" alt="Preview" />
<img src="docs/screenshots/Moku-Tracker.png" width="49%" alt="Tracker" />
<img src="docs/screenshots/Moku-Settings.png" width="49%" alt="Settings" />
</div>
<div align="center">
<a href="docs/screenshots">View all screenshots →</a>
</div> </div>
--- ---
## Requirements ## Features
[Suwayomi-Server](https://github.com/Suwayomi/Suwayomi-Server) must be running. By default Moku expects it at `http://127.0.0.1:4567`. - **Library management** — organize manga into folders, track unread counts, filter by genre
- **Built-in reader** — single page, long strip, configurable fit modes, customizable keybinds
> Moku will attempt to launch the server automatically on startup if the `suwayomi-server` binary is on your `PATH`. - **Extension support** — install and manage Suwayomi extensions directly from the app
- **Download management** — queue and monitor chapter downloads with progress toasts
- **Tracker integration** — sync reading progress with AniList, MyAnimeList, Kitsu, and more
- **Auto-start server** — optionally launch Suwayomi in the background on startup
- **Multiple themes** — Dark, Light, Midnight, Warm, High Contrast, and more
- **Auto-updates** — in-app update checker with silent background notifications
--- ---
## Installation ## Installation
**Nix (recommended)** ### Flatpak (Linux, recommended)
Suwayomi-Server and a bundled JRE are included — no separate install needed.
```bash ```bash
nix run github:Youwes09/moku flatpak install moku.flatpak
flatpak run dev.moku.app
```
Download the latest `moku.flatpak` from the [releases page](https://github.com/Youwes09/Moku/releases/latest).
### Nix
```bash
nix run github:Youwes09/Moku
``` ```
Add to your flake: Add to your flake:
```nix ```nix
inputs.moku.url = "github:Youwes09/moku"; inputs.moku.url = "github:Youwes09/Moku";
``` ```
**From source** ### Windows
```bash Download the `.exe` installer from the [releases page](https://github.com/Youwes09/Moku/releases/latest). Suwayomi-Server and a JRE are bundled.
git clone https://github.com/Youwes09/moku
cd moku ### macOS
nix build
./result/bin/moku Download the `.dmg` from the [releases page](https://github.com/Youwes09/Moku/releases/latest).
```
> **Note:** Builds are ad-hoc signed. On first launch you may need to run:
> ```bash
> xattr -rd com.apple.quarantine /Applications/Moku.app
> ```
---
## Requirements
If you're not using the bundled Flatpak or Windows installer, [Suwayomi-Server](https://github.com/Suwayomi/Suwayomi-Server) must be running separately. By default Moku connects to `http://127.0.0.1:4567`.
You can point Moku at any Suwayomi instance — local or remote — via **Settings → General → Server URL**.
--- ---
## Development ## Development
**Prerequisites:** [Rust](https://rustup.rs), [Node.js](https://nodejs.org), [pnpm](https://pnpm.io), and [Tauri v2 prerequisites](https://tauri.app/start/prerequisites/).
```bash
git clone https://github.com/Youwes09/Moku
cd Moku
pnpm install
pnpm tauri:dev
```
Or with Nix:
```bash ```bash
nix develop nix develop
pnpm install pnpm install
@@ -54,12 +121,20 @@ pnpm tauri:dev
| | | | | |
|---|---| |---|---|
| [Tauri v2](https://tauri.app) | Native app shell | | [Tauri v2](https://tauri.app) | Native app shell |
| [Svelte](https://svelte.dev) + [TypeScript](https://www.typescriptlang.org) | UI | | [Svelte 5](https://svelte.dev) + [TypeScript](https://www.typescriptlang.org) | UI |
| [Vite](https://vitejs.dev) | Frontend bundler | | [Vite](https://vitejs.dev) | Frontend bundler |
| [Crane](https://github.com/ipetkov/crane) | Nix Rust builds | | [Crane](https://github.com/ipetkov/crane) | Nix Rust builds |
--- ---
## Community
Questions, feedback, or just want to hang out — join the Discord.
[![Discord](https://img.shields.io/discord/1485151759511978077?style=for-the-badge&color=a8c4a8&labelColor=151515&label=Join+the+Discord)](https://discord.gg/cfncTbJ2)
---
## License ## License
Distributed under the [Apache 2.0 License](./LICENSE). Distributed under the [Apache 2.0 License](./LICENSE).
+52
View File
@@ -0,0 +1,52 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 320" width="1280" height="320">
<defs>
<linearGradient id="leafHero" x1="0.3" y1="0" x2="0.7" y2="1">
<stop offset="0%" stop-color="#52b888"/>
<stop offset="100%" stop-color="#1e5840"/>
</linearGradient>
<clipPath id="roundedBounds">
<rect width="1280" height="320" rx="18" ry="18"/>
</clipPath>
</defs>
<g clip-path="url(#roundedBounds)">
<rect width="1280" height="320" fill="#070e09"/>
<!-- Icon — rotate(7) from moku-icon-splash.svg -->
<g transform="translate(640, 148) rotate(7) scale(0.065,-0.065) translate(-5000,-4800)"
fill="url(#leafHero)" opacity="0.97">
<path d="M4908 6615 c-597 -629 -935 -1264 -934 -1755 0 -508 195 -778 790
-1098 l194 -104 22 -114 c33 -164 76 -271 140 -347 80 -94 106 -62 77 94 -47
255 43 443 294 613 797 539 714 1392 -250 2551 -230 278 -224 275 -333 160z
m52 -945 c0 -472 -8 -850 -17 -850 -36 0 -693 703 -692 740 2 48 167 321 309
509 175 233 359 451 380 451 13 0 20 -304 20 -850z m183 775 c120 -126 357
-447 356 -482 0 -18 -47 -83 -105 -144 -57 -61 -151 -163 -208 -225 -151 -166
-146 -180 -146 406 0 286 7 520 16 520 9 0 48 -34 87 -75z m541 -750 c86 -150
196 -391 196 -430 0 -8 -25 -42 -55 -75 -31 -33 -149 -163 -264 -290 -339
-374 -480 -520 -501 -520 -13 0 -20 167 -20 465 l1 465 151 170 c83 94 193
217 244 275 122 138 137 135 248 -60z m-1098 -633 l374 -378 0 -462 c0 -254
-5 -462 -11 -462 -6 0 -55 23 -110 50 l-99 51 0 208 c0 259 -11 288 -176 457
-196 200 -188 199 -326 62 l-117 -116 -35 79 c-71 161 -39 569 64 816 42 100
20 116 436 -305z m1349 23 c109 -390 -87 -848 -475 -1111 -207 -139 -305 -262
-338 -420 -7 -31 -21 -56 -32 -56 -36 -1 -46 86 -48 405 l-2 313 123 137 c68
75 214 236 325 357 454 493 421 466 447 375z m-1292 -785 c12 -29 15 -118 7
-215 l-14 -166 -73 44 c-174 104 -403 341 -403 416 0 15 47 70 105 123 l104
98 127 -125 c70 -69 136 -147 147 -175z"/>
</g>
<!-- Stack text pinned to bottom -->
<text
x="640" y="300"
text-anchor="middle"
font-family="'SF Mono', 'JetBrains Mono', 'Fira Code', monospace"
font-size="14"
letter-spacing="5"
fill="#a8c4a8"
opacity="0.32">TAURI v2 · SVELTE 5 · TYPESCRIPT</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 914 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 648 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 609 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.
+2 -22
View File
@@ -274,8 +274,7 @@ fn resolve_server_binary(
} }
}; };
// Windows: use bundled JRE + JAR #[cfg(not(target_os = "macos"))]
#[cfg(target_os = "windows")]
{ {
let bundle_dir = resource_dir.join("binaries").join("suwayomi-bundle"); let bundle_dir = resource_dir.join("binaries").join("suwayomi-bundle");
let jar = bundle_dir.join("bin").join("Suwayomi-Server.jar"); let jar = bundle_dir.join("bin").join("Suwayomi-Server.jar");
@@ -308,32 +307,15 @@ fn resolve_server_binary(
} }
} }
// macOS: use the arch-specific sidecar shell script registered via externalBin.
// Tauri's shell plugin resolves externalBin names relative to Contents/MacOS/,
// so we look there directly rather than in resource_dir.
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
{ {
// Contents/MacOS/ is the parent of resource_dir's parent on macOS:
// resource_dir = Moku.app/Contents/Resources
// macos_dir = Moku.app/Contents/MacOS
let macos_dir = resource_dir
.parent() // Contents/
.map(|p| p.join("MacOS"))
.unwrap_or_else(|| resource_dir.clone());
let candidates = [ let candidates = [
"suwayomi-server-aarch64-apple-darwin", "suwayomi-server-aarch64-apple-darwin",
"suwayomi-server-x86_64-apple-darwin", "suwayomi-server-x86_64-apple-darwin",
"suwayomi-server", "suwayomi-server",
]; ];
for name in &candidates { for name in &candidates {
// Check Contents/MacOS/ first (where externalBin sidecars live) let p = resource_dir.join(name);
let in_macos = macos_dir.join(name);
// Also check resource_dir root as a fallback
let in_resources = resource_dir.join(name);
for p in &[in_macos, in_resources] {
do_log(log, &format!("[resolve] macOS candidate: {:?} exists={}", p, p.exists())); do_log(log, &format!("[resolve] macOS candidate: {:?} exists={}", p, p.exists()));
if p.exists() { if p.exists() {
do_log(log, &format!("[resolve] using macOS candidate: {:?}", p)); do_log(log, &format!("[resolve] using macOS candidate: {:?}", p));
@@ -345,9 +327,7 @@ fn resolve_server_binary(
} }
} }
} }
}
// Linux / PATH fallback for all platforms
do_log(log, "[resolve] trying PATH fallback"); do_log(log, "[resolve] trying PATH fallback");
for name in &["suwayomi-server", "tachidesk-server"] { for name in &["suwayomi-server", "tachidesk-server"] {
let found = std::process::Command::new("which") let found = std::process::Command::new("which")
+22 -3
View File
@@ -104,10 +104,29 @@
getVersion(), getVersion(),
invoke<Array<{ tag_name: string; html_url: string }>>("list_releases"), invoke<Array<{ tag_name: string; html_url: string }>>("list_releases"),
]); ]);
if (!releases.length) return;
const latestTag = releases[0].tag_name.replace(/^v/, ""); // Filter out drafts / incomplete releases that have no tag_name
if (latestTag !== currentVersion) { const valid = releases.filter(r => typeof r.tag_name === "string" && r.tag_name.trim());
if (!valid.length) return;
const parse = (tag: string): number[] =>
tag.replace(/^v/, "").split(".").map(Number);
const compare = (a: number[], b: number[]): number => {
for (let i = 0; i < 3; i++) {
if ((a[i] ?? 0) !== (b[i] ?? 0)) return (b[i] ?? 0) - (a[i] ?? 0);
}
return 0;
};
const latestTag = valid
.map(r => r.tag_name)
.sort((a, b) => compare(parse(a), parse(b)))[0]
.replace(/^v/, "");
// Only toast if latest is strictly newer than installed
const isNewer = compare(parse(latestTag), parse(currentVersion)) < 0;
if (isNewer) {
addToast({ addToast({
kind: "info", kind: "info",
title: `Update available — v${latestTag}`, title: `Update available — v${latestTag}`,
+38 -3
View File
@@ -336,16 +336,26 @@
let dlTotal = $state<number | null>(null); let dlTotal = $state<number | null>(null);
let targetTag = $state<string | null>(null); // tag being installed let targetTag = $state<string | null>(null); // tag being installed
let releasesLoaded = false; // plain var — not $state, so effect doesn't re-run on change
$effect(() => { $effect(() => {
if (tab !== "about") return; if (tab !== "about") return;
getVersion().then(v => appVersion = v).catch(() => appVersion = "unknown"); getVersion().then(v => appVersion = v).catch(() => appVersion = "unknown");
if (releases.length === 0 && !releasesLoading) loadReleases(); if (!releasesLoaded) { releasesLoaded = true; loadReleases(); }
}); });
async function loadReleases() { async function loadReleases() {
releasesLoading = true; releasesError = null; releasesLoading = true; releasesError = null;
try { try {
releases = await invoke<ReleaseInfo[]>("list_releases"); const timeout = new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error("Request timed out after 10s")), 10_000)
);
const all = await Promise.race([
invoke<ReleaseInfo[]>("list_releases"),
timeout,
]);
// Filter out drafts / incomplete entries with no tag_name
releases = all.filter(r => typeof r.tag_name === "string" && r.tag_name.trim());
} catch (e: any) { } catch (e: any) {
releasesError = e instanceof Error ? e.message : String(e); releasesError = e instanceof Error ? e.message : String(e);
} finally { } finally {
@@ -358,6 +368,25 @@
function isCurrentVersion(tag: string) { return stripV(tag) === appVersion; } function isCurrentVersion(tag: string) { return stripV(tag) === appVersion; }
function parseSemver(v: string): number[] {
return stripV(v).split(".").map(Number);
}
function compareSemver(a: string, b: string): number {
const pa = parseSemver(a), pb = parseSemver(b);
for (let i = 0; i < 3; i++) {
if ((pa[i] ?? 0) !== (pb[i] ?? 0)) return (pb[i] ?? 0) - (pa[i] ?? 0);
}
return 0;
}
// True when releases are loaded and installed version is >= highest published tag
let onLatestVersion = $derived((() => {
if (releasesLoading || releases.length === 0 || !appVersion || appVersion === "…") return false;
const sorted = releases.slice().sort((a, b) => compareSemver(a.tag_name, b.tag_name));
return compareSemver(appVersion, sorted[0].tag_name) >= 0;
})());
function fmtDate(iso: string): string { function fmtDate(iso: string): string {
if (!iso) return ""; if (!iso) return "";
const d = new Date(iso); const d = new Date(iso);
@@ -1044,10 +1073,15 @@
<span class="toggle-desc">v{appVersion}</span> <span class="toggle-desc">v{appVersion}</span>
</div> </div>
<button class="step-btn" style="width:auto;padding:0 var(--sp-3);font-size:var(--text-xs);letter-spacing:var(--tracking-wide)" <button class="step-btn" style="width:auto;padding:0 var(--sp-3);font-size:var(--text-xs);letter-spacing:var(--tracking-wide)"
onclick={loadReleases} disabled={releasesLoading}> onclick={() => { releasesError = null; loadReleases(); }} disabled={releasesLoading}>
{releasesLoading ? "Loading…" : "Refresh"} {releasesLoading ? "Loading…" : "Refresh"}
</button> </button>
</div> </div>
{#if onLatestVersion}
<div class="step-row">
<span class="toggle-desc" style="padding:0 var(--sp-3);color:var(--accent-fg)">✓ You're on the latest version.</span>
</div>
{/if}
<!-- active download progress --> <!-- active download progress -->
{#if updatePhase === "downloading" && IS_WINDOWS} {#if updatePhase === "downloading" && IS_WINDOWS}
@@ -1157,6 +1191,7 @@
<p class="section-title">Links</p> <p class="section-title">Links</p>
<div class="about-block"> <div class="about-block">
<a href="https://github.com/Youwes09/Moku" target="_blank" class="about-line" style="color:var(--accent-fg);text-decoration:none">GitHub →</a> <a href="https://github.com/Youwes09/Moku" target="_blank" class="about-line" style="color:var(--accent-fg);text-decoration:none">GitHub →</a>
<a href="https://discord.gg/cfncTbJ2" target="_blank" class="about-line" style="color:var(--accent-fg);text-decoration:none;margin-top:var(--sp-1)">Discord →</a>
</div> </div>
</div> </div>