瀏覽代碼

Add ImageCaptureCore scanner service and fix connection hang.

Replace the inline scan session with a dedicated scanner panel and service that properly handles async session open/close so the UI reaches ready state instead of staying on "Connecting to scanner…".

Co-authored-by: Cursor <cursoragent@cursor.com>
AhtashamShahzad1 1 月之前
父節點
當前提交
817c474c62

+ 18 - 2
smart_printer.xcodeproj/project.pbxproj

@@ -257,8 +257,9 @@
 				ENABLE_APP_SANDBOX = YES;
 				ENABLE_USER_SELECTED_FILES = readonly;
 				GENERATE_INFOPLIST_FILE = YES;
-				INFOPLIST_KEY_NSContactsUsageDescription = "Smart Printer needs access to your contacts so you can search and print them.";
+				INFOPLIST_FILE = smart_printer/Info.plist;
 				INFOPLIST_KEY_NSCameraUsageDescription = "Smart Printer needs camera access to scan documents from connected devices.";
+				INFOPLIST_KEY_NSContactsUsageDescription = "Smart Printer needs access to your contacts so you can search and print them.";
 				INFOPLIST_KEY_NSHumanReadableCopyright = "";
 				INFOPLIST_KEY_NSMainStoryboardFile = Main;
 				INFOPLIST_KEY_NSPrincipalClass = NSApplication;
@@ -267,6 +268,13 @@
 					"@executable_path/../Frameworks",
 				);
 				MARKETING_VERSION = 1.0;
+				OTHER_LDFLAGS = (
+					"$(inherited)",
+					"-framework",
+					Quartz,
+					"-framework",
+					ImageCaptureCore,
+				);
 				PRODUCT_BUNDLE_IDENTIFIER = "MQL-DEV.smart-printer";
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				REGISTER_APP_GROUPS = YES;
@@ -291,8 +299,9 @@
 				ENABLE_APP_SANDBOX = YES;
 				ENABLE_USER_SELECTED_FILES = readonly;
 				GENERATE_INFOPLIST_FILE = YES;
-				INFOPLIST_KEY_NSContactsUsageDescription = "Smart Printer needs access to your contacts so you can search and print them.";
+				INFOPLIST_FILE = smart_printer/Info.plist;
 				INFOPLIST_KEY_NSCameraUsageDescription = "Smart Printer needs camera access to scan documents from connected devices.";
+				INFOPLIST_KEY_NSContactsUsageDescription = "Smart Printer needs access to your contacts so you can search and print them.";
 				INFOPLIST_KEY_NSHumanReadableCopyright = "";
 				INFOPLIST_KEY_NSMainStoryboardFile = Main;
 				INFOPLIST_KEY_NSPrincipalClass = NSApplication;
@@ -301,6 +310,13 @@
 					"@executable_path/../Frameworks",
 				);
 				MARKETING_VERSION = 1.0;
+				OTHER_LDFLAGS = (
+					"$(inherited)",
+					"-framework",
+					Quartz,
+					"-framework",
+					ImageCaptureCore,
+				);
 				PRODUCT_BUNDLE_IDENTIFIER = "MQL-DEV.smart-printer";
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				REGISTER_APP_GROUPS = YES;

+ 3 - 1
smart_printer/AppDelegate.swift

