A native macOS comic book reader built with SwiftUI, AppKit, and Metal. Designed for large libraries, smooth reading, and pixel-perfect vertical scrolling.
Requirements: Apple Silicon Mac (M1 or newer), macOS 14 (Sonoma) or later.
Option A — direct download (no build needed):
- Grab the latest
OpenComic-<version>.zipfrom the Releases page. - Unzip it and move
OpenComic.appinto/Applications. - First launch: because the app is ad-hoc signed (not notarized through an Apple Developer account), Gatekeeper blocks it the first time. Right-click the app → Open → Open, or, if that option is missing on macOS 15+, go to System Settings → Privacy & Security → Open Anyway. From a terminal you can instead run
xattr -dr com.apple.quarantine /Applications/OpenComic.app. Only needed once.
Option B — Homebrew:
brew install --cask greatdeepband/tap/open-comic # once a tap is published
# …or directly from this repo's checkout:
brew install --cask ./homebrew/Formula/open-comic.rbunar/lsar (for CBR/CB7) ship inside the app bundle, so no extra dependencies are required.
- Formats: CBZ, CBR, CB7, CBT, and PDF.
- Reading modes: Single Page, Double Page, Vertical Scroll, Vertical Double.
- Library: two-column
NavigationSplitView— sidebar (Home · Favorites · Recents · All Comics · user Galleries with count badges) and a detail pane that changes per selection. Home hosts a "Continue Reading" hero plus horizontal rails; other panes are filtered/sorted grids with a scoped search, sort menu, and card-size control. - User-defined galleries: multi-folder scanning, drag-to-reorder within a gallery, drag a card onto a sidebar gallery row to move between galleries, per-gallery sort (custom order preserved only when sort is Custom and search is empty).
- Drag-to-import: drop CBZ/CBR/CB7/CBT/PDF files onto the library window to open immediately; an accent-coloured drop-zone highlights the target area.
- Fast library load: background parallel thumbnail extraction with an
NSCache+ disk-backed fallback keyed by FNV-1a content hash. - Per-comic persistence: remembers page index, reading mode, exact scroll offset, and page count in
UserDefaults. - Magnifier loupe: left-click and hold on any page (all four reading modes) for a 1.45× circular loupe centred on the cursor.
- GPU-accelerated rendering across every reading mode: Metal pipeline with three LRU rings (decoded
CVPixelBuffers and uploaded full-resMTLTextures at 24 entries each, plus a 200-entry thumbnailMTLTexturering that fills the placeholder tier during fast scroll). Vertical modes use nativeNSScrollView.magnificationfor zoom; single/double use frame-resize zoom with the documentView sized to fit the viewport.
SwiftUI for library, chrome, and state; AppKit (NSScrollView) for pixel-accurate scrolling; Metal for page rendering in all four reading modes. MetalPageView (NSViewRepresentable wrapping NSScrollView + a CAMetalLayer sublayer) backs single-page, double-page, vertical, and vertical-double — branching on a ReadingLayout enum. The decode cache (MetalPageManager) and texture ring (MetalPageRenderer) are shared across modes; SwiftUI swaps the layout parameter and the same Coordinator rebuilds the document.
| Component | Responsibility |
|---|---|
DCApp / ContentView |
App entry point. Owns LibraryViewModel; switches between LibraryView and ReaderView. |
LibraryViewModel |
Gallery persistence, folder scanning, NSCache-backed thumbnail pipeline, search. Also owns the new-library state: selectedSection, cardSize, sortPreferences — all persisted to UserDefaults. |
ReaderViewModel |
Current comic state: page nav, zoom, reading mode. Owns the shared MetalPageManager (decode cache for all four reading modes). |
ComicLoader |
Archive decode: ZIPFoundation for CBZ, PDFKit for PDF, bundled unar / lsar for CBR / CB7, tar for CBT. |
ReadingPositionStore |
UserDefaults: page index, reading mode, scroll offset, page count per comic. |
MemoryMonitor |
mach_task_basic_info RSS sampler with pressure thresholds (passive — informational only). |
DCLogger |
Actor-based file logger → /tmp/dc_debug.log. |
| View | Role |
|---|---|
LibraryView |
NavigationSplitView shell. Hosts sidebar + detail, plus a window-level drop target for CBZ/CBR/CB7/CBT/PDF drag-to-import and an ErrorBanner overlay for LibraryViewModel.errorMessage. |
LibrarySidebar |
List(selection:) keyed by LibrarySection. Static top rows (Home/Favorites/Recents/All Comics) and a dynamic Galleries section. Gallery rows accept drops (moveComic) and expose a context menu for Rename / Add Folders / Reset Order / Delete. "+ New Gallery" button sits at the bottom via safeAreaInset. |
LibraryDetail |
Router. Switches on library.selectedSection and returns LibraryHome / LibraryGridPane / LibraryGalleryPane. The window toolbar provides Open Comic (⌘O) and the memory-debug toggle. |
LibraryHome |
Hero + rails. ContinueReadingHero when a resumable comic exists, else WelcomeHero. Rails: Recently Added, Favorites, Continue Reading — each a LazyHStack of ComicCards with a "See All" jump. |
LibraryGridPane |
Sectioned grid for Favorites / Recents / All Comics. Pulls its URL list from the view model, filters via searchQuery, sorts via LibrarySort.apply. |
LibraryGalleryPane |
Gallery grid. Uses DraggableComicGrid when sort is .manual and search is empty (preserving drag-reorder); otherwise a sorted/filtered LazyVGrid. |
PaneToolbar |
Shared per-pane toolbar: scoped search field, sort menu, card-size menu, optional trailing content. |
ComicCard |
Cover + title, progress badge, heart button. Hover state drives scale + shadow + heart-reveal animation. |
DraggableComicGrid / ComicDropDelegate |
Preserved from the previous library. Drag-to-reorder within a single gallery via onDrag / onDrop delegate. |
EmptyPane / ErrorBanner / DebugMemoryBar |
Supporting chrome. |
CreateGallerySheet / RenameGallerySheet |
Unchanged semantics, lightly re-styled. |
LibrarySection, CardSize, and LibrarySortOrder live in LibraryTypes.swift. LibrarySort.apply centralises the sort logic so every pane uses the same implementation.
| View | Role |
|---|---|
ReaderView |
Hosts the reader content + the custom integrated top bar. Keyboard routing and mode switch. Calls ReaderToolbar for the chrome strip. |
ReaderToolbar |
52-pt transparent strip carrying three floating Liquid-Glass capsules: leading (back / Library), centred transport (prev-comic / prev-page / page-count / next-page / next-comic with 1pt hairline dividers), trailing (favorite + ellipsis menu). Lives in Sources/DC/Views/ReaderToolbar.swift. |
ToolbarCapsule |
Private wrapper inside ReaderToolbar.swift. Applies .glassEffect(.regular.interactive(true), in: .capsule) on macOS 26+; falls back to .background(.ultraThinMaterial, in: Capsule()) + 0.5pt rim on macOS 14–25 via an if #available gate. One place to change material, three call sites. |
FullSizeTitleBarConfigurator |
NSViewRepresentable attached to ReaderView.background(...) (and ContentView.background(...)). On appear, reaches up to the hosting NSWindow and sets titlebarAppearsTransparent = true + styleMask.insert(.fullSizeContentView) + titleVisibility = .hidden, plus re-asserts standardWindowButton(...)?.isHidden = false so the traffic lights stay visible. Without this the title-bar slab stays opaque above the content and the toolbar reads as two stacked bars. |
MetalPageView |
All four reading modes. Hosts NSScrollView with a flipped MetalCanvasView as documentView; renders visible pages via Metal. Layout branches on ReadingLayout. |
ReadingLayout |
Enum in MetalPageView.swift: .verticalStack(pagesPerRow:) · .singlePage · .doubleSpread. Drives all layout/scroller/zoom branching in Coordinator.rebuildLayout(). |
LoupeOverlayState |
Struct passed via MetalPageView.onLoupeOverlay callback. ReaderView keeps a @State LoupeOverlayState? and renders a SwiftUI MagnifierView from it; the loupe is naturally clipped by the reader's ZStack bounds. |
MetalCanvasView |
NSScrollView.documentView with a plain CALayer as backing and a CAMetalLayer sublayer pinned to clipView.bounds on scroll (see "Why a sublayer" below). |
MetalPageRenderer |
MTLRenderPipelineState, TextureRingBuffer (10-entry LRU), render(viewport:visibleRange:pagePositions:…). Shader uniforms carry (viewportOriginX, Y, Width, Height) matching the metalLayer frame. |
MetalPageManager |
Actor holding decoded CVPixelBuffers (10-entry LRU) + parallel nonisolated NSCache<NSNumber, NSImage> (cap 10). Shared across all reading modes. nsImage(for:) fast-path for the loupe; prefetch(around:pages:) decodes the surrounding window. |
MagnifierView |
SwiftUI Canvas loupe (1.45×, circular, top-left-origin cursor coords). |
Version 0.11 replaced the v0.8 unified-strip-with-NSVisualEffectView design with a transparent strip carrying three floating Liquid-Glass capsules. Key decisions, recorded here so we don't relitigate:
- One integrated chrome strip.
DCAppuses the default.windowStyle(.titleBar)so traffic lights stay visible always (the previous.windowStyle(.hiddenTitleBar)hid them on macOS 26 until hover).FullSizeTitleBarConfiguratormakes the title bar transparent (titlebarAppearsTransparent = true) and stretches content underneath (styleMask.insert(.fullSizeContentView)).ReaderView.bodyadds.ignoresSafeArea(.container, edges: .top)so SwiftUI doesn't reserve title-bar safe area. Traffic-light controls + our toolbar share the same 52-pt strip; no stacked bars. - ZStack geometric centring.
.toolbar { .principal }centres relative to the leading/trailing clusters — which shifts whenever they have different widths. The only way to pin the transport at the true window midpoint is a ZStack with the transport laid as a separate layer over the leading/trailing HStack. - 80-pt leading gutter reserves room for the macOS traffic-light triad (14 pt × 3 + padding) plus an 8-pt buffer so the leading capsule's Liquid-Glass rim doesn't kiss the close-button hit area.
- Liquid Glass material.
.glassEffect(.regular.interactive(true), in: .capsule)inside oneGlassEffectContaineron macOS 26+ — refractive, reactive to hover, coordinated sampling across the three capsules. macOS 14–25 falls back to.ultraThinMaterial+ a 0.5pt white-10% rim, same shape, same hit targets, no refraction. The fallback is honest: same geometry, just a flatter material. - Loupe / navbar interaction. The NSScrollView spans the full window with
topContentInset = 52, so the navbar overlays the scrollview (not next to it).MetalPageView+Loupe.swift:handleLoupeEventskips the initial.leftMouseDownwhensvLocal.y < topBarHeight(NSScrollView is top-origin in our setup because theMetalCanvasViewdocumentView isisFlipped = true). Once a drag has started below the strip,loupeDragActivekeeps subsequent.leftMouseDraggedevents flowing regardless of cursor position — the loupe fades to black at the page top edge the same way it already did at left/right/bottom.
Earlier Metal-pipeline versions made CAMetalLayer the backing layer of MetalCanvasView. Because MetalCanvasView.frame is the full stacked-pages height (tens of thousands of points) but drawableSize is the viewport in pixels, CAMetalLayer's default contentsGravity = .resize stretched the drawable ~100× vertically. v0.5.0 fixed this by making the backing layer a plain CALayer and adding CAMetalLayer as a sublayer that is repositioned over the visible clipView.bounds on every scroll, with drawableSize set to visible × contentsScale. The Metal-rendered drawable composites 1:1 against the visible viewport; the vertex shader projects document-space page rects into NDC using the current scroll offset + viewport dimensions.
On every scroll event, Coordinator.updateVisibleRange():
- Binary-searches
pageYOffsets(page-indexed, with duplicate Y for vertical-double pairs), walking left/right across duplicates so both halves of the first and last visible rows are in range. - Cancels the previous
prefetchTaskand starts a new one that decodes visible pages first, then lookahead pages fanning outward. - Calls
updateMetalLayerFrame()so the CAMetalLayer sublayer is in position beforerender(visibleRange:). - Renders deferred by one runloop tick (lets the layout pass settle).
When a prefetch decode lands, onTextureReady(seqIdx) re-renders if the page is still visible, so pages that complete after the last scroll event still appear without the user scrolling again. The TextureRingBuffer's own LRU (cap 10) bounds memory — there is no per-frame explicit eviction.
rebuildLayout() lays out left and right pages at half-width with shared Y offset, appending two pageYOffsets entries per pair (so page indexing stays consistent with single mode). Render draws left and right as two independent quads from their individual pagePositions rects — no spread compute-kernel composition. Each page appears the moment its own texture uploads; the partner page doesn't block it.
The loupe is one unified MagnifierView instance across all four reading modes, driven by MetalPageView's Coordinator and rendered as a SwiftUI overlay in ReaderView. Key mechanics:
- A window-local
NSEvent.addLocalMonitorForEventsmonitor for.leftMouseDown/Dragged/Upobserves events without consuming them, so scroll / pinch / wheel continue to work. - Coordinate resolution via
scrollView.documentView.convert(windowPt, from: nil)— single-step conversion through the flipped documentView. - The Coordinator emits a
LoupeOverlayState?viaonLoupeOverlaycallback.ReaderViewkeeps the state in@Stateand renders theMagnifierViewinside its inner ZStack, where SwiftUI's natural bounds clip the circle to the reader frame. - Position is translated from AppKit's bottom-left window coords to SwiftUI's top-left coords inside the Coordinator:
swiftuiY = window.frame.height - windowPt.y. - For the page under the cursor, the Coordinator hit-tests
pagePositionsdirectly (single/double-page) or viapageYOffsetsbinary search (vertical). When the cursor is in a row gap, the column gap, the top/side margins, or past the document edges,updateLoupesticks toloupeActivePage— the last page that was under the cursor — so the loupe never disappears mid-drag. Initial fallback (no active page yet) iscurrentPagefor single/double orlastVisibleRange.lowerBoundfor vertical.MagnifierViewalways paints the canvas black before attempting the image draw, so off-page cursors render as a solid black circle rather than a transparent ring; raw cursor coords are never clamped, so within-page edges fade to black naturally viasrcRect.intersection(ivBounds). Same gesture/lifetime/edge behaviour as the pre-MetalZoomableImageViewloupe, generalised across multiple page rects. - NSImage source priority: 1-entry per-page cache (
loupeImage);MetalPageManager.nsImage(for:)(synchronous, hits the nonisolatedNSCache); else async snapshot from theCVPixelBufferviaCGContext.makeImage(), with adecodePagefallback. - Async fetches use a
loupeTaskIDgeneration counter; late-arriving Tasks from prior drag positions are silently dropped. While a fetch is in flight, the Coordinator emits a fallback state using the last cached image so the loupe position keeps tracking the cursor. scrollDidChangere-triggersupdateLoupeusingNSEvent.mouseLocationso trackpad scrolls (norightMouseDraggedevent) still refresh the loupe as pages move under a stationary cursor.- Cursor hide/show is balanced via a
cursorHiddentracker. The OS cursor is hidden onmouseDown(after the page resolves) and restored onmouseUpviahideLoupe;loupeActivePageresets there too, so the next press starts fresh. The loupe stays visible for the full duration of the drag — content tracks whichever page sits under the cursor, the panel position is the raw cursor, and the reader's ZStack bounds clip the circle progressively at the window edges (no manual bezel arithmetic).
| Layer | Scope | Cap |
|---|---|---|
NSCache thumbnails + FNV-1a disk fallback |
Library grid | 600 entries |
MetalPageManager.decodedPages |
All reading modes — CVPixelBuffer ring |
24 |
MetalPageManager.nsImageCache |
All reading modes — NSImage fast-path (paired with CVPixelBuffer ring, evicted in lockstep) |
24 |
MetalPageRenderer.textureRing |
Metal-backed modes — full-res MTLTexture ring |
24 |
MetalPageRenderer.thumbnailRing |
All reading modes — low-res MTLTexture placeholder ring (filled by parallel pre-scan, ~108 MB GPU for 200 thumbs at 300×450 BGRA) |
200 |
| Loupe NSImage | Last cursored page only | 1 |
Since v0.9.0, one MetalPageManager is owned by ReaderViewModel and injected into every MetalPageView instance, so all four reading modes share the same decode cache. PageImageCache was retired in v0.9.0.
Per-card refreshes are driven by LibraryViewModel.thumbnailUpdates, a non-@Published PassthroughSubject<URL, Never> that broadcasts one event per cache insertion; cards observe via .onReceive so a thumbnail write for one card never invalidates the rest of the grid.
- macOS 14.0+
- Xcode 15+ or Swift 5.10 toolchain
unar/lsarare bundled in the app atContents/Resources/bin/(used for CBR / CB7 extraction). A Homebrew-installed copy is used as fallback.
cd /path/to/OpenComicOSX
./build_app.shbuild_app.sh:
- Runs
swift build -c release. - Assembles
OpenComic.app/in the repo root. - Writes
Info.plist+ entitlements, copiesShaders.metal(the renderer compiles it at runtime if the SPM default library is not present in the bundle). - Ad-hoc signs the binary.
swift testRuns the DCTests target (baseline coverage of ComicFormat extension parsing, ReadingPositionStore UserDefaults round-trip, and TextureRingBuffer LRU semantics — the last requires a Metal device, present on every supported macOS).
GitHub Actions runs swift build -c release and swift test --parallel on every push and PR via .github/workflows/swift.yml.
- ZIPFoundation — native CBZ extraction and entry streaming. MIT-licensed.
unar/lsar— bundled binaries from The Unarchiver, used for CBR (RAR) and CB7 (7-Zip) extraction. LGPL-2.1-or-later; seeTHIRD_PARTY_LICENSES.mdfor source-availability statement and relinking instructions.- System
tar(BSD tar, ships with macOS) — used for CBT (Tar) extraction. No bundling.
| Key | Single / Double page | Vertical / Vertical Double |
|---|---|---|
← / A |
Previous page | — |
→ / D |
Next page | — |
↑ / W |
Zoom in | — (use ⌘+scroll or pinch — grows the window in ±10% steps) |
↓ / S |
Zoom out | — |
Q |
Open previous comic in gallery | Open previous comic in gallery |
E |
Open next comic in gallery | Open next comic in gallery |
Backspace / Z |
Back to library | Back to library |
⌃⌘F |
Enter full screen | Enter full screen |
1 |
Switch to Single Page mode | Switch to Single Page mode |
2 |
Switch to Double Page mode | Switch to Double Page mode |
3 |
Switch to Vertical mode | Switch to Vertical mode |
4 |
Switch to Vertical Double mode | Switch to Vertical Double mode |
Mouse / trackpad:
- Left-click and hold anywhere on a page — 1.45× circular magnifier loupe centred on the cursor (every reading mode).
- ⌘+scroll or pinch — zoom (in vertical modes this becomes a ±10% window-resize step; in single/double it scales the page).
- Double-click (single / double page only) — reset zoom to fit-to-window.
See CONTRIBUTING.md for how to set up the dev environment, the testing workflow, and — importantly — a list of load-bearing comments / workarounds that look like cleanup opportunities but are not.
Open Comic itself is MIT-licensed. The bundled unar / lsar binaries are LGPL-2.1-or-later; full attribution + relinking instructions are in THIRD_PARTY_LICENSES.md. The full LGPL-2.1 text is in LICENSES/LGPL-2.1.txt.