Skip to content
All case studies

Nyora for iOS & iPadOS

A native SwiftUI manga reader for iPhone and iPad that runs the same 11,507-line JavaScript source-parser engine as its sibling apps inside a hidden web view, and typesets whole-page AI translation back over the original artwork entirely on-device.

Stack

  • Swift 5.9
  • SwiftUI (iOS/iPadOS 17+)
  • WKWebView + JavaScriptCore (JS parser host)
  • URLSession (async/await networking)
  • Apple Vision (OCR)
  • Core Image (page preprocessing)
  • FoundationModels / Apple Intelligence (on-device LLM refine, iOS 26+)
  • WidgetKit
  • Supabase + GoogleSignIn (id_token auth)
  • Swift Package Manager (NyoraEngine)
  • Xcode 16 / xcodebuild CI (unsigned IPA)

Overview

Nyora for iOS and iPadOS is a native manga reader for iPhone and iPad, built from scratch on iOS 17 and later. It is one member of a six-platform family — Android, iOS, macOS, Windows, Linux, and the web — that all share a single synced library, so a reader can stop on one device and resume on another. The app offers hundreds of on-device content sources, whole-page AI translation laid over the original artwork, offline downloads, reading-progress trackers, Home-Screen widgets, and free cloud sync. There are no ads, no analytics trackers, and no account is required just to read. The codebase is roughly 28,500 lines of Swift across 180 files, organized into 20 feature modules.

The headline engineering decision is worth stating plainly. The hardest, most fragile part of any app like this is the layer that talks to dozens of independent third-party websites and turns their messy, ever-changing HTML into a clean list of titles, chapters, and image addresses. Those sites change their markup constantly, so this source-parser layer needs frequent fixes. The obvious approach is to write and maintain a separate native version of that layer for every operating system — six copies that must all be patched whenever a site breaks. Nyora refuses that cost. It reuses the exact same JavaScript parsers that power its web build, running an 11,507-line parser bundle inside a hidden WKWebView (an off-screen, invisible browser engine). One parser codebase serves all six platforms. That single bet drives most of the hard problems described below.

Technology stack

Everything the reader sees is native SwiftUI, Apple's modern declarative UI framework. It was chosen so the app behaves like a first-class iOS app and reflows cleanly between the narrow iPhone layout and the roomier iPad layout. Networking uses async/await URLSession, Apple's built-in HTTP stack. Source parsing runs in JavaScriptCore, hosted by WKWebView — the JavaScript parsers are already written, shared, and proven across other platforms, so rewriting them natively would only double the maintenance burden. The translation pipeline combines Apple Vision for on-device text recognition (OCR, turning pixels into characters), Core Image for page preprocessing, and FoundationModels (Apple Intelligence) for on-device language refinement on iOS 26 and newer. WidgetKit powers the Home-Screen widgets. Cloud sync uses GoogleSignIn to obtain an identity token, which is exchanged for a session in Supabase, a hosted Postgres backend. A Swift Package, NyoraEngine, supplies the shared domain model, the source registry, and the Cloudflare-solving layer. Continuous integration is plain xcodebuild on a macOS runner, producing an unsigned, sideloadable IPA — the app ships outside the App Store.

Architecture

The app splits into three clean layers. The presentation layer is SwiftUI views: twelve top-level screens — Library, Explore, History, Updates, global search, manga detail, the reader, settings, and more — plus the 20 feature modules, all of which adapt between iPhone and iPad. The engine layer is JSParserEngine, a @MainActor singleton that owns one hidden 1×1-pixel WKWebView. On startup it injects a bridge script before any page loads, loads the parser bundle, and exposes a single typed entry point, runParser(method:sourceId:args:), that returns JSON. The services layer handles networking, the Cloudflare solver, the translation pipeline, Supabase sync, and an over-the-air (OTA) updater.

Here is one real action followed end to end. A reader opens a title's page. The detail view calls runParser(method: "getDetails", …). JSParserEngine base64-encodes the arguments, mints a unique token (a short identifier used to match a question with its answer), and fires a fire-and-forget call into the web view: void window.__runParser(token, …). The JavaScript parser runs. Whenever it needs to fetch a web page it cannot reach from inside the browser sandbox, it calls a bridge function that hands the request back to native Swift, which performs the real HTTP request with the correct headers and returns the body. When the parser finishes, it posts its JSON result back over a message channel keyed by that token, which resolves the original Swift call. The view decodes the JSON into the shared domain model and renders the chapters. Every catalog interaction — browse, search, chapter list, page list — flows through this same bridge.

