Skip to content

How It Works

Michal Švácha edited this page Oct 16, 2026 · 1 revision

How It Works

SwiftUI views ──▶ ScanController ──▶ ScannerEngine (protocol)
                                        ├── ImageCaptureEngine   Apple ImageCaptureCore, in-process
                                        ├── SANEEngine           runs scanimage as a child process
                                        └── MockEngine           synthetic pages for UI work and screenshots

The contract

Sources/EasyScan/Engine/ScannerEngine.swift defines a small @MainActor protocol: discover scanners (with hot-plug callbacks), report capabilities (resolutions, modes, glass size in centimeters), preview, scan with settings and a progress callback, cancel. Everything above the engines is engine-agnostic.

ScanController

The single ObservableObject the views use. It owns the persisted settings, runs previews and scans, appends pages to an existing PDF with PDFKit (writing to a temporary file and swapping atomically), keeps the recent list with thumbnails, and decides which engine to start:

  • Image Capture starts immediately.
  • SANE is a fallback. It starts only when no scanner was found after four seconds, or when the user presses Look again. Probing SANE opens the USB device, which would disturb an Image Capture session, so it is never polled.

ImageCaptureEngine

Bridges the delegate-based ICDeviceBrowser / ICScannerDevice API to async/await, with one pending continuation per wait and a timeout on every wait. Quirks it works around, all observed on a Samsung M2070:

  • A requestScan() or requestOverviewScan() issued inside deviceDidBecomeReady is silently dropped ("Scanner Device has not yet become ready" in the log). Requests go out on a later run-loop turn.
  • functionalUnit.measurementUnit must be set to centimeters before reading physicalSize or writing scanArea. The M2070 reports 21.6 × 29.7 cm, origin top-left.
  • A cancel produces two completions; a request sent right after a completion "finishes" instantly with no file. The engine waits one second after a scan and three after a cancel.
  • A cancelled scan leaves a hidden partial file behind. The engine deletes it.
  • After ICDeviceBrowser.stop(), any later start() in the same process makes the device appear and vanish every second, forever. The browser is therefore never stopped; discovery is simply republished.
  • The Samsung driver allows one session. The engine releases its session after 60 seconds of inactivity so Image Capture and other apps still get a turn.
  • When SANE holds the USB interface, the driver logs -9933 errors and then reports success with no file. The engine turns that into "in use by another app".

SANEEngine

Locates scanimage (Homebrew first), lists devices with scanimage -L, parses --help for resolutions, modes and the -x/-y maxima, and scans with --format=png --progress, reading both pipes asynchronously. Areas are converted from centimeters to millimeter offsets. The xerox_mfp backend pads a few extra rows (about 302 mm on a 297 mm page), so results are trimmed to the requested size. Output is converted to PDF, JPEG or TIFF with ImageIO and CoreGraphics. Cancel sends one SIGTERM, then SIGKILL after five seconds, and relies on its own flag because a second SIGTERM makes scanimage exit "successfully".

Verification

Each engine shipped with a hardware harness run on the real scanner: discovery, capabilities, preview, color PDF, gray PNG, a 10 × 5 cm area (which confirmed centimeters and the top-left origin), busy handling, cancel at 3 seconds, and a scan after cancel. The app binary has a --selftest mode that runs preview, a color PDF, a second page appended to it, and a grayscale area scan through the real controller and engines.

Clone this wiki locally