diff --git a/.gitignore b/.gitignore
index 5aaf47f..e73ab2a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -37,5 +37,7 @@ src-tauri/gen/
# --- Flatpak build artifacts ---
build-dir/
repo/
+dist/
+packaging/frontend-dist.tar.gz
*.flatpak
.flatpak-builder/\n# --- Staged sidecar binaries (platform-specific, never commit) ---\nsrc-tauri/binaries/
diff --git a/README.md b/README.md
index 1a41f2b..16069f7 100644
--- a/README.md
+++ b/README.md
@@ -1,46 +1,113 @@
-
-
Moku
-
A fast, minimal manga reader for Suwayomi-Server . Built with Tauri v2 and Svelte.
+
+
+
+
+
+[](https://github.com/Youwes09/Moku/releases/latest)
+[](https://github.com/Youwes09/Moku/releases/latest)
+[](./LICENSE)
+[](https://discord.gg/cfncTbJ2)
+
+
+
+
+
+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
+
+
+
+
---
-## 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`.
-
-> Moku will attempt to launch the server automatically on startup if the `suwayomi-server` binary is on your `PATH`.
+- **Library management** — organize manga into folders, track unread counts, filter by genre
+- **Built-in reader** — single page, long strip, configurable fit modes, customizable keybinds
+- **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
-**Nix (recommended)**
+### Flatpak (Linux, recommended)
+
+Suwayomi-Server and a bundled JRE are included — no separate install needed.
```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:
```nix
-inputs.moku.url = "github:Youwes09/moku";
+inputs.moku.url = "github:Youwes09/Moku";
```
-**From source**
+### Windows
-```bash
-git clone https://github.com/Youwes09/moku
-cd moku
-nix build
-./result/bin/moku
-```
+Download the `.exe` installer from the [releases page](https://github.com/Youwes09/Moku/releases/latest). Suwayomi-Server and a JRE are bundled.
+
+### macOS
+
+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
+**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
nix develop
pnpm install
@@ -54,12 +121,20 @@ pnpm tauri:dev
| | |
|---|---|
| [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 |
| [Crane](https://github.com/ipetkov/crane) | Nix Rust builds |
---
+## Community
+
+Questions, feedback, or just want to hang out — join the Discord.
+
+[](https://discord.gg/cfncTbJ2)
+
+---
+
## License
Distributed under the [Apache 2.0 License](./LICENSE).
@@ -68,4 +143,4 @@ Distributed under the [Apache 2.0 License](./LICENSE).
## Disclaimer
-Moku does not host or distribute any content. The developers have no affiliation with any content providers accessible through connected sources.
+Moku does not host or distribute any content. The developers have no affiliation with any content providers accessible through connected sources.
\ No newline at end of file
diff --git a/docs/banner.svg b/docs/banner.svg
new file mode 100644
index 0000000..b936378
--- /dev/null
+++ b/docs/banner.svg
@@ -0,0 +1,52 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ TAURI v2 · SVELTE 5 · TYPESCRIPT
+
+
+
diff --git a/docs/screenshots/Moku-Discover.png b/docs/screenshots/Moku-Discover.png
new file mode 100644
index 0000000..61bad4f
Binary files /dev/null and b/docs/screenshots/Moku-Discover.png differ
diff --git a/docs/screenshots/Moku-Extensions.png b/docs/screenshots/Moku-Extensions.png
new file mode 100644
index 0000000..f7e8cec
Binary files /dev/null and b/docs/screenshots/Moku-Extensions.png differ
diff --git a/docs/screenshots/Moku-Home.png b/docs/screenshots/Moku-Home.png
new file mode 100644
index 0000000..19274ca
Binary files /dev/null and b/docs/screenshots/Moku-Home.png differ
diff --git a/docs/screenshots/Moku-Preview.png b/docs/screenshots/Moku-Preview.png
new file mode 100644
index 0000000..ff96ba3
Binary files /dev/null and b/docs/screenshots/Moku-Preview.png differ
diff --git a/docs/screenshots/Moku-Reader.png b/docs/screenshots/Moku-Reader.png
new file mode 100644
index 0000000..ca9c5b8
Binary files /dev/null and b/docs/screenshots/Moku-Reader.png differ
diff --git a/docs/screenshots/Moku-Settings.png b/docs/screenshots/Moku-Settings.png
new file mode 100644
index 0000000..4b345e3
Binary files /dev/null and b/docs/screenshots/Moku-Settings.png differ
diff --git a/docs/screenshots/Moku-SplashScreen.png b/docs/screenshots/Moku-SplashScreen.png
new file mode 100644
index 0000000..0b451b0
Binary files /dev/null and b/docs/screenshots/Moku-SplashScreen.png differ
diff --git a/docs/screenshots/Moku-Tracker.png b/docs/screenshots/Moku-Tracker.png
new file mode 100644
index 0000000..518c9e9
Binary files /dev/null and b/docs/screenshots/Moku-Tracker.png differ
diff --git a/packaging/frontend-dist.tar.gz b/packaging/frontend-dist.tar.gz
deleted file mode 100644
index e54fabd..0000000
Binary files a/packaging/frontend-dist.tar.gz and /dev/null differ
diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs
index df52be4..886fb2d 100644
--- a/src-tauri/src/lib.rs
+++ b/src-tauri/src/lib.rs
@@ -274,8 +274,7 @@ fn resolve_server_binary(
}
};
- // Windows: use bundled JRE + JAR
- #[cfg(target_os = "windows")]
+ #[cfg(not(target_os = "macos"))]
{
let bundle_dir = resource_dir.join("binaries").join("suwayomi-bundle");
let jar = bundle_dir.join("bin").join("Suwayomi-Server.jar");
@@ -308,46 +307,27 @@ 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")]
{
- // 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 = [
"suwayomi-server-aarch64-apple-darwin",
"suwayomi-server-x86_64-apple-darwin",
"suwayomi-server",
];
-
for name in &candidates {
- // Check Contents/MacOS/ first (where externalBin sidecars live)
- 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()));
- if p.exists() {
- do_log(log, &format!("[resolve] using macOS candidate: {:?}", p));
- return Ok(ServerInvocation {
- bin: p.to_string_lossy().into_owned(),
- args: vec![],
- working_dir: None,
- });
- }
+ let p = resource_dir.join(name);
+ do_log(log, &format!("[resolve] macOS candidate: {:?} exists={}", p, p.exists()));
+ if p.exists() {
+ do_log(log, &format!("[resolve] using macOS candidate: {:?}", p));
+ return Ok(ServerInvocation {
+ bin: p.to_string_lossy().into_owned(),
+ args: vec![],
+ working_dir: None,
+ });
}
}
}
- // Linux / PATH fallback for all platforms
do_log(log, "[resolve] trying PATH fallback");
for name in &["suwayomi-server", "tachidesk-server"] {
let found = std::process::Command::new("which")
diff --git a/src/App.svelte b/src/App.svelte
index 249a7b4..8485e4a 100644
--- a/src/App.svelte
+++ b/src/App.svelte
@@ -104,10 +104,29 @@
getVersion(),
invoke>("list_releases"),
]);
- if (!releases.length) return;
- const latestTag = releases[0].tag_name.replace(/^v/, "");
- if (latestTag !== currentVersion) {
+ // Filter out drafts / incomplete releases that have no tag_name
+ 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({
kind: "info",
title: `Update available — v${latestTag}`,
diff --git a/src/components/settings/Settings.svelte b/src/components/settings/Settings.svelte
index 360fc5d..c657825 100644
--- a/src/components/settings/Settings.svelte
+++ b/src/components/settings/Settings.svelte
@@ -336,16 +336,26 @@
let dlTotal = $state(null);
let targetTag = $state(null); // tag being installed
+ let releasesLoaded = false; // plain var — not $state, so effect doesn't re-run on change
+
$effect(() => {
if (tab !== "about") return;
getVersion().then(v => appVersion = v).catch(() => appVersion = "unknown");
- if (releases.length === 0 && !releasesLoading) loadReleases();
+ if (!releasesLoaded) { releasesLoaded = true; loadReleases(); }
});
async function loadReleases() {
releasesLoading = true; releasesError = null;
try {
- releases = await invoke("list_releases");
+ const timeout = new Promise((_, reject) =>
+ setTimeout(() => reject(new Error("Request timed out after 10s")), 10_000)
+ );
+ const all = await Promise.race([
+ invoke("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) {
releasesError = e instanceof Error ? e.message : String(e);
} finally {
@@ -358,6 +368,25 @@
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 {
if (!iso) return "";
const d = new Date(iso);
@@ -1044,10 +1073,15 @@
v{appVersion}
+ onclick={() => { releasesError = null; loadReleases(); }} disabled={releasesLoading}>
{releasesLoading ? "Loading…" : "Refresh"}
+ {#if onLatestVersion}
+
+ ✓ You're on the latest version.
+
+ {/if}
{#if updatePhase === "downloading" && IS_WINDOWS}
@@ -1157,6 +1191,7 @@
Links