Skip to content
All case studies

Nyora for Windows

A self-contained Windows manga reader, built from one Kotlin codebase, that ships as native installers for both Intel/AMD and ARM64 machines and translates Japanese pages in place using only the operating system's own text-recognition engine.

Stack

  • Kotlin 2.1.21 (JVM)
  • Compose Multiplatform for Desktop 1.8.2
  • Skiko 0.9.4.2 (windows-x64 + windows-arm64)
  • GraalVM JS / Truffle (shared engine)
  • SQLDelight 2.1.0 (SQLite/JDBC)
  • JNA + jna-platform 5.14.0 (DWM, registry)
  • Windows.Media.Ocr WinRT via PowerShell 5.1
  • Coil3 + OkHttp 4.12 (image loading)
  • Gradle 9.x + jpackage / WiX Toolset v3
  • GitHub Actions (Zulu JDK 17, x64 + windows-11-arm matrix)

Overview

Nyora for Windows is a desktop manga reader built from a single Kotlin codebase. It browses hundreds of online sources, downloads chapters for fully offline reading, and keeps a personal library and per-chapter reading progress in sync across every other Nyora platform through a Google sign-in. It can also translate untranslated Japanese pages in place: it finds the text on the page, translates it, and repaints the English result back over the artwork, inside the original speech bubbles.

It installs as a single self-contained .exe that carries its own Java runtime inside it, so the user installs nothing else. Crucially, it ships for two different kinds of Windows machine: the traditional Intel/AMD desktops and laptops (called x64), and the newer ARM-based laptops (called ARM64, the chips found in recent Surface and Copilot+ devices). There is deliberately no 32-bit build, because the UI toolkit, its graphics layer, and the bundled Java runtime are all 64-bit only.

The headline engineering achievement is making one Java-based application behave like a genuine native Windows program on two completely different processor families. That sounds routine until you hit the catch: the shared parsing engine the app depends on did not actually work on Windows ARM64 at all. It failed in a silent, deeply hidden way that had to be tracked down on real hardware. This is not a thin wrapper around a website. It is a real desktop application with native window styling, an in-process server, and an on-device translation pipeline.

Technology stack

  • Kotlin 2.1.21 on the JVM (the Java Virtual Machine, the runtime that executes Java-family code). One language runs from the user interface all the way down to the source-parsing engine, so there is no language boundary to maintain between the front end and the data layer.
  • Compose Multiplatform for Desktop 1.8.2, a declarative UI toolkit (you describe what the screen should look like and the framework keeps it in sync with the data). It renders through Skiko 0.9.4.2, a binding to the Skia graphics library. Both the windows-x64 and windows-arm64 Skiko runtimes are declared, so each machine gets graphics code compiled for its own processor.
  • nyora-shared, a private Git submodule, is the underlying engine. It packages the JavaScript source parsers (the site-specific adapters that know how to read each manga website), runs them inside the JVM via GraalVM, stores data locally through SQLDelight 2.1.0 over SQLite, and exposes a small loopback REST server. All of it runs inside the same process as the app.
  • JNA / jna-platform 5.14.0 (Java Native Access) to call native Windows APIs — the desktop window manager and the registry — without writing any C code.
  • Windows.Media.Ocr, the operating system's own built-in text-recognition (OCR) engine, reached through a Windows PowerShell 5.1 helper script. This gives on-device text recognition with no machine-learning model shipped in the installer.
  • Coil3 + OkHttp for image loading and HTTP; jpackage with WiX Toolset v3 to produce the bundled-runtime installer; GitHub Actions for dual-architecture release automation.

Architecture

The application has two halves that talk to each other locally. The front end is a Compose Desktop user interface: 39 Kotlin files and 17 full screens covering Library, Explore, a paged Reader, Details, Downloads, Backup, Trackers, Statistics, Global Search, History, and a macOS-style sidebar Settings panel. This front end contains no parsing logic of its own.

When the program launches, main() calls HelperMain.bootstrap(), which opens the local database, runs schema migrations, seeds the catalogue of 235 JavaScript source parsers, wires up cloud sync, and configures networking. It then starts NyoraRestServer on the loopback interface — a small HTTP server bound to the local machine only, reachable at 127.0.0.1 and invisible to the network.

From there, the UI talks to that local server over plain HTTP, exactly as every other Nyora platform does. Picture one real action. The user opens a source and taps a series. The UI sends a request to http://127.0.0.1:<port>/.... The in-process server runs the relevant JavaScript parser inside GraalVM, fetches and parses the remote web page, and returns structured data. Cover images come back as URLs pointing at the same server's image-proxy endpoint, so the front end never has to deal with the remote site's referer checks or hotlink protection — the server handles those headers on its behalf. This separation keeps parsing cleanly apart from rendering, lets the desktop app reuse the exact server contract the mobile and web builds use, and means the entire data layer is shared rather than reimplemented per platform.

