Skip to content
All case studies

Nyora for Linux

A native Linux desktop manga reader that runs a complete server-grade source engine — JavaScript parser sandbox, SQLite store, and image proxy — inside its own GUI process, packaged as a self-contained, runtime-bundled binary that installs on every major distribution with one command.

Stack

  • Kotlin/JVM (2.1.x)
  • Compose Multiplatform for Desktop 1.7.3
  • Material 3 + custom design system
  • GraalVM Polyglot (GraalJS) for JS source parsers
  • SQLDelight (SQLite/JDBC)
  • OkHttp 4.12 + Coil3 image loading
  • Jsoup HTML parsing
  • Supabase (cloud sync)
  • Tesseract 5.x (CLI OCR)
  • Gradle + jpackage/jlink packaging
  • GitHub Actions CI (x86_64 + ARM64 matrix)

Overview

Nyora for Linux is a native desktop manga reader. It brings hundreds of online sources, a full reader, offline downloads, on-image translation, and free Google-backed cloud sync into one application that installs on every major distribution — Debian, Ubuntu, Fedora, RHEL, Rocky, openSUSE, Arch, and more — on both x86_64 (standard Intel/AMD) and arm64 processors. It ships with its own Java runtime inside the package, so there is nothing to install or configure first.

It is built with Compose Multiplatform for Desktop, JetBrains' Kotlin user-interface (UI) toolkit — the desktop counterpart of the framework that powers modern Android apps. But the UI is not the headline achievement. The hard part is what sits behind it. The manga engine was originally written to run as a server: a backend process with a JavaScript sandbox for parsing websites, a SQLite database, an image proxy, and cloud sync. This build folds that entire server into a single graphical desktop process and ships it as a dependency-free binary — on an operating system famous for fragmentation. Linux has no single way to install software, no guaranteed runtime present on a user's machine, and inconsistent display behaviour between desktop environments. Producing one binary that simply works everywhere is the real engineering, and this build does it without forking the engine's data layer.

Technology stack

  • Kotlin/JVM (2.1.21) for the whole application and the shared engine — one language across UI, networking, storage, and parsers, which keeps the team small and the codebase consistent.
  • Compose Multiplatform for Desktop 1.7.3 (Material 3 plus a custom design system) for the UI. The version is force-pinned through a Gradle resolutionStrategy so that no indirect dependency can silently pull in a mismatched Compose runtime — a class of bug that only surfaces at runtime.
  • GraalVM Polyglot (GraalJS) to run the source parsers, which are written in JavaScript rather than Kotlin, inside a controlled sandbox on the JVM.
  • SQLDelight 2.1 over a JDBC SQLite driver for the library, history, and bookmarks store, including a migration path that imports older JSON data into SQL.
  • OkHttp 4.12 for networking, Coil3 for image loading and decoding, and Jsoup 1.17 for parsing the HTML the sources return.
  • Supabase for cross-device cloud sync, and Tesseract 5.x — an open-source OCR engine, invoked as a command-line subprocess — for reading text off pages during translation.
  • Gradle with jpackage/jlink to produce the runtime-bundled installers, driven by a tag-triggered GitHub Actions matrix that builds x86_64 and arm64 on native runners.

Architecture

The manga engine lives in its own private repository, nyora-shared, and is brought into this project as a git submodule compiled by a thin :shared Gradle module. The same engine source powers the macOS and web builds, so a single fix is inherited by three platforms at once.

The pivotal decision is that the desktop app does not reimplement data access inside the UI. Instead, it runs the engine's real server in the same Java process as the graphical interface. At startup, main() calls HelperMain.bootstrap(), which opens the database, runs migrations, configures networking, and starts cloud sync. It then constructs NyoraRestServer — the engine's HTTP server — and calls server.start() on a localhost port inside the GUI process. ("Localhost" means the server is reachable only from this machine, never the network.) The Compose UI then talks to that loopback server through a small OkHttp client, exactly as the separate web client talks to the same server over a real network.