@@ -92,8 +92,10 @@ class AppDelegate: NSObject, NSApplicationDelegate {
         window.styleMask.insert(.fullSizeContentView)
         window.isMovableByWindowBackground = true
         window.backgroundColor = AppTheme.background
-        window.setContentSize(NSSize(width: AppTheme.windowWidth, height: AppTheme.windowHeight))
+        let defaultSize = NSSize(width: AppTheme.windowWidth, height: AppTheme.windowHeight)
+        window.setContentSize(defaultSize)
         window.minSize = NSSize(width: AppTheme.windowMinWidth, height: AppTheme.windowMinHeight)
+        window.maxSize = defaultSize
         window.isRestorable = false
         centerWindowOnScreen(window)
 

+ 17 - 0
smart_printer/Info.plist

@@ -0,0 +1,17 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+	<key>NSLocalNetworkUsageDescription</key>
+	<string>Smart Printer needs local network access to discover scanners on your Wi-Fi network.</string>
+	<key>NSBonjourServices</key>
+	<array>
+		<string>_uscan._tcp</string>
+		<string>_uscans._tcp</string>
+		<string>_ipp._tcp</string>
+		<string>_ipps._tcp</string>
+		<string>_scanner._tcp</string>
+		<string>_pdl-datastream._tcp</string>
+	</array>
+</dict>
+</plist>

+ 64 - 179
smart_printer/ScanFileView.swift

@@ -1,5 +1,4 @@
 import Cocoa
-import ImageCaptureCore
 import PDFKit
 import UniformTypeIdentifiers
 import Vision
@@ -16,7 +15,7 @@ enum ScanError: LocalizedError {
     var errorDescription: String? {
         switch self {
         case .noScannerFound:
-            return "No scanner was found. Connect a scanner or import an image instead."
+            return "No scanner was found. Make sure your scanner is connected and appears in System Settings → Printers & Scanners, then try again. You can also import a photo instead."
         case .scanFailed:
             return "The scan could not be completed. Please try again."
         case .invalidImage:
@@ -32,8 +31,6 @@ enum ScanError: LocalizedError {
 // MARK: - Scan File Service
 
 enum ScanFileService {
-    private static var activeScannerSession: ScanDeviceSession?
-
     static func present(from window: NSWindow?) {
         let hostWindow = window ?? NSApp.keyWindow
         showSourcePicker(from: hostWindow) { source in
@@ -85,7 +82,7 @@ enum ScanFileService {
     private static func showSourcePicker(from window: NSWindow?, completion: @escaping (ScanSource?) -> Void) {
         let alert = NSAlert()
         alert.messageText = "Scan Document"
-        alert.informativeText = "Choose how you want to capture your document."
+        alert.informativeText = "Scan with a connected scanner, or import an existing image."
         alert.alertStyle = .informational
         alert.addButton(withTitle: "Use Scanner")
         alert.addButton(withTitle: "Import Image")
@@ -110,27 +107,20 @@ enum ScanFileService {
     }
 
     private static func startScannerScan(from window: NSWindow?, completion: ((Result<NSImage, Error>) -> Void)? = nil) {
-        let session = ScanDeviceSession()
-        activeScannerSession = session
-        session.scan { result in
-            activeScannerSession = nil
+        guard let viewController = mainViewController(from: window) else {
+            if let completion {
+                completion(.failure(ScanError.noScannerFound))
+            } else {
+                showScanError(ScanError.noScannerFound, from: window)
+            }
+            return
+        }
+
+        viewController.presentScanner(showScanPreview: completion == nil) { result in
             switch result {
-            case .success(let url):
-                Task { @MainActor in
-                    do {
-                        let image = try await processImage(at: url)
-                        if let completion {
-                            completion(.success(image))
-                        } else {
-                            presentScanPreview(pages: [image], from: window)
-                        }
-                    } catch {
-                        if let completion {
-                            completion(.failure(error))
-                        } else {
-                            showScanError(error, from: window)
-                        }
-                    }
+            case .success(let image):
+                if let completion {
+                    completion(.success(image))
                 }
             case .failure(let error):
                 if let completion {
@@ -138,34 +128,12 @@ enum ScanFileService {
                 } else if case ScanError.cancelled = error {
                     return
                 } else {
-                    showScannerFallback(error: error, from: window)
+                    showScanError(error, from: window)
                 }
             }
         }
     }
 
-    private static func showScannerFallback(error: Error, from window: NSWindow?) {
-        let alert = NSAlert()
-        alert.messageText = "Scanner Unavailable"
-        alert.informativeText = "\(error.localizedDescription)\n\nYou can import a photo of your document instead."
-        alert.alertStyle = .warning
-        alert.addButton(withTitle: "Import Image")
-        alert.addButton(withTitle: "Cancel")
-
-        let handler: (NSApplication.ModalResponse) -> Void = { response in
-            guard response == .alertFirstButtonReturn else { return }
-            importImage(from: window)
-        }
-
-        if let window {
-            alert.beginSheetModal(for: window, completionHandler: handler)
-        } else {
-            if alert.runModal() == .alertFirstButtonReturn {
-                handler(.alertFirstButtonReturn)
-            }
-        }
-    }
-
     private static func importImage(from window: NSWindow?, completion: ((Result<NSImage, Error>) -> Void)? = nil) {
         let panel = NSOpenPanel()
         panel.title = "Import Document Image"
@@ -227,10 +195,27 @@ enum ScanFileService {
         return await ScanDocumentProcessor.process(cgImage) ?? image
     }
 
+    private static func mainViewController(from window: NSWindow?) -> ViewController? {
+        if let viewController = window?.contentViewController as? ViewController {
+            return viewController
+        }
+        if let viewController = NSApp.keyWindow?.contentViewController as? ViewController {
+            return viewController
+        }
+        for appWindow in NSApp.windows {
+            if let viewController = appWindow.contentViewController as? ViewController {
+                return viewController
+            }
+        }
+        return nil
+    }
+
     private static func presentScanPreview(pages: [NSImage], from window: NSWindow?) {
         guard !pages.isEmpty else { return }
-        let hostWindow = window ?? NSApp.keyWindow
-        guard let viewController = hostWindow?.contentViewController as? ViewController else { return }
+        guard let viewController = mainViewController(from: window) else {
+            showScanError(ScanError.scanFailed, from: window)
+            return
+        }
         viewController.presentScanFile(pages: pages)
     }
 
@@ -248,126 +233,6 @@ enum ScanFileService {
     }
 }
 
-// MARK: - Scanner Session
-
-private final class ScanDeviceSession: NSObject, ICDeviceBrowserDelegate, ICDeviceDelegate, ICScannerDeviceDelegate {
-    private var browser: ICDeviceBrowser?
-    private var scanners: [ICScannerDevice] = []
-    private var activeScanner: ICScannerDevice?
-    private var completion: ((Result<URL, Error>) -> Void)?
-    private var discoveryTimer: Timer?
-    private var didComplete = false
-
-    func scan(completion: @escaping (Result<URL, Error>) -> Void) {
-        self.completion = completion
-        let browser = ICDeviceBrowser()
-        browser.delegate = self
-        browser.browsedDeviceTypeMask = .scanner
-        self.browser = browser
-        browser.start()
-
-        discoveryTimer = Timer.scheduledTimer(withTimeInterval: 3.0, repeats: false) { [weak self] _ in
-            self?.finishDiscovery()
-        }
-    }
-
-    private func finishDiscovery() {
-        guard !didComplete else { return }
-        discoveryTimer?.invalidate()
-        discoveryTimer = nil
-
-        guard let scanner = scanners.first else {
-            browser?.stop()
-            browser = nil
-            finish(.failure(ScanError.noScannerFound))
-            return
-        }
-        beginScan(with: scanner)
-    }
-
-    private func beginScan(with scanner: ICScannerDevice) {
-        activeScanner = scanner
-        scanner.delegate = self
-        scanner.requestOpenSession()
-    }
-
-    private func finish(_ result: Result<URL, Error>) {
-        guard !didComplete else { return }
-        didComplete = true
-        discoveryTimer?.invalidate()
-        discoveryTimer = nil
-        browser?.stop()
-        browser = nil
-        activeScanner?.requestCloseSession()
-        activeScanner = nil
-        completion?(result)
-        completion = nil
-    }
-
-    func deviceBrowser(_ browser: ICDeviceBrowser, didAdd device: ICDevice, moreComing: Bool) {
-        guard let scanner = device as? ICScannerDevice else { return }
-        if !scanners.contains(where: { $0 === scanner }) {
-            scanners.append(scanner)
-        }
-        if !moreComing, discoveryTimer != nil {
-            finishDiscovery()
-        }
-    }
-
-    func deviceBrowser(_ browser: ICDeviceBrowser, didRemove device: ICDevice, moreGoing: Bool) {
-        guard let scanner = device as? ICScannerDevice else { return }
-        scanners.removeAll { $0 === scanner }
-    }
-
-    func device(_ device: ICDevice, didOpenSessionWithError error: Error?) {
-        guard device === activeScanner, let scanner = device as? ICScannerDevice else { return }
-        if let error {
-            finish(.failure(error))
-            return
-        }
-
-        if scanner.availableFunctionalUnitTypes.contains(where: {
-            ($0 as? NSNumber)?.uintValue == ICScannerFunctionalUnitType.flatbed.rawValue
-        }) {
-            scanner.requestSelect(.flatbed)
-        } else if let first = scanner.availableFunctionalUnitTypes.first as? NSNumber {
-            scanner.requestSelect(ICScannerFunctionalUnitType(rawValue: first.uintValue) ?? .flatbed)
-        } else {
-            scanner.requestScan()
-        }
-    }
-
-    func device(_ device: ICDevice, didCloseSessionWithError error: Error?) {}
-
-    func didRemove(_ device: ICDevice) {
-        guard let scanner = device as? ICScannerDevice else { return }
-        scanners.removeAll { $0 === scanner }
-        if activeScanner === scanner {
-            activeScanner = nil
-            finish(.failure(ScanError.scanFailed))
-        }
-    }
-
-    func scannerDevice(_ scanner: ICScannerDevice, didSelect functionalUnit: ICScannerFunctionalUnit, error: Error?) {
-        guard scanner === activeScanner else { return }
-        if let error {
-            finish(.failure(error))
-            return
-        }
-        scanner.requestScan()
-    }
-
-    func scannerDevice(_ scanner: ICScannerDevice, didScanTo url: URL) {
-        finish(.success(url))
-    }
-
-    func scannerDevice(_ scanner: ICScannerDevice, didCompleteScanWithError error: Error?) {
-        if let error {
-            finish(.failure(error))
-        }
-    }
-}
-
 // MARK: - Document Processing
 
 enum ScanDocumentProcessor {
@@ -428,6 +293,24 @@ enum ScanDocumentProcessor {
 // MARK: - Scan Export
 
 enum ScanExporter {
+    static func saveAsPDF(pages: [NSImage], from window: NSWindow?) {
+        guard !pages.isEmpty else { return }
+
+        let panel = NSSavePanel()
+        panel.title = "Save as PDF"
+        panel.message = "Choose where to save your scanned document."
+        panel.prompt = "Save"
+        panel.canCreateDirectories = true
+        panel.allowedContentTypes = [.pdf]
+        panel.nameFieldStringValue = "Scan.pdf"
+
+        guard panel.runModal() == .OK, let url = panel.url else { return }
+        let scaledPages = pages.map { scaleForResolution($0) }
+        if !writePDF(pages: scaledPages, to: url) {
+            showSaveFailed(from: window)
+        }
+    }
+
     static func save(pages: [NSImage], from window: NSWindow?) {
         guard !pages.isEmpty else { return }
 
@@ -731,6 +614,12 @@ final class ScanFileOverlayView: NSView, AppearanceRefreshable {
         ])
     }
 
+    func appendPages(_ newPages: [NSImage]) {
+        guard !newPages.isEmpty else { return }
+        pages.append(contentsOf: newPages)
+        showPage(at: pages.count - 1)
+    }
+
     private func showPage(at index: Int) {
         guard pages.indices.contains(index) else { return }
         currentIndex = index
@@ -746,8 +635,7 @@ final class ScanFileOverlayView: NSView, AppearanceRefreshable {
     private func addPage() {
         ScanFileService.addPage(from: window) { [weak self] image in
             guard let self, let image else { return }
-            pages.append(image)
-            showPage(at: pages.count - 1)
+            appendPages([image])
         }
     }
 
@@ -756,13 +644,10 @@ final class ScanFileOverlayView: NSView, AppearanceRefreshable {
     }
 
     private func printScan() {
-        guard pages.indices.contains(currentIndex) else { return }
-        PrintService.printPhoto(pages[currentIndex], title: "Scan", from: window)
-
-        if currentIndex < pages.count - 1 {
-            showPage(at: currentIndex + 1)
-        } else if pages.count == 1 {
-            dismiss()
+        guard !pages.isEmpty else { return }
+        for (index, page) in pages.enumerated() {
+            let title = pages.count > 1 ? "Scan Page \(index + 1)" : "Scan"
+            PrintService.printPhoto(page, title: title, from: window)
         }
     }
 }

+ 897 - 0
smart_printer/ScannerOverlayView.swift

@@ -0,0 +1,897 @@
+import Cocoa
+import ImageCaptureCore
+
+// MARK: - Scanner Panel
+
+@MainActor
+final class ScannerPanelController: NSObject, NSWindowDelegate {
+    private static var activeController: ScannerPanelController?
+
+    private static let panelWidth: CGFloat = 560
+    private static let panelHeight: CGFloat = 480
+
+    private var panel: NSPanel?
+    private var panelView: ScannerOverlayView?
+    private weak var parentWindow: NSWindow?
+    private let showResultPreview: Bool
+    private var onComplete: ((Result<NSImage, Error>) -> Void)?
+    private var didComplete = false
+
+    static func present(
+        from parentWindow: NSWindow?,
+        showResultPreview: Bool,
+        onComplete: @escaping (Result<NSImage, Error>) -> Void
+    ) {
+        Task { @MainActor in
+            if let active = activeController {
+                await active.teardownPanel()
+            }
+            let controller = ScannerPanelController(
+                parentWindow: parentWindow,
+                showResultPreview: showResultPreview,
+                onComplete: onComplete
+            )
+            activeController = controller
+            await controller.show()
+        }
+    }
+
+    private init(
+        parentWindow: NSWindow?,
+        showResultPreview: Bool,
+        onComplete: @escaping (Result<NSImage, Error>) -> Void
+    ) {
+        self.parentWindow = parentWindow
+        self.showResultPreview = showResultPreview
+        self.onComplete = onComplete
+        super.init()
+    }
+
+    private func show() async {
+        let contentSize = NSSize(width: Self.panelWidth, height: Self.panelHeight)
+        let panel = NSPanel(
+            contentRect: NSRect(origin: .zero, size: contentSize),
+            styleMask: [.titled, .closable, .fullSizeContentView],
+            backing: .buffered,
+            defer: false
+        )
+        panel.title = "Scanner"
+        panel.isFloatingPanel = false
+        panel.hidesOnDeactivate = false
+        panel.delegate = self
+        panel.isReleasedWhenClosed = false
+        panel.backgroundColor = AppTheme.background
+        hideTrafficLights(on: panel)
+
+        let container = NSView(frame: NSRect(origin: .zero, size: contentSize))
+        container.translatesAutoresizingMaskIntoConstraints = true
+        panel.contentView = container
+
+        let scannerView = ScannerOverlayView()
+        scannerView.translatesAutoresizingMaskIntoConstraints = false
+        scannerView.onScanComplete = { [weak self] image in
+            self?.handleScanComplete(image)
+        }
+        scannerView.onDismiss = { [weak self] in
+            self?.close()
+        }
+        container.addSubview(scannerView)
+        NSLayoutConstraint.activate([
+            scannerView.leadingAnchor.constraint(equalTo: container.leadingAnchor),
+            scannerView.trailingAnchor.constraint(equalTo: container.trailingAnchor),
+            scannerView.topAnchor.constraint(equalTo: container.topAnchor),
+            scannerView.bottomAnchor.constraint(equalTo: container.bottomAnchor),
+        ])
+
+        center(panel, relativeTo: parentWindow)
+        self.panel = panel
+        self.panelView = scannerView
+
+        await scannerView.activate()
+        NSApp.activate(ignoringOtherApps: true)
+        parentWindow?.makeKeyAndOrderFront(nil)
+        panel.makeKeyAndOrderFront(nil)
+    }
+
+    private func teardownPanel() async {
+        await panelView?.teardown()
+        panel?.orderOut(nil)
+        panel = nil
+        panelView = nil
+        if Self.activeController === self {
+            Self.activeController = nil
+        }
+        onComplete = nil
+    }
+
+    private func handleScanComplete(_ image: NSImage) {
+        Task { @MainActor in
+            let processed: NSImage
+            if let cgImage = image.cgImage(forProposedRect: nil, context: nil, hints: nil) {
+                processed = await ScanDocumentProcessor.process(cgImage) ?? image
+            } else {
+                processed = image
+            }
+
+            if showResultPreview {
+                panel?.title = "Scan Result"
+                panelView?.showResult(processed)
+            } else {
+                didComplete = true
+                onComplete?(.success(processed))
+                close()
+            }
+        }
+    }
+
+    func windowWillClose(_ notification: Notification) {
+        let cancelled = !didComplete && panelView?.hasResult != true
+        Task { @MainActor in
+            await finish(cancelled: cancelled)
+        }
+    }
+
+    private func finish(cancelled: Bool) async {
+        await panelView?.teardown()
+        panel?.orderOut(nil)
+        panel = nil
+        panelView = nil
+        if Self.activeController === self {
+            Self.activeController = nil
+        }
+        if cancelled {
+            onComplete?(.failure(ScanError.cancelled))
+        }
+        onComplete = nil
+    }
+
+    private func close() {
+        panel?.close()
+    }
+
+    private func center(_ panelWindow: NSWindow, relativeTo parentWindow: NSWindow?) {
+        guard let parentWindow else {
+            panelWindow.center()
+            return
+        }
+        let parentFrame = parentWindow.frame
+        var panelFrame = panelWindow.frame
+        panelFrame.origin.x = parentFrame.midX - panelFrame.width / 2
+        panelFrame.origin.y = parentFrame.midY - panelFrame.height / 2
+        panelWindow.setFrame(panelFrame, display: true)
+    }
+
+    private func hideTrafficLights(on window: NSWindow) {
+        for buttonType: NSWindow.ButtonType in [.miniaturizeButton, .zoomButton] {
+            window.standardWindowButton(buttonType)?.isHidden = true
+        }
+    }
+}
+
+// MARK: - Scanner View
+
+final class ScannerOverlayView: NSView, AppearanceRefreshable {
+    var onScanComplete: ((NSImage) -> Void)?
+    var onDismiss: (() -> Void)?
+
+    private(set) var hasResult = false
+
+    private let scanner: ScannerService
+    private var pendingScan = false
+    private var scannedImage: NSImage?
+
+    private let scanPhaseView = NSView()
+    private let resultPhaseView = NSView()
+
+    private let titleLabel = NSTextField(labelWithString: "Scanner")
+    private let sidebarView = NSView()
+    private let localHeader = ScannerSectionHeader(title: "DEVICES")
+    private let localList = NSStackView()
+    private let sharedHeader = ScannerSectionHeader(title: "SHARED")
+    private let sharedList = NSStackView()
+    private let previewContainer = NSView()
+    private let previewImageView = NSImageView()
+    private let placeholderView = ScannerPlaceholderView()
+    private let statusLabel = NSTextField(labelWithString: "Select a scanner to begin")
+    private let paperSizePopup = NSPopUpButton()
+    private let scanButton = ScannerPrimaryButton(title: "Scan")
+    private let progressIndicator = NSProgressIndicator()
+
+    private let resultPreviewContainer = NSView()
+    private let resultImageView = NSImageView()
+    private let savePDFButton = ScannerSecondaryButton(title: "Save as PDF", symbolName: "square.and.arrow.down")
+    private let printButton = ScannerSecondaryButton(title: "Print", symbolName: "printer.fill")
+
+    init(scanner: ScannerService = .shared) {
+        self.scanner = scanner
+        super.init(frame: .zero)
+        translatesAutoresizingMaskIntoConstraints = false
+        setup()
+        bindScanner()
+        refreshAppearance()
+        rebuildDeviceLists()
+        updateSelectionUI()
+        updateStateUI(scanner.state)
+        NotificationCenter.default.addObserver(
+            self,
+            selector: #selector(appearanceDidChange),
+            name: .appearanceDidChange,
+            object: nil
+        )
+    }
+
+    deinit {
+        NotificationCenter.default.removeObserver(self)
+    }
+
+    @available(*, unavailable)
+    required init?(coder: NSCoder) { nil }
+
+    func activate() async {
+        bindScanner()
+        await scanner.prepareForScanning()
+        rebuildDeviceLists()
+        updateSelectionUI()
+        updateStateUI(scanner.state)
+    }
+
+    func teardown() async {
+        scanner.onStateChanged = nil
+        scanner.onDevicesChanged = nil
+        scanner.onSelectionChanged = nil
+        scanner.onScanComplete = nil
+        await scanner.stopBrowsing()
+    }
+
+    func showResult(_ image: NSImage) {
+        hasResult = true
+        scannedImage = image
+        resultImageView.image = image
+        scanPhaseView.isHidden = true
+        resultPhaseView.isHidden = false
+        // Session stays open until the panel closes; avoids breaking the next scan.
+    }
+
+    func refreshAppearance() {
+        layer?.backgroundColor = AppTheme.background.cgColor
+        titleLabel.textColor = AppTheme.textPrimary
+        sidebarView.layer?.backgroundColor = AppTheme.sidebarBackground.cgColor
+        previewContainer.layer?.backgroundColor = AppTheme.cardBackground.cgColor
+        previewContainer.layer?.borderColor = AppTheme.paywallBorder.cgColor
+        resultPreviewContainer.layer?.backgroundColor = AppTheme.cardBackground.cgColor
+        resultPreviewContainer.layer?.borderColor = AppTheme.paywallBorder.cgColor
+        statusLabel.textColor = AppTheme.textSecondary
+        scanButton.refreshAppearance()
+        localHeader.refreshAppearance()
+        sharedHeader.refreshAppearance()
+        placeholderView.refreshAppearance()
+        savePDFButton.refreshAppearance()
+        printButton.refreshAppearance()
+    }
+
+    @objc private func appearanceDidChange() {
+        refreshAppearance()
+        rebuildDeviceLists()
+    }
+
+    private func setup() {
+        wantsLayer = true
+        setupScanPhase()
+        setupResultPhase()
+
+        addSubview(scanPhaseView)
+        addSubview(resultPhaseView)
+        resultPhaseView.isHidden = true
+
+        NSLayoutConstraint.activate([
+            scanPhaseView.leadingAnchor.constraint(equalTo: leadingAnchor),
+            scanPhaseView.trailingAnchor.constraint(equalTo: trailingAnchor),
+            scanPhaseView.topAnchor.constraint(equalTo: topAnchor),
+            scanPhaseView.bottomAnchor.constraint(equalTo: bottomAnchor),
+
+            resultPhaseView.leadingAnchor.constraint(equalTo: leadingAnchor),
+            resultPhaseView.trailingAnchor.constraint(equalTo: trailingAnchor),
+            resultPhaseView.topAnchor.constraint(equalTo: topAnchor),
+            resultPhaseView.bottomAnchor.constraint(equalTo: bottomAnchor),
+        ])
+    }
+
+    private func setupScanPhase() {
+        scanPhaseView.translatesAutoresizingMaskIntoConstraints = false
+
+        titleLabel.font = AppTheme.semiboldFont(size: 16)
+        titleLabel.alignment = .center
+        titleLabel.translatesAutoresizingMaskIntoConstraints = false
+
+        sidebarView.wantsLayer = true
+        sidebarView.translatesAutoresizingMaskIntoConstraints = false
+
+        localList.orientation = .vertical
+        localList.alignment = .leading
+        localList.spacing = 2
+        localList.translatesAutoresizingMaskIntoConstraints = false
+
+        sharedList.orientation = .vertical
+        sharedList.alignment = .leading
+        sharedList.spacing = 2
+        sharedList.translatesAutoresizingMaskIntoConstraints = false
+
+        previewContainer.wantsLayer = true
+        previewContainer.layer?.cornerRadius = AppTheme.contentPanelCornerRadius
+        previewContainer.layer?.borderWidth = 1.5
+        previewContainer.applyCardShadow()
+        previewContainer.translatesAutoresizingMaskIntoConstraints = false
+        previewContainer.setContentHuggingPriority(.defaultLow, for: .horizontal)
+        previewContainer.setContentHuggingPriority(.defaultLow, for: .vertical)
+        previewContainer.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
+        previewContainer.setContentCompressionResistancePriority(.defaultLow, for: .vertical)
+
+        previewImageView.imageScaling = .scaleProportionallyUpOrDown
+        previewImageView.imageAlignment = .alignCenter
+        previewImageView.translatesAutoresizingMaskIntoConstraints = false
+        previewImageView.isHidden = true
+        previewImageView.setContentHuggingPriority(.defaultLow, for: .horizontal)
+        previewImageView.setContentHuggingPriority(.defaultLow, for: .vertical)
+        previewImageView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
+        previewImageView.setContentCompressionResistancePriority(.defaultLow, for: .vertical)
+
+        placeholderView.translatesAutoresizingMaskIntoConstraints = false
+
+        statusLabel.font = AppTheme.regularFont(size: 13)
+        statusLabel.alignment = .center
+        statusLabel.lineBreakMode = .byWordWrapping
+        statusLabel.maximumNumberOfLines = 2
+        statusLabel.translatesAutoresizingMaskIntoConstraints = false
+
+        for size in PaperSize.allCases {
+            paperSizePopup.addItem(withTitle: size.rawValue)
+        }
+        paperSizePopup.selectItem(withTitle: AppSettings.defaultPaperSize.rawValue)
+        paperSizePopup.font = AppTheme.regularFont(size: 13)
+        paperSizePopup.translatesAutoresizingMaskIntoConstraints = false
+        paperSizePopup.target = self
+        paperSizePopup.action = #selector(paperSizeChanged)
+
+        scanButton.translatesAutoresizingMaskIntoConstraints = false
+        scanButton.isEnabled = false
+        scanButton.onClick = { [weak self] in self?.startScan() }
+
+        progressIndicator.style = .spinning
+        progressIndicator.controlSize = .small
+        progressIndicator.isDisplayedWhenStopped = false
+        progressIndicator.translatesAutoresizingMaskIntoConstraints = false
+
+        let sidebarStack = NSStackView()
+        sidebarStack.orientation = .vertical
+        sidebarStack.alignment = .leading
+        sidebarStack.spacing = 6
+        sidebarStack.translatesAutoresizingMaskIntoConstraints = false
+        sidebarStack.addArrangedSubview(localHeader)
+        sidebarStack.addArrangedSubview(localList)
+        sidebarStack.addArrangedSubview(sharedHeader)
+        sidebarStack.addArrangedSubview(sharedList)
+
+        let sidebarScroll = NSScrollView()
+        sidebarScroll.hasVerticalScroller = true
+        sidebarScroll.hasHorizontalScroller = false
+        sidebarScroll.autohidesScrollers = true
+        sidebarScroll.drawsBackground = false
+        sidebarScroll.borderType = .noBorder
+        sidebarScroll.translatesAutoresizingMaskIntoConstraints = false
+
+        let sidebarDocument = FlippedSidebarContentView()
+        sidebarDocument.translatesAutoresizingMaskIntoConstraints = false
+        sidebarDocument.addSubview(sidebarStack)
+        sidebarScroll.documentView = sidebarDocument
+        sidebarView.addSubview(sidebarScroll)
+
+        previewContainer.addSubview(placeholderView)
+        previewContainer.addSubview(previewImageView)
+
+        scanPhaseView.addSubview(titleLabel)
+        scanPhaseView.addSubview(sidebarView)
+        scanPhaseView.addSubview(previewContainer)
+        scanPhaseView.addSubview(statusLabel)
+        scanPhaseView.addSubview(paperSizePopup)
+        scanPhaseView.addSubview(progressIndicator)
+        scanPhaseView.addSubview(scanButton)
+
+        NSLayoutConstraint.activate([
+            titleLabel.topAnchor.constraint(equalTo: scanPhaseView.topAnchor, constant: 16),
+            titleLabel.centerXAnchor.constraint(equalTo: scanPhaseView.centerXAnchor),
+
+            sidebarView.leadingAnchor.constraint(equalTo: scanPhaseView.leadingAnchor),
+            sidebarView.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 12),
+            sidebarView.bottomAnchor.constraint(equalTo: scanPhaseView.bottomAnchor),
+            sidebarView.widthAnchor.constraint(equalToConstant: 168),
+
+            sidebarScroll.leadingAnchor.constraint(equalTo: sidebarView.leadingAnchor),
+            sidebarScroll.trailingAnchor.constraint(equalTo: sidebarView.trailingAnchor),
+            sidebarScroll.topAnchor.constraint(equalTo: sidebarView.topAnchor),
+            sidebarScroll.bottomAnchor.constraint(equalTo: sidebarView.bottomAnchor),
+
+            sidebarStack.leadingAnchor.constraint(equalTo: sidebarDocument.leadingAnchor, constant: 8),
+            sidebarStack.trailingAnchor.constraint(equalTo: sidebarDocument.trailingAnchor, constant: -8),
+            sidebarStack.topAnchor.constraint(equalTo: sidebarDocument.topAnchor, constant: 8),
+            sidebarStack.bottomAnchor.constraint(equalTo: sidebarDocument.bottomAnchor, constant: -8),
+            sidebarDocument.widthAnchor.constraint(equalTo: sidebarScroll.widthAnchor),
+            localHeader.widthAnchor.constraint(equalTo: sidebarStack.widthAnchor),
+            sharedHeader.widthAnchor.constraint(equalTo: sidebarStack.widthAnchor),
+            localList.widthAnchor.constraint(equalTo: sidebarStack.widthAnchor),
+            sharedList.widthAnchor.constraint(equalTo: sidebarStack.widthAnchor),
+
+            previewContainer.leadingAnchor.constraint(equalTo: sidebarView.trailingAnchor, constant: 12),
+            previewContainer.trailingAnchor.constraint(equalTo: scanPhaseView.trailingAnchor, constant: -16),
+            previewContainer.topAnchor.constraint(equalTo: sidebarView.topAnchor),
+            previewContainer.bottomAnchor.constraint(equalTo: statusLabel.topAnchor, constant: -12),
+
+            placeholderView.centerXAnchor.constraint(equalTo: previewContainer.centerXAnchor),
+            placeholderView.centerYAnchor.constraint(equalTo: previewContainer.centerYAnchor),
+            placeholderView.widthAnchor.constraint(equalTo: previewContainer.widthAnchor, multiplier: 0.55),
+            placeholderView.heightAnchor.constraint(equalTo: previewContainer.heightAnchor, multiplier: 0.7),
+
+            previewImageView.leadingAnchor.constraint(equalTo: previewContainer.leadingAnchor, constant: 12),
+            previewImageView.trailingAnchor.constraint(equalTo: previewContainer.trailingAnchor, constant: -12),
+            previewImageView.topAnchor.constraint(equalTo: previewContainer.topAnchor, constant: 12),
+            previewImageView.bottomAnchor.constraint(equalTo: previewContainer.bottomAnchor, constant: -12),
+
+            statusLabel.leadingAnchor.constraint(equalTo: previewContainer.leadingAnchor),
+            statusLabel.trailingAnchor.constraint(equalTo: previewContainer.trailingAnchor),
+            statusLabel.bottomAnchor.constraint(equalTo: paperSizePopup.topAnchor, constant: -10),
+
+            paperSizePopup.leadingAnchor.constraint(equalTo: previewContainer.leadingAnchor),
+            paperSizePopup.bottomAnchor.constraint(equalTo: scanPhaseView.bottomAnchor, constant: -20),
+            paperSizePopup.widthAnchor.constraint(equalToConstant: 110),
+
+            progressIndicator.centerYAnchor.constraint(equalTo: scanButton.centerYAnchor),
+            progressIndicator.trailingAnchor.constraint(equalTo: scanButton.leadingAnchor, constant: -10),
+
+            scanButton.trailingAnchor.constraint(equalTo: previewContainer.trailingAnchor),
+            scanButton.bottomAnchor.constraint(equalTo: scanPhaseView.bottomAnchor, constant: -16),
+            scanButton.widthAnchor.constraint(greaterThanOrEqualToConstant: 96),
+            scanButton.heightAnchor.constraint(equalToConstant: 32),
+        ])
+    }
+
+    private func setupResultPhase() {
+        resultPhaseView.translatesAutoresizingMaskIntoConstraints = false
+
+        let resultTitle = NSTextField(labelWithString: "Scan Result")
+        resultTitle.font = AppTheme.semiboldFont(size: 16)
+        resultTitle.alignment = .center
+        resultTitle.textColor = AppTheme.textPrimary
+        resultTitle.translatesAutoresizingMaskIntoConstraints = false
+
+        resultPreviewContainer.wantsLayer = true
+        resultPreviewContainer.layer?.cornerRadius = AppTheme.contentPanelCornerRadius
+        resultPreviewContainer.layer?.borderWidth = 1.5
+        resultPreviewContainer.applyCardShadow()
+        resultPreviewContainer.translatesAutoresizingMaskIntoConstraints = false
+        resultPreviewContainer.setContentHuggingPriority(.defaultLow, for: .horizontal)
+        resultPreviewContainer.setContentHuggingPriority(.defaultLow, for: .vertical)
+        resultPreviewContainer.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
+        resultPreviewContainer.setContentCompressionResistancePriority(.defaultLow, for: .vertical)
+
+        resultImageView.imageScaling = .scaleProportionallyUpOrDown
+        resultImageView.imageAlignment = .alignCenter
+        resultImageView.translatesAutoresizingMaskIntoConstraints = false
+        resultImageView.setContentHuggingPriority(.defaultLow, for: .horizontal)
+        resultImageView.setContentHuggingPriority(.defaultLow, for: .vertical)
+        resultImageView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
+        resultImageView.setContentCompressionResistancePriority(.defaultLow, for: .vertical)
+
+        savePDFButton.translatesAutoresizingMaskIntoConstraints = false
+        printButton.translatesAutoresizingMaskIntoConstraints = false
+        savePDFButton.onClick = { [weak self] in self?.saveAsPDF() }
+        printButton.onClick = { [weak self] in self?.printScan() }
+
+        resultPreviewContainer.addSubview(resultImageView)
+        resultPhaseView.addSubview(resultTitle)
+        resultPhaseView.addSubview(resultPreviewContainer)
+        resultPhaseView.addSubview(savePDFButton)
+        resultPhaseView.addSubview(printButton)
+
+        NSLayoutConstraint.activate([
+            resultTitle.topAnchor.constraint(equalTo: resultPhaseView.topAnchor, constant: 16),
+            resultTitle.centerXAnchor.constraint(equalTo: resultPhaseView.centerXAnchor),
+
+            resultPreviewContainer.leadingAnchor.constraint(equalTo: resultPhaseView.leadingAnchor, constant: 16),
+            resultPreviewContainer.trailingAnchor.constraint(equalTo: resultPhaseView.trailingAnchor, constant: -16),
+            resultPreviewContainer.topAnchor.constraint(equalTo: resultTitle.bottomAnchor, constant: 12),
+            resultPreviewContainer.bottomAnchor.constraint(equalTo: savePDFButton.topAnchor, constant: -16),
+
+            resultImageView.leadingAnchor.constraint(equalTo: resultPreviewContainer.leadingAnchor, constant: 12),
+            resultImageView.trailingAnchor.constraint(equalTo: resultPreviewContainer.trailingAnchor, constant: -12),
+            resultImageView.topAnchor.constraint(equalTo: resultPreviewContainer.topAnchor, constant: 12),
+            resultImageView.bottomAnchor.constraint(equalTo: resultPreviewContainer.bottomAnchor, constant: -12),
+
+            savePDFButton.leadingAnchor.constraint(equalTo: resultPhaseView.leadingAnchor, constant: 16),
+            savePDFButton.bottomAnchor.constraint(equalTo: resultPhaseView.bottomAnchor, constant: -20),
+            savePDFButton.heightAnchor.constraint(equalToConstant: 40),
+            savePDFButton.widthAnchor.constraint(equalTo: resultPhaseView.widthAnchor, multiplier: 0.5, constant: -22),
+
+            printButton.trailingAnchor.constraint(equalTo: resultPhaseView.trailingAnchor, constant: -16),
+            printButton.bottomAnchor.constraint(equalTo: resultPhaseView.bottomAnchor, constant: -20),
+            printButton.heightAnchor.constraint(equalToConstant: 40),
+            printButton.widthAnchor.constraint(equalTo: resultPhaseView.widthAnchor, multiplier: 0.5, constant: -22),
+        ])
+    }
+
+    private func bindScanner() {
+        scanner.onDevicesChanged = { [weak self] in
+            self?.rebuildDeviceLists()
+        }
+        scanner.onSelectionChanged = { [weak self] in
+            self?.updateSelectionUI()
+        }
+        scanner.onStateChanged = { [weak self] state in
+            self?.updateStateUI(state)
+        }
+        scanner.onScanComplete = { [weak self] result in
+            self?.handleScanResult(result)
+        }
+    }
+
+    private func rebuildDeviceLists() {
+        localList.arrangedSubviews.forEach { $0.removeFromSuperview() }
+        sharedList.arrangedSubviews.forEach { $0.removeFromSuperview() }
+
+        localHeader.count = scanner.localDevices.count
+        sharedHeader.count = scanner.sharedDevices.count
+
+        for device in scanner.localDevices {
+            localList.addArrangedSubview(makeDeviceRow(device))
+        }
+        for device in scanner.sharedDevices {
+            sharedList.addArrangedSubview(makeDeviceRow(device))
+        }
+
+        if scanner.localDevices.isEmpty {
+            localList.addArrangedSubview(emptyRow("No devices connected"))
+        }
+        if scanner.sharedDevices.isEmpty {
+            sharedList.addArrangedSubview(emptyRow("No shared scanners"))
+        }
+    }
+
+    private func makeDeviceRow(_ device: ICScannerDevice) -> ScannerDeviceRow {
+        let name = device.name ?? device.locationDescription ?? "Scanner"
+        let row = ScannerDeviceRow(title: name, isSelected: scanner.selectedDevice === device)
+        row.onClick = { [weak self] in
+            self?.scanner.selectDevice(device)
+        }
+        return row
+    }
+
+    private func emptyRow(_ text: String) -> NSView {
+        let label = NSTextField(labelWithString: text)
+        label.font = AppTheme.regularFont(size: 12)
+        label.textColor = AppTheme.textSecondary
+        label.lineBreakMode = .byTruncatingTail
+        label.translatesAutoresizingMaskIntoConstraints = false
+        let container = NSView()
+        container.translatesAutoresizingMaskIntoConstraints = false
+        container.addSubview(label)
+        NSLayoutConstraint.activate([
+            label.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 4),
+            label.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -4),
+            label.topAnchor.constraint(equalTo: container.topAnchor, constant: 4),
+            label.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -4),
+            container.heightAnchor.constraint(equalToConstant: 28),
+        ])
+        return container
+    }
+
+    private func updateSelectionUI() {
+        rebuildDeviceLists()
+        let hasSelection = scanner.selectedDevice != nil
+        scanButton.isEnabled = hasSelection && scanner.state != .scanning && scanner.state != .connecting
+    }
+
+    private func updateStateUI(_ state: ScannerService.State) {
+        switch state {
+        case .idle, .browsing:
+            statusLabel.stringValue = "Looking for scanners…"
+            progressIndicator.stopAnimation(nil)
+            scanButton.isEnabled = scanner.selectedDevice != nil
+        case .connecting:
+            statusLabel.stringValue = "Connecting to scanner…"
+            progressIndicator.startAnimation(nil)
+            scanButton.isEnabled = false
+        case .ready:
+            statusLabel.stringValue = "Ready to scan"
+            progressIndicator.stopAnimation(nil)
+            scanButton.isEnabled = true
+            if pendingScan {
+                pendingScan = false
+                scanner.scan()
+            }
+        case .scanning:
+            statusLabel.stringValue = "Scanning…"
+            progressIndicator.startAnimation(nil)
+            scanButton.isEnabled = false
+        case .error(let message):
+            statusLabel.stringValue = message
+            progressIndicator.stopAnimation(nil)
+            scanButton.isEnabled = false
+        }
+    }
+
+    private func startScan() {
+        guard scanner.selectedDevice != nil else { return }
+        previewImageView.isHidden = true
+        placeholderView.isHidden = false
+
+        if case .ready = scanner.state {
+            scanner.scan()
+        } else {
+            pendingScan = true
+        }
+    }
+
+    private func handleScanResult(_ result: Result<NSImage, Error>) {
+        progressIndicator.stopAnimation(nil)
+        switch result {
+        case .success(let image):
+            onScanComplete?(image)
+        case .failure(let error):
+            if case ScanError.cancelled = error { return }
+            statusLabel.stringValue = error.localizedDescription
+            scanButton.isEnabled = scanner.selectedDevice != nil
+        }
+    }
+
+    private func saveAsPDF() {
+        guard let image = scannedImage else { return }
+        ScanExporter.saveAsPDF(pages: [image], from: window)
+    }
+
+    private func printScan() {
+        guard let image = scannedImage else { return }
+        PrintService.printPhoto(image, title: "Scan", from: window)
+    }
+
+    @objc private func paperSizeChanged() {
+        guard let title = paperSizePopup.selectedItem?.title,
+              let size = PaperSize(rawValue: title) else { return }
+        scanner.paperSize = size
+    }
+}
+
+// MARK: - Controls
+
+private final class FlippedSidebarContentView: NSView {
+    override var isFlipped: Bool { true }
+}
+
+private final class ScannerSectionHeader: NSView, AppearanceRefreshable {
+    var count: Int = 0 {
+        didSet { countLabel.stringValue = "\(count)" }
+    }
+
+    private let titleLabel = NSTextField(labelWithString: "")
+    private let countLabel = NSTextField(labelWithString: "0")
+
+    init(title: String) {
+        super.init(frame: .zero)
+        translatesAutoresizingMaskIntoConstraints = false
+        titleLabel.stringValue = title
+        titleLabel.font = AppTheme.semiboldFont(size: 11)
+        titleLabel.translatesAutoresizingMaskIntoConstraints = false
+
+        countLabel.font = AppTheme.regularFont(size: 11)
+        countLabel.alignment = .right
+        countLabel.translatesAutoresizingMaskIntoConstraints = false
+
+        addSubview(titleLabel)
+        addSubview(countLabel)
+        NSLayoutConstraint.activate([
+            heightAnchor.constraint(equalToConstant: 20),
+            titleLabel.leadingAnchor.constraint(equalTo: leadingAnchor),
+            titleLabel.centerYAnchor.constraint(equalTo: centerYAnchor),
+            countLabel.trailingAnchor.constraint(equalTo: trailingAnchor),
+            countLabel.centerYAnchor.constraint(equalTo: centerYAnchor),
+        ])
+        refreshAppearance()
+    }
+
+    @available(*, unavailable)
+    required init?(coder: NSCoder) { nil }
+
+    func refreshAppearance() {
+        titleLabel.textColor = AppTheme.textSecondary
+        countLabel.textColor = AppTheme.textSecondary
+    }
+}
+
+private final class ScannerDeviceRow: NSControl, AppearanceRefreshable {
+    var onClick: (() -> Void)?
+
+    private let iconView = NSImageView()
+    private let titleLabel = NSTextField(labelWithString: "")
+    private var isSelected = false
+
+    init(title: String, isSelected: Bool) {
+        self.isSelected = isSelected
+        super.init(frame: .zero)
+        translatesAutoresizingMaskIntoConstraints = false
+        wantsLayer = true
+        layer?.cornerRadius = 6
+
+        titleLabel.stringValue = title
+        titleLabel.font = AppTheme.regularFont(size: 13)
+        titleLabel.lineBreakMode = .byTruncatingTail
+        titleLabel.translatesAutoresizingMaskIntoConstraints = false
+
+        if let image = NSImage(systemSymbolName: "scanner", accessibilityDescription: "Scanner") {
+            iconView.image = image.withSymbolConfiguration(
+                NSImage.SymbolConfiguration(pointSize: 14, weight: .medium)
+            )
+        }
+        iconView.translatesAutoresizingMaskIntoConstraints = false
+
+        addSubview(iconView)
+        addSubview(titleLabel)
+        NSLayoutConstraint.activate([
+            heightAnchor.constraint(equalToConstant: 32),
+            iconView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 4),
+            iconView.centerYAnchor.constraint(equalTo: centerYAnchor),
+            iconView.widthAnchor.constraint(equalToConstant: 18),
+            titleLabel.leadingAnchor.constraint(equalTo: iconView.trailingAnchor, constant: 6),
+            titleLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -4),
+            titleLabel.centerYAnchor.constraint(equalTo: centerYAnchor),
+        ])
+        refreshAppearance()
+    }
+
+    @available(*, unavailable)
+    required init?(coder: NSCoder) { nil }
+
+    func refreshAppearance() {
+        if isSelected {
+            layer?.backgroundColor = AppTheme.blue.withAlphaComponent(0.2).cgColor
+            titleLabel.textColor = AppTheme.blue
+            iconView.contentTintColor = AppTheme.blue
+        } else {
+            layer?.backgroundColor = .clear
+            titleLabel.textColor = AppTheme.textPrimary
+            iconView.contentTintColor = AppTheme.textPrimary
+        }
+    }
+
+    override func mouseUp(with event: NSEvent) {
+        guard bounds.contains(convert(event.locationInWindow, from: nil)) else { return }
+        onClick?()
+    }
+
+    override func resetCursorRects() {
+        addCursorRect(bounds, cursor: .pointingHand)
+    }
+}
+
+private final class ScannerPlaceholderView: NSView, AppearanceRefreshable {
+    override var isOpaque: Bool { false }
+
+    func refreshAppearance() {
+        needsDisplay = true
+    }
+
+    override func draw(_ dirtyRect: NSRect) {
+        let rect = bounds.insetBy(dx: 20, dy: 20)
+        let path = NSBezierPath(roundedRect: rect, xRadius: 4, yRadius: 4)
+        path.setLineDash([6, 4], count: 2, phase: 0)
+        AppTheme.border.setStroke()
+        path.lineWidth = 1.5
+        path.stroke()
+    }
+}
+
+private final class ScannerPrimaryButton: NSControl, AppearanceRefreshable {
+    var onClick: (() -> Void)?
+
+    private let titleLabel = NSTextField(labelWithString: "")
+    private var isEnabledState = true
+
+    override var isEnabled: Bool {
+        get { isEnabledState }
+        set {
+            isEnabledState = newValue
+            refreshAppearance()
+        }
+    }
+
+    init(title: String) {
+        super.init(frame: .zero)
+        titleLabel.stringValue = title
+        titleLabel.font = AppTheme.semiboldFont(size: 13)
+        titleLabel.alignment = .center
+        titleLabel.translatesAutoresizingMaskIntoConstraints = false
+        wantsLayer = true
+        layer?.cornerRadius = 6
+        addSubview(titleLabel)
+        NSLayoutConstraint.activate([
+            titleLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 16),
+            titleLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -16),
+            titleLabel.centerYAnchor.constraint(equalTo: centerYAnchor),
+        ])
+        refreshAppearance()
+    }
+
+    @available(*, unavailable)
+    required init?(coder: NSCoder) { nil }
+
+    func refreshAppearance() {
+        if isEnabledState {
+            layer?.backgroundColor = NSColor.controlAccentColor.cgColor
+            titleLabel.textColor = .white
+        } else {
+            layer?.backgroundColor = AppTheme.elevatedBackground.cgColor
+            titleLabel.textColor = AppTheme.textSecondary
+        }
+    }
+
+    override func mouseUp(with event: NSEvent) {
+        guard isEnabledState, bounds.contains(convert(event.locationInWindow, from: nil)) else { return }
+        onClick?()
+    }
+
+    override func resetCursorRects() {
+        guard isEnabledState else { return }
+        addCursorRect(bounds, cursor: .pointingHand)
+    }
+}
+
+private final class ScannerSecondaryButton: NSControl, AppearanceRefreshable {
+    var onClick: (() -> Void)?
+
+    private let titleLabel = NSTextField(labelWithString: "")
+    private let iconView = NSImageView()
+
+    init(title: String, symbolName: String) {
+        super.init(frame: .zero)
+        wantsLayer = true
+        layer?.cornerRadius = 10
+        layer?.borderWidth = 1.5
+
+        titleLabel.stringValue = title
+        titleLabel.font = AppTheme.semiboldFont(size: 14)
+        titleLabel.translatesAutoresizingMaskIntoConstraints = false
+
+        if let image = NSImage(systemSymbolName: symbolName, accessibilityDescription: title) {
+            iconView.image = image.withSymbolConfiguration(
+                NSImage.SymbolConfiguration(pointSize: 13, weight: .semibold)
+            )
+        }
+        iconView.translatesAutoresizingMaskIntoConstraints = false
+
+        addSubview(iconView)
+        addSubview(titleLabel)
+        NSLayoutConstraint.activate([
+            iconView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 16),
+            iconView.centerYAnchor.constraint(equalTo: centerYAnchor),
+            titleLabel.leadingAnchor.constraint(equalTo: iconView.trailingAnchor, constant: 8),
+            titleLabel.centerYAnchor.constraint(equalTo: centerYAnchor),
+            titleLabel.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor, constant: -16),
+        ])
+        refreshAppearance()
+    }
+
+    @available(*, unavailable)
+    required init?(coder: NSCoder) { nil }
+
+    func refreshAppearance() {
+        layer?.backgroundColor = AppTheme.elevatedBackground.cgColor
+        layer?.borderColor = AppTheme.paywallBorder.cgColor
+        titleLabel.textColor = AppTheme.textPrimary
+        iconView.contentTintColor = AppTheme.textPrimary
+    }
+
+    override func mouseUp(with event: NSEvent) {
+        guard bounds.contains(convert(event.locationInWindow, from: nil)) else { return }
+        onClick?()
+    }
+
+    override func resetCursorRects() {
+        addCursorRect(bounds, cursor: .pointingHand)
+    }
+}

+ 771 - 0
smart_printer/ScannerService.swift

@@ -0,0 +1,771 @@
+import Cocoa
+import ImageCaptureCore
+
+// MARK: - Scanner Service
+
+@MainActor
+final class ScannerService: NSObject {
+    static let shared = ScannerService()
+
+    enum State: Equatable {
+        case idle
+        case browsing
+        case connecting
+        case ready
+        case scanning
+        case error(String)
+    }
+
+    private(set) var state: State = .idle {
+        didSet { onStateChanged?(state) }
+    }
+
+    private(set) var localDevices: [ICScannerDevice] = [] {
+        didSet { onDevicesChanged?() }
+    }
+
+    private(set) var sharedDevices: [ICScannerDevice] = [] {
+        didSet { onDevicesChanged?() }
+    }
+
+    private(set) var selectedDevice: ICScannerDevice? {
+        didSet { onSelectionChanged?() }
+    }
+
+    var paperSize: PaperSize = AppSettings.defaultPaperSize
+    var resolution: ScanResolution = AppSettings.scanResolution
+
+    var onStateChanged: ((State) -> Void)?
+    var onDevicesChanged: (() -> Void)?
+    var onSelectionChanged: (() -> Void)?
+    var onScanComplete: ((Result<NSImage, Error>) -> Void)?
+
+    private let browser = ICDeviceBrowser()
+    private var scanCompletion: ((Result<NSImage, Error>) -> Void)?
+    private var shouldScanWhenReady = false
+    private var scannedImageURL: URL?
+    private var didFinishCurrentScan = false
+    private var accumulatedBands: [ICScannerBandData] = []
+    private var isClosingSession = false
+    private var isOpeningSession = false
+    private var sessionOpenRetryCount = 0
+    private var lastSelectedDeviceName: String?
+    private var pendingPreferredDeviceName: String?
+    private var sessionCloseContinuation: CheckedContinuation<Void, Never>?
+    private let maxSessionOpenRetries = 4
+    private let connectionTimeoutSeconds: UInt64 = 15
+    private let sessionCloseTimeoutSeconds: UInt64 = 4
+    private let browserRestartDelayNanoseconds: UInt64 = 700_000_000
+
+    private override init() {
+        super.init()
+        browser.delegate = self
+        let maskRaw = ICDeviceTypeMask.scanner.rawValue
+            | ICDeviceLocationTypeMask.local.rawValue
+            | ICDeviceLocationTypeMask.shared.rawValue
+            | ICDeviceLocationTypeMask.bonjour.rawValue
+        browser.browsedDeviceTypeMask = ICDeviceTypeMask(rawValue: maskRaw) ?? .scanner
+    }
+
+    func startBrowsing() {
+        switch state {
+        case .scanning:
+            return
+        case .idle, .error:
+            state = .browsing
+            browser.start()
+        case .browsing, .connecting, .ready:
+            if localDevices.isEmpty && sharedDevices.isEmpty {
+                browser.start()
+            }
+        }
+    }
+
+    /// Prepares a completely fresh scanner session, same as the first scan.
+    func prepareForScanning() async {
+        clearConnectionTimeout()
+        isOpeningSession = false
+        sessionOpenRetryCount = 0
+        didFinishCurrentScan = false
+        shouldScanWhenReady = false
+        scanCompletion = nil
+        scannedImageURL = nil
+        accumulatedBands = []
+        pendingPreferredDeviceName = lastSelectedDeviceName
+
+        browser.stop()
+        await closeOpenSessionIfNeeded()
+        resetDeviceLists()
+
+        try? await Task.sleep(nanoseconds: browserRestartDelayNanoseconds)
+        browser.start()
+        state = .browsing
+    }
+
+    func stopBrowsing() async {
+        clearConnectionTimeout()
+        browser.stop()
+        rememberSelectedDeviceName()
+        await closeOpenSessionIfNeeded()
+        resetFlagsAfterShutdown()
+    }
+
+    private func rememberSelectedDeviceName() {
+        if let device = selectedDevice {
+            let name = device.name ?? device.locationDescription
+            if let name { lastSelectedDeviceName = name }
+        }
+    }
+
+    private func resetDeviceLists() {
+        selectedDevice = nil
+        localDevices = []
+        sharedDevices = []
+    }
+
+    private func resetFlagsAfterShutdown() {
+        isClosingSession = false
+        isOpeningSession = false
+        pendingPreferredDeviceName = nil
+        resetDeviceLists()
+        state = .idle
+        sessionOpenRetryCount = 0
+        scanCompletion = nil
+        scannedImageURL = nil
+        didFinishCurrentScan = false
+        accumulatedBands = []
+        shouldScanWhenReady = false
+    }
+
+    private func closeOpenSessionIfNeeded() async {
+        guard let device = selectedDevice else {
+            isClosingSession = false
+            return
+        }
+        device.delegate = nil
+        guard device.hasOpenSession else {
+            isClosingSession = false
+            return
+        }
+        isClosingSession = true
+        await closeDeviceSessionAndWait(device)
+    }
+
+    private func waitForSessionToClose() async {
+        if !isClosingSession, selectedDevice?.hasOpenSession != true {
+            return
+        }
+        await withCheckedContinuation { (continuation: CheckedContinuation<Void, Never>) in
+            sessionCloseContinuation = continuation
+            Task { @MainActor in
+                try? await Task.sleep(nanoseconds: sessionCloseTimeoutSeconds * 1_000_000_000)
+                resumeSessionCloseWait()
+            }
+        }
+    }
+
+    private func resumeSessionCloseWait() {
+        guard let continuation = sessionCloseContinuation else { return }
+        sessionCloseContinuation = nil
+        isClosingSession = false
+        continuation.resume()
+    }
+
+    private func sendOpenSession(to device: ICScannerDevice) {
+        Task { @MainActor in
+            guard selectedDevice === device else { return }
+            do {
+                try await device.requestOpenSession()
+                processSessionOpened(on: device, error: nil)
+            } catch {
+                processSessionOpened(on: device, error: error)
+            }
+        }
+    }
+
+    private func sendCloseSession(for device: ICScannerDevice) {
+        Task { @MainActor in
+            do {
+                try await device.requestCloseSession()
+                processSessionClosed(on: device, error: nil)
+            } catch {
+                processSessionClosed(on: device, error: error)
+            }
+        }
+    }
+
+    private func closeDeviceSessionAndWait(_ device: ICScannerDevice) async {
+        do {
+            try await device.requestCloseSession()
+            processSessionClosed(on: device, error: nil)
+        } catch {
+            processSessionClosed(on: device, error: error)
+        }
+        await waitForSessionToClose()
+    }
+
+    private func processSessionOpened(on scanner: ICScannerDevice, error: Error?) {
+        guard selectedDevice === scanner else { return }
+        guard isOpeningSession || state == .connecting else { return }
+
+        clearConnectionTimeout()
+        isOpeningSession = false
+
+        if let error {
+            if sessionOpenRetryCount < maxSessionOpenRetries {
+                sessionOpenRetryCount += 1
+                let delay = UInt64(sessionOpenRetryCount) * 600_000_000
+                Task { @MainActor in
+                    try? await Task.sleep(nanoseconds: delay)
+                    guard self.selectedDevice === scanner else { return }
+                    self.openSession(on: scanner)
+                }
+                return
+            }
+            sessionOpenRetryCount = 0
+            state = .error(error.localizedDescription)
+            finishScan(with: .failure(error))
+            return
+        }
+
+        isClosingSession = false
+        sessionOpenRetryCount = 0
+        scanner.delegate = self
+        if shouldScanWhenReady {
+            shouldScanWhenReady = false
+            configureFunctionalUnit(on: scanner)
+            state = .scanning
+            scanner.requestScan()
+        } else {
+            state = .ready
+        }
+    }
+
+    private func processSessionClosed(on device: ICDevice, error: Error?) {
+        sessionOpenRetryCount = 0
+        resumeSessionCloseWait()
+        if selectedDevice === device, state != .idle {
+            state = .browsing
+        }
+    }
+
+    /// Keeps the scanner session alive for subsequent scans (e.g. Add Page).
+    func resetAfterScan() {
+        clearConnectionTimeout()
+        scanCompletion = nil
+        scannedImageURL = nil
+        didFinishCurrentScan = false
+        accumulatedBands = []
+        shouldScanWhenReady = false
+        sessionOpenRetryCount = 0
+        isOpeningSession = false
+
+        if let device = selectedDevice, device.hasOpenSession {
+            device.delegate = self
+            state = .ready
+        } else if let device = selectedDevice {
+            if isClosingSession {
+                waitForSessionClose { [weak self] in
+                    guard let self, self.selectedDevice === device else { return }
+                    self.openSession(on: device)
+                }
+            } else {
+                openSession(on: device)
+            }
+        } else if state == .idle {
+            startBrowsing()
+        }
+    }
+
+    func selectDevice(_ device: ICScannerDevice) {
+        let name = device.name ?? device.locationDescription
+        if let name { lastSelectedDeviceName = name }
+        pendingPreferredDeviceName = nil
+
+        if selectedDevice === device {
+            retryConnection(to: device)
+            return
+        }
+
+        clearConnectionTimeout()
+        isOpeningSession = false
+        closeSessionIfNeeded()
+        selectedDevice = device
+
+        let connect = { [weak self] in
+            guard let self, self.selectedDevice === device else { return }
+            self.openSession(on: device)
+        }
+        if isClosingSession {
+            waitForSessionClose(then: connect)
+        } else {
+            connect()
+        }
+    }
+
+    func scan() {
+        guard let device = selectedDevice else { return }
+        scanCompletion = onScanComplete
+        scannedImageURL = nil
+        didFinishCurrentScan = false
+        accumulatedBands = []
+
+        let performScan = { [weak self] in
+            guard let self, let device = self.selectedDevice else { return }
+            device.delegate = self
+            if device.hasOpenSession {
+                self.configureFunctionalUnit(on: device)
+                self.state = .scanning
+                device.requestScan()
+            } else {
+                self.shouldScanWhenReady = true
+                self.openSession(on: device)
+            }
+        }
+
+        if isClosingSession {
+            waitForSessionClose(then: performScan)
+        } else if case .connecting = state {
+            shouldScanWhenReady = true
+        } else {
+            performScan()
+        }
+    }
+
+    func cancelScan() {
+        selectedDevice?.cancelScan()
+        scanCompletion?(.failure(ScanError.cancelled))
+        scanCompletion = nil
+        scannedImageURL = nil
+        didFinishCurrentScan = true
+        accumulatedBands = []
+        state = selectedDevice?.hasOpenSession == true ? .ready : .idle
+    }
+
+    private func openSession(on device: ICScannerDevice) {
+        guard selectedDevice === device else { return }
+
+        clearConnectionTimeout()
+        isOpeningSession = false
+        device.delegate = self
+
+        if device.hasOpenSession {
+            state = .ready
+            if shouldScanWhenReady {
+                shouldScanWhenReady = false
+                configureFunctionalUnit(on: device)
+                state = .scanning
+                device.requestScan()
+            }
+            return
+        }
+
+        isOpeningSession = true
+        state = .connecting
+        sendOpenSession(to: device)
+        scheduleConnectionTimeout(for: device)
+    }
+
+    private func retryConnection(to device: ICScannerDevice) {
+        if device.hasOpenSession {
+            clearConnectionTimeout()
+            isOpeningSession = false
+            device.delegate = self
+            state = .ready
+            return
+        }
+
+        clearConnectionTimeout()
+        isOpeningSession = false
+        device.delegate = nil
+
+        let reconnect = { [weak self] in
+            guard let self, self.selectedDevice === device else { return }
+            self.openSession(on: device)
+        }
+
+        if device.hasOpenSession || isClosingSession {
+            if device.hasOpenSession {
+                isClosingSession = true
+                sendCloseSession(for: device)
+            }
+            waitForSessionClose(then: reconnect)
+        } else if case .connecting = state {
+            reconnect()
+        } else if !isClosingSession {
+            reconnect()
+        } else {
+            waitForSessionClose(then: reconnect)
+        }
+    }
+
+    private var connectionTimeoutTask: Task<Void, Never>?
+
+    private func scheduleConnectionTimeout(for device: ICScannerDevice) {
+        connectionTimeoutTask?.cancel()
+        connectionTimeoutTask = Task { @MainActor in
+            try? await Task.sleep(nanoseconds: connectionTimeoutSeconds * 1_000_000_000)
+            guard !Task.isCancelled else { return }
+            guard selectedDevice === device, case .connecting = state else { return }
+            handleConnectionTimeout(for: device)
+        }
+    }
+
+    private func handleConnectionTimeout(for device: ICScannerDevice) {
+        isOpeningSession = false
+        device.delegate = nil
+        if device.hasOpenSession {
+            isClosingSession = true
+            sendCloseSession(for: device)
+        } else {
+            isClosingSession = false
+        }
+        state = .error("Could not connect to the scanner. Select it again to retry.")
+    }
+
+    private func clearConnectionTimeout() {
+        connectionTimeoutTask?.cancel()
+        connectionTimeoutTask = nil
+    }
+
+    private func waitForSessionClose(then action: @escaping () -> Void) {
+        Task { @MainActor in
+            for _ in 0..<40 {
+                if !isClosingSession { break }
+                if selectedDevice?.hasOpenSession != true {
+                    isClosingSession = false
+                    break
+                }
+                try? await Task.sleep(nanoseconds: 250_000_000)
+            }
+            isClosingSession = false
+            action()
+        }
+    }
+
+    private func closeSessionIfNeeded() {
+        guard let device = selectedDevice else { return }
+        device.delegate = nil
+        if device.hasOpenSession {
+            isClosingSession = true
+            sendCloseSession(for: device)
+        }
+    }
+
+    private func disconnectCurrentDevice() {
+        closeSessionIfNeeded()
+    }
+
+    private func configureFunctionalUnit(on device: ICScannerDevice) {
+        let unit = device.selectedFunctionalUnit
+        unit.pixelDataType = .RGB
+        if let eightBit = ICScannerBitDepth(rawValue: 8) {
+            unit.bitDepth = eightBit
+        }
+        unit.resolution = resolution.dpi
+
+        let docType = documentType(for: paperSize)
+        if let flatbed = unit as? ICScannerFunctionalUnitFlatbed {
+            flatbed.documentType = docType
+        } else if let feeder = unit as? ICScannerFunctionalUnitDocumentFeeder {
+            feeder.documentType = docType
+        } else if let positive = unit as? ICScannerFunctionalUnitPositiveTransparency {
+            positive.documentType = docType
+        } else if let negative = unit as? ICScannerFunctionalUnitNegativeTransparency {
+            negative.documentType = docType
+        }
+
+        let physicalSize = unit.physicalSize
+        if physicalSize.width > 0, physicalSize.height > 0 {
+            unit.scanArea = NSRect(origin: .zero, size: physicalSize)
+        }
+
+        device.transferMode = .fileBased
+        device.documentName = "Scan"
+        device.documentUTI = "public.jpeg"
+        device.downloadsDirectory = scanDownloadsDirectory()
+    }
+
+    private func scanDownloadsDirectory() -> URL {
+        let directory = FileManager.default.temporaryDirectory
+            .appendingPathComponent("Scans", isDirectory: true)
+        try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
+        return directory
+    }
+
+    private func documentType(for paperSize: PaperSize) -> ICScannerDocumentType {
+        switch paperSize {
+        case .a4: return .typeA4
+        case .letter: return .typeUSLetter
+        case .legal: return .typeUSLegal
+        }
+    }
+
+    private func isSharedDevice(_ device: ICDevice) -> Bool {
+        let locationBits = device.type.rawValue & ICDeviceLocationTypeMask.remote.rawValue
+        return locationBits != 0
+    }
+
+    private func categorize(_ device: ICScannerDevice, moreComing: Bool) {
+        if isSharedDevice(device) {
+            if !sharedDevices.contains(where: { $0 === device }) {
+                sharedDevices.append(device)
+            }
+        } else if !localDevices.contains(where: { $0 === device }) {
+            localDevices.append(device)
+        }
+        autoSelectIfNeeded(device, moreComing: moreComing)
+    }
+
+    private func autoSelectIfNeeded(_ device: ICScannerDevice, moreComing: Bool) {
+        guard selectedDevice == nil else { return }
+
+        let deviceName = device.name ?? device.locationDescription
+        if let preferred = pendingPreferredDeviceName,
+           let deviceName,
+           deviceName == preferred {
+            pendingPreferredDeviceName = nil
+            selectDevice(device)
+            return
+        }
+
+        if !moreComing, localDevices.count + sharedDevices.count == 1 {
+            selectDevice(device)
+        }
+    }
+
+    private func remove(_ device: ICDevice) {
+        localDevices.removeAll { $0 === device }
+        sharedDevices.removeAll { $0 === device }
+        if selectedDevice === device {
+            selectedDevice = nil
+            state = .browsing
+        }
+    }
+
+    private func finishScan(with result: Result<NSImage, Error>) {
+        guard !didFinishCurrentScan else { return }
+        didFinishCurrentScan = true
+        scanCompletion?(result)
+        scanCompletion = nil
+        scannedImageURL = nil
+        accumulatedBands = []
+
+        if let device = selectedDevice {
+            device.delegate = nil
+            if device.hasOpenSession {
+                isClosingSession = true
+                sendCloseSession(for: device)
+            }
+        }
+        state = .browsing
+    }
+
+    private func imageFromScanURL(_ url: URL) -> NSImage? {
+        if let data = try? Data(contentsOf: url), let image = NSImage(data: data) {
+            return image
+        }
+        return NSImage(contentsOf: url)
+    }
+
+    private func loadImageFromScanURL(_ url: URL) async -> NSImage? {
+        for attempt in 0..<6 {
+            if let image = imageFromScanURL(url) {
+                return image
+            }
+            if attempt < 5 {
+                try? await Task.sleep(nanoseconds: 250_000_000)
+            }
+        }
+        return nil
+    }
+
+    private func resolveScannedImage() async -> NSImage? {
+        if let url = scannedImageURL, let image = await loadImageFromScanURL(url) {
+            return image
+        }
+        if !accumulatedBands.isEmpty {
+            return assembleImage(from: accumulatedBands)
+        }
+        return nil
+    }
+}
+
+// MARK: - ICDeviceBrowserDelegate
+
+extension ScannerService: ICDeviceBrowserDelegate {
+    nonisolated func deviceBrowser(_ browser: ICDeviceBrowser, didAdd device: ICDevice, moreComing: Bool) {
+        guard let scanner = device as? ICScannerDevice else { return }
+        Task { @MainActor in
+            categorize(scanner, moreComing: moreComing)
+        }
+    }
+
+    nonisolated func deviceBrowser(_ browser: ICDeviceBrowser, didRemove device: ICDevice, moreGoing: Bool) {
+        Task { @MainActor in
+            remove(device)
+        }
+    }
+}
+
+// MARK: - ICDeviceDelegate / ICScannerDeviceDelegate
+
+extension ScannerService: ICDeviceDelegate, ICScannerDeviceDelegate {
+    nonisolated func didRemove(_ device: ICDevice) {
+        Task { @MainActor in
+            remove(device)
+        }
+    }
+
+    nonisolated func device(_ device: ICDevice, didOpenSessionWithError error: (any Error)?) {
+        Task { @MainActor in
+            guard let scanner = device as? ICScannerDevice else { return }
+            processSessionOpened(on: scanner, error: error)
+        }
+    }
+
+    nonisolated func device(_ device: ICDevice, didCloseSessionWithError error: (any Error)?) {
+        Task { @MainActor in
+            processSessionClosed(on: device, error: error)
+        }
+    }
+
+    nonisolated func scannerDevice(_ scanner: ICScannerDevice, didScanTo url: URL) {
+        Task { @MainActor in
+            scannedImageURL = url
+            if let image = imageFromScanURL(url) {
+                finishScan(with: .success(image))
+            }
+        }
+    }
+
+    nonisolated func scannerDevice(_ scanner: ICScannerDevice, didScanTo bandData: ICScannerBandData) {
+        Task { @MainActor in
+            accumulatedBands.append(bandData)
+            if bandData.dataNumRows >= bandData.fullImageHeight,
+               let image = imageFromBandData(bandData) {
+                finishScan(with: .success(image))
+            }
+        }
+    }
+
+    nonisolated func scannerDevice(_ scanner: ICScannerDevice, didCompleteScanWithError error: (any Error)?) {
+        Task { @MainActor in
+            if didFinishCurrentScan { return }
+            if let error {
+                finishScan(with: .failure(error))
+                return
+            }
+            if let image = await resolveScannedImage() {
+                finishScan(with: .success(image))
+                return
+            }
+            finishScan(with: .failure(ScanError.scanFailed))
+        }
+    }
+
+    nonisolated func scannerDeviceDidBecomeAvailable(_ scanner: ICScannerDevice) {
+        Task { @MainActor in
+            guard selectedDevice === scanner else { return }
+            clearConnectionTimeout()
+            isOpeningSession = false
+            isClosingSession = false
+            scanner.delegate = self
+            if shouldScanWhenReady {
+                shouldScanWhenReady = false
+                configureFunctionalUnit(on: scanner)
+                state = .scanning
+                scanner.requestScan()
+            } else {
+                state = .ready
+            }
+        }
+    }
+}
+
+private extension ScanResolution {
+    var dpi: Int {
+        switch self {
+        case .dpi150: return 150
+        case .dpi300: return 300
+        case .dpi600: return 600
+        }
+    }
+}
+
+private extension ScannerService {
+    func imageFromBandData(_ bandData: ICScannerBandData) -> NSImage? {
+        guard let data = bandData.dataBuffer,
+              !data.isEmpty,
+              bandData.fullImageWidth > 0,
+              bandData.dataNumRows > 0 else { return nil }
+
+        let colorSpace = CGColorSpaceCreateDeviceRGB()
+        let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.noneSkipLast.rawValue)
+        guard let provider = CGDataProvider(data: data as CFData),
+              let cgImage = CGImage(
+                  width: Int(bandData.fullImageWidth),
+                  height: Int(bandData.dataNumRows),
+                  bitsPerComponent: Int(bandData.bitsPerComponent),
+                  bitsPerPixel: Int(bandData.bitsPerPixel),
+                  bytesPerRow: Int(bandData.bytesPerRow),
+                  space: colorSpace,
+                  bitmapInfo: bitmapInfo,
+                  provider: provider,
+                  decode: nil,
+                  shouldInterpolate: true,
+                  intent: .defaultIntent
+              ) else {
+            return nil
+        }
+
+        let rep = NSBitmapImageRep(cgImage: cgImage)
+        let image = NSImage(size: NSSize(width: bandData.fullImageWidth, height: bandData.dataNumRows))
+        image.addRepresentation(rep)
+        return image
+    }
+
+    func assembleImage(from bands: [ICScannerBandData]) -> NSImage? {
+        guard let first = bands.first,
+              first.fullImageWidth > 0,
+              first.fullImageHeight > 0 else { return nil }
+
+        let width = Int(first.fullImageWidth)
+        let height = Int(first.fullImageHeight)
+        let bytesPerRow = Int(first.bytesPerRow)
+        guard bytesPerRow > 0 else { return nil }
+
+        var fullData = Data(count: bytesPerRow * height)
+        for band in bands {
+            guard let bandData = band.dataBuffer, !bandData.isEmpty else { continue }
+            let startRow = Int(band.dataStartRow)
+            let startOffset = startRow * bytesPerRow
+            let copyLength = min(bandData.count, fullData.count - startOffset)
+            guard startOffset >= 0, copyLength > 0, startOffset + copyLength <= fullData.count else { continue }
+            fullData.replaceSubrange(startOffset..<(startOffset + copyLength), with: bandData.prefix(copyLength))
+        }
+
+        let colorSpace = CGColorSpaceCreateDeviceRGB()
+        let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.noneSkipLast.rawValue)
+        var mutableData = fullData
+        return mutableData.withUnsafeMutableBytes { buffer in
+            guard let baseAddress = buffer.baseAddress,
+                  let context = CGContext(
+                      data: baseAddress,
+                      width: width,
+                      height: height,
+                      bitsPerComponent: Int(first.bitsPerComponent),
+                      bytesPerRow: bytesPerRow,
+                      space: colorSpace,
+                      bitmapInfo: bitmapInfo.rawValue
+                  ),
+                  let cgImage = context.makeImage() else {
+                return nil
+            }
+            let rep = NSBitmapImageRep(cgImage: cgImage)
+            let image = NSImage(size: NSSize(width: width, height: height))
+            image.addRepresentation(rep)
+            return image
+        }
+    }
+}

+ 24 - 0
smart_printer/ViewController.swift

@@ -92,6 +92,21 @@ class ViewController: NSViewController {
         scanFileOverlay?.refreshAppearance()
     }
 
+    func presentScanner(showScanPreview: Bool = false, onComplete: @escaping (Result<NSImage, Error>) -> Void) {
+        photoPreviewOverlay?.dismiss(animated: false)
+        filePreviewOverlay?.dismiss(animated: false)
+        printTextOverlay?.dismiss(animated: false)
+        printContactsOverlay?.dismiss(animated: false)
+        drawPrintOverlay?.dismiss(animated: false)
+        ocrFileOverlay?.dismiss(animated: false)
+
+        ScannerPanelController.present(
+            from: view.window,
+            showResultPreview: showScanPreview,
+            onComplete: onComplete
+        )
+    }
+
     func presentPrintText() {
         photoPreviewOverlay?.dismiss(animated: false)
         filePreviewOverlay?.dismiss(animated: false)
@@ -150,9 +165,18 @@ class ViewController: NSViewController {
         drawPrintOverlay?.dismiss(animated: false)
         ocrFileOverlay?.dismiss(animated: false)
 
+        if let overlay = scanFileOverlay {
+            overlay.appendPages(pages)
+            overlay.present(in: mainContentView)
+            return
+        }
+
         let overlay = ScanFileOverlayView(pages: pages)
         overlay.onDismiss = { [weak self] in
             self?.scanFileOverlay = nil
+            Task { @MainActor in
+                await ScannerService.shared.stopBrowsing()
+            }
         }
         scanFileOverlay = overlay
         overlay.present(in: mainContentView)

+ 4 - 0
smart_printer/smart_printer.entitlements

@@ -6,8 +6,12 @@
 	<true/>
 	<key>com.apple.security.files.user-selected.read-write</key>
 	<true/>
+	<key>com.apple.security.files.downloads.read-write</key>
+	<true/>
 	<key>com.apple.security.network.client</key>
 	<true/>
+	<key>com.apple.security.network.server</key>
+	<true/>
 	<key>com.apple.security.print</key>
 	<true/>
 	<key>com.apple.security.personal-information.addressbook</key>