Repository navigation
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
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.
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.
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()orrequestOverviewScan()issued insidedeviceDidBecomeReadyis silently dropped ("Scanner Device has not yet become ready" in the log). Requests go out on a later run-loop turn. -
functionalUnit.measurementUnitmust be set to centimeters before readingphysicalSizeor writingscanArea. 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 laterstart()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".
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".
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.