|
|
@@ -48,14 +48,11 @@ final class ScannerService: NSObject {
|
|
|
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 let connectionTimeoutSeconds: UInt64 = 20
|
|
|
+ private let sessionCloseTimeoutSeconds: UInt64 = 6
|
|
|
|
|
|
private override init() {
|
|
|
super.init()
|
|
|
@@ -67,91 +64,167 @@ final class ScannerService: NSObject {
|
|
|
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.
|
|
|
+ /// Opens the scanner panel. Reuses an existing session when one is already open.
|
|
|
func prepareForScanning() async {
|
|
|
clearConnectionTimeout()
|
|
|
isOpeningSession = false
|
|
|
- sessionOpenRetryCount = 0
|
|
|
+ isClosingSession = false
|
|
|
didFinishCurrentScan = false
|
|
|
shouldScanWhenReady = false
|
|
|
scanCompletion = nil
|
|
|
scannedImageURL = nil
|
|
|
accumulatedBands = []
|
|
|
- pendingPreferredDeviceName = lastSelectedDeviceName
|
|
|
|
|
|
- browser.stop()
|
|
|
- await closeOpenSessionIfNeeded()
|
|
|
- resetDeviceLists()
|
|
|
+ if let device = selectedDevice, device.hasOpenSession {
|
|
|
+ device.delegate = self
|
|
|
+ state = .ready
|
|
|
+ ensureBrowsing()
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ if let device = deviceMatchingLastSelection(), device.hasOpenSession {
|
|
|
+ selectedDevice = device
|
|
|
+ device.delegate = self
|
|
|
+ state = .ready
|
|
|
+ ensureBrowsing()
|
|
|
+ return
|
|
|
+ }
|
|
|
|
|
|
- try? await Task.sleep(nanoseconds: browserRestartDelayNanoseconds)
|
|
|
- browser.start()
|
|
|
+ pendingPreferredDeviceName = lastSelectedDeviceName
|
|
|
+ ensureBrowsing()
|
|
|
state = .browsing
|
|
|
}
|
|
|
|
|
|
- func stopBrowsing() async {
|
|
|
+ /// Panel closed — keep the scanner session alive for the next open.
|
|
|
+ func detachFromUI() {
|
|
|
clearConnectionTimeout()
|
|
|
- browser.stop()
|
|
|
- rememberSelectedDeviceName()
|
|
|
- await closeOpenSessionIfNeeded()
|
|
|
- resetFlagsAfterShutdown()
|
|
|
+ scanCompletion = nil
|
|
|
+ shouldScanWhenReady = false
|
|
|
}
|
|
|
|
|
|
- private func rememberSelectedDeviceName() {
|
|
|
+ /// Fully releases the scanner when leaving the scan feature entirely.
|
|
|
+ func releaseScanner() async {
|
|
|
+ clearConnectionTimeout()
|
|
|
+ detachFromUI()
|
|
|
+
|
|
|
if let device = selectedDevice {
|
|
|
- let name = device.name ?? device.locationDescription
|
|
|
- if let name { lastSelectedDeviceName = name }
|
|
|
+ await closeSession(on: device)
|
|
|
}
|
|
|
+
|
|
|
+ if browser.isBrowsing {
|
|
|
+ browser.stop()
|
|
|
+ }
|
|
|
+
|
|
|
+ resetAfterFullShutdown()
|
|
|
}
|
|
|
|
|
|
- private func resetDeviceLists() {
|
|
|
- selectedDevice = nil
|
|
|
- localDevices = []
|
|
|
- sharedDevices = []
|
|
|
+ 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) }
|
|
|
+ }
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ selectedDevice = device
|
|
|
+
|
|
|
+ if device.hasOpenSession {
|
|
|
+ device.delegate = self
|
|
|
+ state = .ready
|
|
|
+ } else {
|
|
|
+ Task { await openSession(on: device) }
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
- private func resetFlagsAfterShutdown() {
|
|
|
+ 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) }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ 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
|
|
|
pendingPreferredDeviceName = nil
|
|
|
- resetDeviceLists()
|
|
|
+ selectedDevice = nil
|
|
|
+ localDevices = []
|
|
|
+ sharedDevices = []
|
|
|
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
|
|
|
- }
|
|
|
+ private func closeSession(on device: ICScannerDevice) async {
|
|
|
device.delegate = nil
|
|
|
+ if state == .scanning {
|
|
|
+ device.cancelScan()
|
|
|
+ }
|
|
|
guard device.hasOpenSession else {
|
|
|
isClosingSession = false
|
|
|
return
|
|
|
}
|
|
|
isClosingSession = true
|
|
|
- await closeDeviceSessionAndWait(device)
|
|
|
+ do {
|
|
|
+ try await device.requestCloseSession()
|
|
|
+ processSessionClosed(on: device, error: nil)
|
|
|
+ } catch {
|
|
|
+ processSessionClosed(on: device, error: error)
|
|
|
+ }
|
|
|
+ await waitForSessionToCloseAsync()
|
|
|
}
|
|
|
|
|
|
- private func waitForSessionToClose() async {
|
|
|
+ private func waitForSessionToCloseAsync() async {
|
|
|
if !isClosingSession, selectedDevice?.hasOpenSession != true {
|
|
|
return
|
|
|
}
|
|
|
@@ -171,37 +244,34 @@ final class ScannerService: NSObject {
|
|
|
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 openSession(on device: ICScannerDevice) async {
|
|
|
+ guard selectedDevice === device else { return }
|
|
|
|
|
|
- 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)
|
|
|
+ clearConnectionTimeout()
|
|
|
+ device.delegate = self
|
|
|
+
|
|
|
+ if device.hasOpenSession {
|
|
|
+ isOpeningSession = false
|
|
|
+ state = .ready
|
|
|
+ if shouldScanWhenReady {
|
|
|
+ shouldScanWhenReady = false
|
|
|
+ configureFunctionalUnit(on: device)
|
|
|
+ state = .scanning
|
|
|
+ device.requestScan()
|
|
|
}
|
|
|
+ return
|
|
|
}
|
|
|
- }
|
|
|
|
|
|
- private func closeDeviceSessionAndWait(_ device: ICScannerDevice) async {
|
|
|
+ isOpeningSession = true
|
|
|
+ state = .connecting
|
|
|
+ scheduleConnectionTimeout(for: device)
|
|
|
+
|
|
|
do {
|
|
|
- try await device.requestCloseSession()
|
|
|
- processSessionClosed(on: device, error: nil)
|
|
|
+ try await device.requestOpenSession()
|
|
|
+ processSessionOpened(on: device, error: nil)
|
|
|
} catch {
|
|
|
- processSessionClosed(on: device, error: error)
|
|
|
+ processSessionOpened(on: device, error: error)
|
|
|
}
|
|
|
- await waitForSessionToClose()
|
|
|
}
|
|
|
|
|
|
private func processSessionOpened(on scanner: ICScannerDevice, error: Error?) {
|
|
|
@@ -212,24 +282,12 @@ final class ScannerService: NSObject {
|
|
|
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
|
|
|
@@ -242,160 +300,27 @@ final class ScannerService: NSObject {
|
|
|
}
|
|
|
|
|
|
private func processSessionClosed(on device: ICDevice, error: Error?) {
|
|
|
- sessionOpenRetryCount = 0
|
|
|
+ let wasClosing = isClosingSession
|
|
|
resumeSessionCloseWait()
|
|
|
+ guard wasClosing else { return }
|
|
|
if selectedDevice === device, state != .idle {
|
|
|
state = .browsing
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- /// Keeps the scanner session alive for subsequent scans (e.g. Add Page).
|
|
|
- func resetAfterScan() {
|
|
|
+ /// 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
|
|
|
- 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)
|
|
|
}
|
|
|
}
|
|
|
|
|
|
@@ -407,20 +332,10 @@ final class ScannerService: NSObject {
|
|
|
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
|
|
|
+ isOpeningSession = false
|
|
|
+ device.delegate = nil
|
|
|
+ state = .error("Could not connect to the scanner. Select it again to retry.")
|
|
|
}
|
|
|
- state = .error("Could not connect to the scanner. Select it again to retry.")
|
|
|
}
|
|
|
|
|
|
private func clearConnectionTimeout() {
|
|
|
@@ -428,34 +343,6 @@ final class ScannerService: NSObject {
|
|
|
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
|
|
|
@@ -547,18 +434,7 @@ final class ScannerService: NSObject {
|
|
|
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
|
|
|
+ resetAfterScan()
|
|
|
}
|
|
|
|
|
|
private func imageFromScanURL(_ url: URL) -> NSImage? {
|
|
|
@@ -667,6 +543,7 @@ extension ScannerService: ICDeviceDelegate, ICScannerDeviceDelegate {
|
|
|
nonisolated func scannerDeviceDidBecomeAvailable(_ scanner: ICScannerDevice) {
|
|
|
Task { @MainActor in
|
|
|
guard selectedDevice === scanner else { return }
|
|
|
+ guard state == .connecting || isOpeningSession else { return }
|
|
|
clearConnectionTimeout()
|
|
|
isOpeningSession = false
|
|
|
isClosingSession = false
|