One common assumption is worth correcting precisely. The repository does contain pure-Swift parser code, inside the NyoraEngine and KotatsuEngine packages, but the live catalog path does not use it for HTML parsing — it runs the JavaScript bundle. NyoraEngine earns its place as the domain model and the Cloudflare-solving layer instead. The underlying open-source Kotatsu parser engine is a technical dependency that defines the parser contract; Nyora is its own product built around it.

Hard problems solved

1. The standard way to call async JavaScript silently returns nothing

To read a result out of an asynchronous JavaScript function, WebKit offers an official API named callAsyncJavaScript. On this iOS WebKit build, that API has a defect: it returns as soon as the synchronous start of the function has run, and discards the value the function eventually produces. The result is that every parser call came back empty — and with no error to explain why. In plain terms, it is like asking a question, getting an instant reply, and that reply always being blank because the real work had not finished yet. The obvious fixes do not help: wrapping the call, polling for a result, or restructuring the JavaScript all founder on the same flaw, because the API itself drops the awaited value before it exists.

The solution is a fully message-driven bridge that abandons the broken API. Swift fires void window.__runParser(token, …) using evaluateJavaScript; the void keyword tells WebKit not to try to serialize the returned Promise — the very operation it cannot do. The JavaScript then runs to completion on its own and posts {token, ok, json} back through a dedicated nyoraResult message handler. That handler looks up a Swift CheckedContinuation (a suspended async call waiting to be resumed) keyed by the token, and resumes it with the JSON. A 45-second safety timeout reclaims any continuation that a hung source would otherwise leak forever. This works because message passing is something WebKit handles reliably, and it keeps a clean async Swift signature for every caller despite the machinery underneath.

2. Giving sandboxed JavaScript real network access

The parsers must fetch from and POST to arbitrary third-party domains and await the responses. But the web view is hosted at a local origin, nyora.local, and the browser's own security rules forbid a page from reaching out to other domains — the same protection that stops a malicious site from quietly calling your bank. You cannot simply switch that protection off in a hosted web view, and routing everything through a relay server would defeat the entire point of parsing on-device.

Instead, the injected bridge replaces the parsers' HTTP primitives — __context.httpGet and httpPost — with functions that post the request to a native nyoraHttp handler. Swift performs the real request with the correct User-Agent, Referer, Origin, and X-Requested-With headers, matching the contract the JavaScript already expects from the Android build, then resolves the JavaScript Promise via window.__resolveHttp(id, ok, base64Payload). Payloads are base64-encoded across the bridge so that binary data and multi-byte UTF-8 text survive the round trip intact, and an argument-count shim reproduces Android's overloaded httpGet signature so the shared parser code runs byte-for-byte unchanged. It works because the network call genuinely happens in trusted native code with full header control, while the JavaScript sees nothing more exotic than an ordinary awaitable fetch.

3. Clearing Cloudflare with a plain URLSession

Many sources sit behind Cloudflare, a service that interrupts a plain request with a "Just a moment…" challenge page. A header-only HTTP client can never pass it, because clearing the challenge requires running JavaScript and sometimes a human tap. No amount of header spoofing on a bare request will satisfy a check that is specifically designed to tell a real browser from a script.

The answer is a two-tier solver. It first detects the challenge — by HTTP status combined with cf-mitigated and Turnstile markers — then runs the page in a headless WKWebView, letting the challenge JavaScript execute and harvesting the resulting cf_clearance cookie into HTTPCookieStorage.shared, the shared cookie jar that URLSession automatically consults on later requests. For managed or Turnstile challenges that demand a tap, it escalates to a visible, interactive SwiftUI sheet, CloudflareChallengeView. Concurrent solves for the same host are coalesced into one run, and the original request is retried exactly once to prevent infinite loops. It works because the browser engine does the one part only a browser can do, and its hard-won cookie is then shared back into the fast native HTTP path.

4. Reader pages rendering blank

The pinch-to-zoom page view embeds an older-style UIKit UIScrollView and UIImageView inside a SwiftUI UIViewRepresentable — the standard adapter for putting UIKit views into SwiftUI. Images came up blank. The cause was a timing race, not a network or decoding fault, which is exactly what makes it easy to misdiagnose. The fit-and-center layout originally ran in updateUIView, but SwiftUI handed that method zero-size bounds on the first pass and never called it again once the real bounds arrived. Every image was therefore scaled to fit a 0×0 frame and drew as nothing.

