| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721 |
- 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 isMaintainingSession = false
- private var hasEstablishedSession = false
- private var lastSelectedDeviceName: String?
- private var pendingPreferredDeviceName: String?
- private var sessionCloseContinuation: CheckedContinuation<Void, Never>?
- private let connectionTimeoutSeconds: UInt64 = 20
- private let sessionCloseTimeoutSeconds: UInt64 = 6
- 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
- }
- /// Opens the scanner panel. Reuses an existing session when one is already open.
- func prepareForScanning() async {
- clearConnectionTimeout()
- isOpeningSession = false
- isClosingSession = false
- didFinishCurrentScan = false
- shouldScanWhenReady = false
- scanCompletion = nil
- scannedImageURL = nil
- accumulatedBands = []
- if let device = selectedDevice {
- device.delegate = self
- if device.hasOpenSession {
- state = .ready
- ensureBrowsing()
- return
- }
- if hasEstablishedSession {
- state = .ready
- ensureBrowsing()
- maintainSession(for: device)
- return
- }
- }
- if let device = deviceMatchingLastSelection() {
- selectedDevice = device
- device.delegate = self
- if device.hasOpenSession {
- state = .ready
- ensureBrowsing()
- return
- }
- if hasEstablishedSession {
- state = .ready
- ensureBrowsing()
- maintainSession(for: device)
- return
- }
- }
- pendingPreferredDeviceName = lastSelectedDeviceName
- ensureBrowsing()
- state = .browsing
- }
- /// Panel closed — keep the scanner session alive for the next open.
- func detachFromUI() {
- clearConnectionTimeout()
- scanCompletion = nil
- shouldScanWhenReady = false
- }
- /// Fully releases the scanner when leaving the scan feature entirely.
- func releaseScanner() async {
- clearConnectionTimeout()
- detachFromUI()
- if let device = selectedDevice {
- await closeSession(on: device)
- }
- if browser.isBrowsing {
- browser.stop()
- }
- resetAfterFullShutdown()
- }
- /// True when reconnecting after a previous successful scan in this session.
- var isWarmReconnecting: Bool {
- isMaintainingSession || (isOpeningSession && hasEstablishedSession)
- }
- func selectDevice(_ device: ICScannerDevice) {
- let name = device.name ?? device.locationDescription
- if let name { lastSelectedDeviceName = name }
- pendingPreferredDeviceName = nil
- if selectedDevice === device {
- if device.hasOpenSession {
- device.delegate = self
- state = .ready
- } else if case .connecting = state {
- return
- } else {
- Task { await openSession(on: device, warm: hasEstablishedSession) }
- }
- return
- }
- selectedDevice = device
- if device.hasOpenSession {
- device.delegate = self
- state = .ready
- } else {
- Task { await openSession(on: device, warm: hasEstablishedSession) }
- }
- }
- func scan() {
- guard let device = selectedDevice else { return }
- scanCompletion = onScanComplete
- scannedImageURL = nil
- didFinishCurrentScan = false
- accumulatedBands = []
- if case .connecting = state {
- shouldScanWhenReady = true
- return
- }
- device.delegate = self
- if device.hasOpenSession {
- configureFunctionalUnit(on: device)
- state = .scanning
- device.requestScan()
- } else {
- shouldScanWhenReady = true
- Task { await openSession(on: device, warm: hasEstablishedSession) }
- }
- }
- func cancelScan() {
- selectedDevice?.cancelScan()
- scanCompletion?(.failure(ScanError.cancelled))
- scanCompletion = nil
- scannedImageURL = nil
- didFinishCurrentScan = true
- accumulatedBands = []
- state = selectedDevice?.hasOpenSession == true ? .ready : .browsing
- }
- private func ensureBrowsing() {
- if !browser.isBrowsing {
- browser.start()
- }
- }
- private func deviceMatchingLastSelection() -> ICScannerDevice? {
- guard let name = lastSelectedDeviceName else { return nil }
- return (localDevices + sharedDevices).first {
- ($0.name ?? $0.locationDescription) == name
- }
- }
- private func resetAfterFullShutdown() {
- isClosingSession = false
- isOpeningSession = false
- isMaintainingSession = false
- hasEstablishedSession = false
- pendingPreferredDeviceName = nil
- selectedDevice = nil
- localDevices = []
- sharedDevices = []
- state = .idle
- scannedImageURL = nil
- didFinishCurrentScan = false
- accumulatedBands = []
- shouldScanWhenReady = false
- }
- private func closeSession(on device: ICScannerDevice) async {
- device.delegate = nil
- if state == .scanning {
- device.cancelScan()
- }
- guard device.hasOpenSession else {
- isClosingSession = false
- return
- }
- isClosingSession = true
- do {
- try await device.requestCloseSession()
- processSessionClosed(on: device, error: nil)
- } catch {
- processSessionClosed(on: device, error: error)
- }
- await waitForSessionToCloseAsync()
- }
- private func waitForSessionToCloseAsync() 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 maintainSession(for device: ICScannerDevice) {
- guard selectedDevice === device, !device.hasOpenSession else { return }
- guard !isOpeningSession, !isMaintainingSession, !isClosingSession else { return }
- isMaintainingSession = true
- Task {
- await openSession(on: device, warm: true)
- isMaintainingSession = false
- }
- }
- private func openSession(on device: ICScannerDevice, warm: Bool = false) async {
- guard selectedDevice === device else { return }
- clearConnectionTimeout()
- device.delegate = self
- if device.hasOpenSession {
- isOpeningSession = false
- state = .ready
- if shouldScanWhenReady {
- shouldScanWhenReady = false
- configureFunctionalUnit(on: device)
- state = .scanning
- device.requestScan()
- }
- return
- }
- isOpeningSession = true
- state = (warm && hasEstablishedSession) ? .ready : .connecting
- scheduleConnectionTimeout(for: device)
- do {
- try await device.requestOpenSession()
- processSessionOpened(on: device, error: nil)
- } catch {
- processSessionOpened(on: device, error: error)
- }
- }
- 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 {
- state = .error(error.localizedDescription)
- finishScan(with: .failure(error))
- return
- }
- isClosingSession = false
- hasEstablishedSession = true
- 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?) {
- let wasClosing = isClosingSession
- resumeSessionCloseWait()
- guard let scanner = device as? ICScannerDevice,
- selectedDevice === scanner else { return }
- if wasClosing {
- if state != .idle { state = .browsing }
- return
- }
- // Many scanners close the session after each scan — reopen quietly for the next one.
- guard state != .idle else { return }
- maintainSession(for: scanner)
- }
- /// Keeps the scanner session open after each scan for the next one.
- private func resetAfterScan() {
- clearConnectionTimeout()
- scanCompletion = nil
- scannedImageURL = nil
- didFinishCurrentScan = false
- accumulatedBands = []
- shouldScanWhenReady = false
- isOpeningSession = false
- if let device = selectedDevice {
- device.delegate = self
- if device.hasOpenSession {
- state = .ready
- } else {
- state = .ready
- maintainSession(for: device)
- }
- }
- }
- 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 }
- isOpeningSession = false
- device.delegate = nil
- state = .error("Could not connect to the scanner. Select it again to retry.")
- }
- }
- private func clearConnectionTimeout() {
- connectionTimeoutTask?.cancel()
- connectionTimeoutTask = nil
- }
- 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)
- resetAfterScan()
- }
- 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 }
- guard state == .connecting || isOpeningSession else { return }
- clearConnectionTimeout()
- isOpeningSession = false
- isClosingSession = false
- hasEstablishedSession = true
- 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 verticallyFlipped(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 verticallyFlipped(image)
- }
- }
- func verticallyFlipped(_ image: NSImage) -> NSImage {
- guard let cgImage = image.cgImage(forProposedRect: nil, context: nil, hints: nil) else { return image }
- let width = cgImage.width
- let height = cgImage.height
- let colorSpace = cgImage.colorSpace ?? CGColorSpaceCreateDeviceRGB()
- guard let context = CGContext(
- data: nil,
- width: width,
- height: height,
- bitsPerComponent: cgImage.bitsPerComponent,
- bytesPerRow: 0,
- space: colorSpace,
- bitmapInfo: cgImage.bitmapInfo.rawValue
- ) else {
- return image
- }
- context.translateBy(x: 0, y: CGFloat(height))
- context.scaleBy(x: 1, y: -1)
- context.draw(cgImage, in: CGRect(x: 0, y: 0, width: width, height: height))
- guard let flipped = context.makeImage() else { return image }
- return NSImage(cgImage: flipped, size: NSSize(width: width, height: height))
- }
- }
|