To picture one real action end to end: a user opens a chapter. The reader asks the loopback server for the page list. The server routes that request to the correct JavaScript parser running in the shared GraalJS sandbox, which fetches and scrapes the source website. Each page image is then requested back through the server's image-proxy endpoint, which attaches the right cookies, referer headers, and Cloudflare anti-bot challenge state — context a naive image fetch straight from the UI could never reproduce, which is precisely why the images route through the proxy. Coil3 decodes and caches the returned bytes, and the page appears. The UI is 36 Kotlin files (~15,200 lines) across 18 screens, all driven by a single observable AppState. Because the HTTP contract is identical across macOS, web, and Linux, the data layer never forks; each platform writes only its own native shell.

Hard problems solved

Shipping one binary that runs on every distribution

A Java desktop app normally assumes the user already has a compatible Java runtime installed. On Linux that is an unsafe bet — the runtime may be missing, the wrong version, or a headless build with no graphics. Compose's jpackage/jlink toolchain addresses this by bundling a trimmed-down Java runtime directly into the app image, so the app carries its own. The catch: jlink aggressively removes any runtime module it cannot statically prove is used. The engine reaches several modules only through reflection and JDBC — indirect calls that a static analyser cannot see. The build compiled cleanly, then crashed at launch with NoClassDefFoundError: java/sql/DriverManager: jlink had stripped java.sql (required by the SQLite driver) along with the TLS modules needed for HTTPS. The fix is includeAllModules = true in the native-distribution config, which keeps the full module set, plus disabling ProGuard because GraalJS's reflection-heavy code breaks under bytecode shrinking. The trade-off is a larger image, and for a desktop binary that is the right call — correctness over size.

A one-command installer that doesn't misroute Arch users

The hand-written install.sh is meant to be run as curl ... | bash. It detects the CPU architecture and distribution family, then downloads the matching package from the latest GitHub release. The non-obvious bug it sidesteps: Arch and Manjaro users often have the rpm binary installed purely to build community (AUR) packages, even though their actual package manager is not RPM-based. A naive check like command -v rpm would therefore route those users down the RPM path and fail. The script instead gates the RPM branch strictly on the presence of a real RPM package managerdnf, yum, or zypper — and the Debian branch on apt-get or dpkg. Anything matching neither falls through to a portable .tar.gz that is fully self-sufficient thanks to the bundled runtime. That portable path also writes a freedesktop .desktop launcher and icon so Nyora appears in the application menu immediately, and the app re-registers that entry on first run (ensureLinuxDesktopEntry()) as a backstop for manual extracts.

On-image translation with no first-party OCR engine

Translating text printed inside a manga page first requires OCR — optical character recognition, reading pixels back into characters. The Android build uses Google ML Kit and the macOS build uses Apple Vision; neither exists on the JVM. Rather than ship a stub, the Linux build drives Tesseract as a command-line subprocess in TesseractOcr.kt, and fails soft when the binary is absent — the UI shows "OCR not installed" instead of crashing. The pipeline preprocesses each page (1.5x upscale, grayscale, strong contrast) so text separates from the dotted screentone shading common in manga, runs Tesseract in sparse-text mode (--psm 11), and filters the detected word boxes by confidence and length to discard halftone noise. It then clusters the surviving words into speech bubbles by spatial proximity and drops page-sized "bubbles" that are really artwork. The most interesting step is the repaint. Instead of stamping a crude rectangle over the original text, MangaTranslator ray-casts outward from each text box through the light balloon interior until it hits the dark balloon outline, producing an eight-point polygon that follows the bubble's true shape. It samples the balloon's background colour, repaints the interior, and draws the translated text in a contrasting colour — so the panel layout survives intact. A reader-side language picker selects the right Tesseract model (jpn_vert, chi_sim, kor, and so on) so the model matches the page's script. The build is honest that this OCR ceiling sits below the mobile engines.

Display scaling and layout correctness on Linux