The fix moves the fit-and-center logic into a UIScrollView subclass's layoutSubviews — a method the system does call again when real bounds arrive — guarded by a needsRefit flag so the image re-fits whenever it changes. As a second detail, panning is disabled at 1× zoom, so a horizontal swipe still flips pages in the parent paged TabView instead of being swallowed by the scroll view. It works because it hooks the correct point in the view's actual layout lifecycle, rather than fighting SwiftUI's update timing from the outside.

5. Whole-page translation, entirely on-device

Translating a comic page well is far more than running OCR and feeding the text to a translator. You must work out which scattered text fragments belong to the same speech bubble, read vertical Japanese in the correct order, and typeset the translation back over the art — all without sending a single page to a server. Naive OCR returns a jumble of loose fragments in arbitrary order, which would produce nonsense bubbles and broken sentences.

The pipeline, an actor named MangaTranslator, preprocesses each page in Core Image, then runs an ensemble of parallel Vision OCR passes across Japanese, Chinese, Korean, and English, scoring each candidate by recognized length plus a bonus for CJK characters to pick the right script automatically. It tiles tall webtoon strips with overlap so no text is lost at a seam between tiles, clusters the fragments into bubbles using 8-direction ray-casting combined with a connected-components graph search, and reads vertical Japanese in right-to-left column order. Results stream through an AsyncStream in three stages — translating, then machine-translated, then Apple-Intelligence-refined — so the overlay fills in progressively and still works, in a simpler form, on devices below iOS 26. It works because the geometry of the page is solved explicitly rather than hoping OCR returns usable order, and the staged stream keeps the interface responsive while heavy machine-learning work runs in the background.

6. Patching sources without an App Store

Because the app is sideloaded, there is no App Store update channel to push the frequent parser fixes that broken sources demand. Waiting for users to reinstall a new build by hand would leave broken sources broken for days.

The answer is a fallback-first OTA updater. It checks a remote manifest, verifies a newer parser bundle and source catalog with a sha256 checksum, writes them to disk atomically, and applies them on the next launch — both files or neither. That all-or-nothing rule means an unreachable or corrupt manifest can never leave the app in a half-updated, unparseable state. Tolerant JS_-prefix resolution keeps libraries synced from other platforms mapped to the correct on-device parser. It works because the atomic write plus checksum verification turns a bad update into a harmless no-op rather than an outage.

Engineering highlights

  • Built a continuation-based Swift-to-JavaScript bridge over a defective WebKit API, with per-call tokens, a 45-second timeout, and base64 transport that preserves multi-byte and binary payloads.
  • Reused a single 11,507-line JavaScript parser bundle across six platforms instead of writing and maintaining a separate native parser layer per operating system.
  • Shipped a two-tier Cloudflare solver that harvests cf_clearance from a headless web view into URLSession's shared cookie store, with per-host request coalescing and exactly-once retry.
  • Diagnosed and fixed a SwiftUI/UIKit zero-bounds layout race by relocating fit logic into layoutSubviews, restoring previously blank reader pages.
  • Implemented a fully on-device translation pipeline — a Vision OCR ensemble, webtoon tiling, ray-cast bubble clustering, vertical-Japanese ordering, and three-stage AsyncStream refinement — at parity with the Android build.
  • Stood up secrets-injected xcodebuild CI producing an unsigned, sideloadable IPA, plus a checksum-verified atomic OTA updater for out-of-store source patching.
  • Culled the catalog to 121 verified on-device sources from an initial 386, and wired free cross-platform sync via GoogleSignIn identity tokens exchanged into Supabase.

What this demonstrates

This body of work shows an engineer who can diagnose undocumented platform bugs that surface with no error message at all — a WebKit API that silently drops results, a layout race that renders blank pages — and then design clean, well-bounded async contracts across a Swift-to-JavaScript divide instead of papering over the seams. It pairs deep platform knowledge — WebKit internals, the UIKit layout lifecycle, Vision and Apple Intelligence, WidgetKit, cookie handling, and CI plumbing — with system-level judgment: choosing to run one shared parser engine across six platforms, and making OTA updates fail safe by construction. The result is a feature-complete iOS and iPadOS application — on-device OCR and machine-learning translation, offline downloads, trackers, widgets, cloud sync, and sideload distribution — delivered solo while staying consistent with five sibling platforms.