Hard problems solved

The engine that silently hung on ARM64 hardware

The problem. On ARM64 Windows laptops, opening any source would hang forever. No error message, no spinner timeout, just a permanent freeze. On x64 machines, the identical build worked perfectly.

Why the obvious approach fails. The parsing engine runs JavaScript through GraalVM, which on first use tries to load a small native helper library matched to the processor. GraalVM 24.x ships that library for macOS-ARM, Linux, and Windows-x64 — but not for Windows ARM64. The file is simply missing from the package. When the engine initialised on that hardware, it threw a java.lang.InternalError. Here is the subtle part: in Java, an Error is a different category from an Exception. They are siblings, not parent and child. The server's catch (Exception) block — the safety net that turns failures into clean error responses — does not catch an Error. So the worker thread handling the request died without ever sending a reply, and the request stayed open indefinitely. The freeze was the absence of any response at all.

The solution. I reproduced it without the GUI: I built a fat JAR of the engine, ran it over SSH on the ARM64 machine, and used curl to hit a parser endpoint and trigger the failure. Then I took a jstack thread dump of the frozen JVM and read its stderr, which finally surfaced the uncaught Error the safety net had missed. The fix is a single line at the very top of main(), before any GraalVM class loads and gated strictly to Windows ARM64: force DefaultTruffleRuntime, GraalVM's interpreter-only mode that needs no native library. x64 keeps its faster JIT-compiled (just-in-time compiled) runtime untouched.

Why it works. The interpreter is slightly slower but loads nothing from disk, which is exactly the right trade for parsing work that spends most of its time waiting on the network anyway. Gating the change to ARM64 means the common architecture loses no performance.

A dependency version trap that made ARM64 unbuildable

The problem. Before the hang could even be reached, the ARM64 build would not compile. The build tool could not find the UI runtime for that architecture.

Why the obvious approach fails. Compose 1.7.3 never published a desktop-jvm-windows-arm64 artifact at all, so the dependency was unresolvable — a hard 404. Upgrading to Compose 1.8.2 fixed that, but the Compose version has to agree in two separate places (the build plugin and the resolved dependencies) or the app crashes at startup with an UnsatisfiedLinkError. The complication: the icon library, material-icons-extended, was frozen at 1.7.3, with no 1.8.x release ever published. So a blanket "force everything to 1.8.2" rule made the icons themselves unresolvable.

The solution. A Gradle resolutionStrategy.eachDependency rule rewrites every org.jetbrains.compose* dependency to 1.8.2 but explicitly carves out anything whose name starts with material-icons, pinning that one library to 1.7.3.

Why it works. The frozen icon vectors are runtime-compatible with the 1.8.2 core, so the single component that has no newer version keeps its old one while everything else moves forward in lockstep. The rule expresses exactly that one exception rather than abandoning the upgrade.

In-place page translation with no model in the installer

The problem. Translating a whole manga page means three things: find every block of Japanese text, translate it, and typeset the English back into the same speech bubbles so it reads naturally. The constraint is that the installer must stay small — no large machine-learning model bundled inside it.

Why the obvious approach fails. A bundled OCR model would add hundreds of megabytes and would still lag behind a maintained system one. Windows already ships a perfectly good text-recognition engine, Windows.Media.Ocr — but it has no Java binding, and it is a modern WinRT (Windows Runtime) API that the cross-platform PowerShell 7 cannot even reach.

The solution. A bundled windows_ocr.ps1 script runs under the older Windows PowerShell 5.1, which can project WinRT types. It loads the recognition engine via the ContentType=WindowsRuntime assembly syntax, bridges the engine's asynchronous call into a synchronous one with AsTask, and emits the detected text boxes as JSON. On the Kotlin side, WindowsOcr.kt grows those raw text lines into speech bubbles with an iterative flood-fill (repeatedly merging nearby lines into one region). It discards page-sized false clusters — anything covering more than 55% of the page area, area > pageArea * 0.55 — and strips the extra spaces Windows inserts between Chinese, Japanese, and Korean (CJK) characters. Finally, MangaTranslator samples each bubble's background colour and ray-casts its interior so the translated text repaints onto the real bubble shape instead of being stamped over a flat rectangle.

Why it works. The heavy lifting stays in an always-updated operating-system component, so the installer ships no model and never goes stale. The clustering and repaint logic is what turns flat, line-by-line OCR output into something that respects the page's actual layout.

Refusing to produce convincing garbage

The problem. Windows OCR only recognises Japanese if the user has installed the Japanese language pack — and by default they have not.

