A single-window SwiftUI + AppKit app, Swift Package Manager only (no .xcodeproj),
macOS 14+, with Sparkle (auto-update) as the only third-party dependency. Division
of labor: SwiftUI owns layout, state, and chrome; AppKit owns the image
canvas, where precise zoom/pan and large-image handling matter.
App/ MacViewApp (@main, Window + menu commands), AppDelegate,
SettingsManager, WindowAccessor (dark chrome + fullscreen + frame autosave),
UpdaterService (Sparkle wrapper: check-for-updates + 4012 TCC alert)
Models/ ImageInfo (overlay metadata)
ViewModels/ ViewerModel — the single source of truth for the session
Services/ FolderScanner, ImageLoader (actor), Thumbnailer (actor), MetadataReader,
FileActions (trash), DefaultViewer (register as default image viewer)
Views/ ContentView (ZStack), ImageCanvas (NSViewRepresentable + canvas),
ViewerToolbar, Filmstrip, InfoOverlay, NavChevrons, SettingsView
Utilities/ Color+Hex / Palette, NSImage+Rotation
- A file arrives via
onOpenURL(Finder/Open With), the ⌘O panel, or drag-drop. ViewerModel.open(url:)asksFolderScannerfor all image siblings in the parent folder, natural-sorted, and locates the opened file's index.loadCurrent()decodes the current image off-main through theImageLoaderactor, publishes it asdisplayImage, and prefetches ±2 neighbors. AloadTokenguards against a stale decode landing after a newer navigation.ContentViewlayers the AppKit canvas under SwiftUI chrome in aZStack.- The canvas (
ZoomableImageScrollView) reports its zoom percentage back to theCanvasController, which the toolbar observes. - Navigation reaches
ViewerModelfrom four surfaces: the canvas key monitor (←/→), edge clicks resolved to aClickZone(handleClick), a two-finger horizontal swipe (canvasscrollWheel), and filmstrip tap-to-jump. The on-screen chevrons are visual-only — see the ClickZone decision below.
-
AppKit canvas, not SwiftUI Image.
ZoomableImageScrollViewis anNSScrollView(allowsMagnification) wrapping anNSImageView, with aCenteringClipViewso small images sit centered on the backdrop. This gives pinch/⌘-scroll zoom, fit math, and smooth large-image rendering for far less code than reimplementing it with SwiftUI gestures. The document view's frame is set to the image's pixel size, so magnification 1.0 == 100% and fit math is exact. -
Fit never upscales.
fitMagnification()ismin(scale-to-fit, 1.0)— large images downscale to fit; small images show at 100%, centered. -
Keyboard via a local event monitor, not first responder. SwiftUI's hosting view keeps first-responder status, so a
keyDownoverride on the AppKit canvas never fires (the view still gets mouse events via hit-testing — that mismatch is the tell).viewDidMoveToWindowinstalls anNSEvent.addLocalMonitorForEventsthat routes ←/→/Space/⌫ (Esc only to exit fullscreen) while our window is key and no sheet is attached. -
Edge-click navigation via
ClickZone, not chevron buttons. A bare SwiftUI control over the embeddedNSViewloses AppKit hit-testing, so chevron buttons would never get the click. Instead the canvas — which reliably receives the click — computes a left/center/rightClickZone(96pt edge band) and reports it viaonClick;ViewerModel.handleClicknavigates on an edge click (when chrome is visible) or toggles chrome on a center click. The chevrons are visual-only (allowsHitTesting(false)). Material-backed overlays (toolbar/filmstrip) keep their own clicks because their.ultraThinMaterialbacking is a real NSView. -
Two-finger swipe paging. Horizontal trackpad scroll in the canvas
scrollWheelpages between images, but only when the image can't pan horizontally (a zoomed image pans instead). It drops the post-lift momentum tail (event.momentumPhase), turns at most one page per gesture (accumulated-Δx threshold), and matches the macOS Spaces direction (left swipe → next). -
Window state & fullscreen live in
WindowAccessor. Beyond the dark chrome, it re-inserts.fullScreenPrimaryintocollectionBehavior(which.hiddenTitleBardrops) and setssetFrameAutosaveName, so size/position persist across launches. -
File actions. Copy (⌘C) writes the displayed
NSImage+ file URL to the pasteboard; Reveal in Finder usesNSWorkspace.activateFileViewerSelecting; Share is a SwiftUIShareLinkon the toolbar; and the Settings pane's "Set as Default Image Viewer" callsDefaultViewer.makeDefault(per-UTINSWorkspaceregistration).FileActionsitself holds only the destructivetrash. -
Live zoom percent lives on
CanvasController(ContentView scope), not the ViewModel (App scope). It changes on every pinch tick; an App-scoped publisher would rebuild thecommandsmenu each tick and make menu items vanish mid-track.CanvasControlleris held by the App as@State(a stable reference, not observed), so its updates never re-evaluate the App scene. -
Rotation is non-destructive.
rotateLeft/Rightadjust arotationvalue;NSImage.rotatedClockwiserenders a pixel-preserving rotated copy for display. The file on disk is never modified. -
Delete is recoverable.
FileManager.trashItem(macOS Trash), gated by an optional confirm prompt, then auto-advance to the next image. -
Sandbox off. Direct (Developer ID) distribution; reading sibling files in a folder needs no security-scoped bookmarks, which is what makes paging work.
build.sh assembles the .app (binary + generated Info.plist declaring image
CFBundleDocumentTypes + Sparkle SU* keys + AppIcon.icns from the committed
iconset), embeds Sparkle.framework into Contents/Frameworks, signs everything
inside-out (ad-hoc for dev, Developer ID for release — never codesign --deep),
and can build/notarize/staple a DMG. The version is derived from the latest v*
git tag (MACVIEW_VERSION overrides). Auto-update is Sparkle 2: UpdaterService
(ported from macpad) drives SPUStandardUpdaterController; the EdDSA-signed
appcast + DMGs are generated by ./build.sh notarize and pushed to the public
gh-pages branch by ./build.sh publish-appcast (feed:
https://thefinder808.github.io/macview/appcast.xml).