Compose Desktop renders through the JetBrains Runtime and the Skiko graphics layer, and on several Linux setups it mis-reads the display scaling (for example a high Xft.dpi or GDK_SCALE=2) and draws the entire UI at 200%. Because Java's graphics stack reads scaling exactly once, at initialisation, the fix has to run before any graphics class loads: the first lines of main() set sun.java2d.uiScale=1, with a NYORA_UI_SCALE environment variable as an escape hatch for genuine high-density panels. The window is then sized to roughly 88% of the real screen (queried from AWT's Toolkit) and clamped so it never overflows a small display. A separate, systemic layout bug also had to be solved: a scrollable list with no assigned weight claims its parent's full height when placed under a header, pushing content off the bottom of the window. It was fixed by switching the primary scrollable on more than ten screens to Modifier.weight(1f), so each list takes only the leftover space. Finally, Skiko is pinned to 0.9.4.2 across all four runtime variants (macOS and Linux, x64 and arm64) so the native rendering backend always matches the pinned Compose version and never fails to load with UnsatisfiedLinkError.

Keeping the JavaScript sandbox cheap at scale

Hundreds of JavaScript source parsers each need an isolated execution context, so that one misbehaving source cannot corrupt another. The naive approach — a fresh GraalJS engine per source — would recompile the ~400 KB parser bundle every single time, which is both slow and memory-hungry. The engine instead shares one process-wide GraalJS engine across per-source contexts, so the bundle is parsed and compiled once and then reused everywhere. The JVM was then tuned for the combined load of Compose rendering, image decoding, and JavaScript execution: -Xmx768m (a 512 MB heap thrashed the garbage collector and spiked CPU), the G1 garbage collector, string deduplication, ExitOnOutOfMemoryError, and --add-opens java.base/jdk.internal.module=ALL-UNNAMED to grant the polyglot engine the module access it needs. These are evidence-driven settings, documented inline alongside the symptom that motivated each one.

Staying awake without breaking headless environments

A reader should not let the screensaver dim the screen mid-page. Linux has no single, portable "inhibit sleep" API that works across every desktop, so the app instead nudges the mouse pointer by one pixel every 30 seconds, using java.awt.Robot, while the reader is open and the keep-awake setting is on. The complication is that Robot does not exist in headless, container, or Wayland-without-XWayland environments and throws the moment it is constructed. The entire heartbeat is therefore wrapped in runCatching, so it degrades to a complete no-op rather than taking down the launch path. That kind of defensive boundary is what separates a demo from a product people can actually run.

Engineering highlights

  • In-process REST architecture: the engine's real HTTP server runs inside the GUI's Java process, reusing one HTTP contract verbatim across macOS, web, and Linux with zero data-layer fork.
  • One-command curl | bash installer with architecture- and distribution-aware routing, including a specific correctness fix so Arch systems with a stray rpm binary are never misrouted.
  • A runtime-bundled, dependency-free binary, achieved by tracing jlink module stripping down to a precise java.sql and TLS root cause.
  • Tag-driven GitHub Actions pipeline publishing six artifacts per release — .deb, .rpm, and portable .tar.gz, each for x86_64 and arm64 — with arm64 built on a native ubuntu-24.04-arm runner and the private engine submodule wired in through a scoped access token.
  • A fail-soft, layout-preserving OCR translation pipeline with ray-cast, balloon-shaped repaint, on a platform with no first-party OCR engine.
  • Linux-specific rendering hardening: a pre-graphics scale override, real-screen window sizing, a cross-cutting Modifier.weight(1f) overflow fix, and a pinned Skiko runtime.
  • JVM and GraalJS tuning grounded in observed behaviour — a shared engine that compiles the parser bundle once, and heap and garbage-collector flags chosen against measured GC thrash.

What this demonstrates

This build shows the judgment to take an engine designed for one runtime and re-host it cleanly in another — choosing to run the real server in-process rather than fork and reimplement the data layer, which is exactly what keeps three platforms in lockstep. It demonstrates depth across the full stack at once: JVM packaging and module-system internals, GraalJS performance tuning, Compose Desktop and Skiko rendering quirks, native-tool integration through subprocesses, shell-level distribution detection, and an end-to-end release pipeline across a CPU-architecture matrix. Just as telling, the work is honest about its limits — OCR quality below the mobile engines, no AppImage in the shipping flow — and it defends every fragile boundary, from a missing Robot to absent Tesseract to stripped runtime modules, so the product survives contact with the messy reality of Linux desktops. That combination of architectural restraint, low-level debugging, and shippability is the signal of a senior engineer owning a product end to end.