Why the obvious approach fails. The naive code path quietly fell back to the English engine and ran English OCR over Japanese text. That does not fail; it produces plausible-looking nonsense. This is the worst possible outcome, because the user cannot tell the result is wrong and may trust it.

The solution. When a requested language pack is missing, the script refuses to fall back silently. It returns reason: "no-language-pack", which WindowsOcr.kt maps to an on-screen hint telling the user to install the OCR pack, along with the one command that does it.

Why it works. The system fails loudly and usefully at exactly the point where a silent fallback would have wasted the user's time, and it points them straight at the fix. An honest error beats a confident wrong answer.

A bundled runtime that survives first launch

The problem. The first packaged build crashed instantly with NoClassDefFoundError: java/sql/DriverManager and missing TLS (the encryption layer that secures HTTPS) support.

Why the obvious approach fails. jpackage bundles a trimmed Java runtime, and its jlink step strips out any Java module it cannot prove is used by reading the code statically. But the SQLite database driver and the TLS stack are loaded reflectively — looked up by name at runtime — so the static analysis could not see them, and it dropped them.

The solution. Set includeAllModules = true to bundle the full Java module set rather than a guessed subset, keep ProGuard (the code shrinker) disabled because GraalJS's heavy reflection breaks under shrinking, and give the installer a stable upgradeUuid so new versions upgrade in place instead of installing side by side.

Why it works. The app trades a slightly larger installer for one that actually runs, and upgrades cleanly replace old versions instead of piling up.

Reproducible installers for two architectures

The problem. Produce release-quality .exe installers for both x64 and ARM64, from a private submodule, on build toolchains where the standard choices do not exist.

Why the obvious approach fails. The usual JDK distribution (Temurin) has no Windows-aarch64 JDK 17, and the usual way to install the WiX installer tooling (the Chocolatey package) is flaky on the CI runners. Either gap quietly breaks the ARM64 leg.

The solution. A tag-triggered GitHub Actions matrix runs on windows-latest and windows-11-arm. It checks out the private engine submodule with a SUBMODULE_TOKEN, installs JDK 17 from Zulu (which does ship a Windows-aarch64 build), downloads the WiX 3.14 binaries directly onto the PATH instead of via Chocolatey, and publishes through an always()-guarded job so that if one architecture fails, the other still ships.

Why it works. Each awkward part — the missing ARM JDK, the unreliable installer tooling, the private dependency — is pinned to a known-good source, and a single failing leg never blocks the whole release.

Engineering highlights

  • One Kotlin/JVM codebase ships as two genuinely native installers (x64 and ARM64) with no 32-bit fallback, each carrying its own JDK 17 runtime inside the .exe.
  • Diagnosed and fixed an Error-not-Exception hang that reproduced only on real Windows ARM64 hardware — using a headless fat JAR, a triggering curl, a jstack thread dump, and stderr — and resolved it in one architecture-gated line.
  • Native Windows 11 chrome through JNA: a dark immersive title bar (DWMWA_USE_IMMERSIVE_DARK_MODE), the Mica backdrop (DWMWA_SYSTEMBACKDROP_TYPE), live system accent colour and light/dark theme read straight from the registry, and Segoe UI loaded from C:\Windows\Fonts — all OS-guarded and wrapped in runCatching so the same binary still compiles and runs on the macOS/Linux dev host.
  • In-place Japanese-to-English page translation driven entirely by the OS OCR engine, with flood-fill bubble clustering, page-sized false-cluster rejection, and background-sampling repaint — and zero ML model in the installer.
  • Optional AI refinement of the machine translation behind one AiRefiner interface with three pluggable backends: off, on-device Windows AI / Phi Silica for Copilot+ PCs, and any OpenAI-compatible bring-your-own-key endpoint — each best-effort and degrading gracefully when unavailable.
  • A self-upgrading installer pipeline: includeAllModules to survive reflective class loading, ProGuard disabled to protect GraalJS, and a stable MSI upgrade UUID for clean in-place updates.
  • Window size, position, and maximised/snapped state persisted between runs and clamped to the active display, so the app never opens larger than the screen it lands on.

What this demonstrates

This build shows the ability to take a shared cross-platform engine and make it genuinely native and shippable on a second processor architecture — not by waving away the differences, but by working through them. That meant diagnosing a runtime hang that surfaces only on real ARM64 hardware and turning on the right interpreter mode for it; untangling a transitive dependency matrix that made the build unresolvable; bridging an OS-only WinRT API from the JVM with no bundled model; and standing up reproducible dual-architecture installer CI around a private submodule and a set of awkward toolchains. The consistent instinct throughout is systems-level pragmatism: find the smallest correct fix for the hard problem, refuse to emit convincing-but-wrong output, and fail soft everywhere else so one binary still builds and runs across three operating systems.