Build a SwiftUI Remote Document Scanner for macOS and iOS Using the Dynamic Web TWAIN REST API

In office environments, network-connected physical document scanners from manufacturers like HP , Canon , Epson , and Brother often support protocols such as ESCL (AirPrint) or ICA (Image Capture). However, for scanners that do not natively support these protocols, you can use third-party services like Dynamic Web TWAIN to enable network scanning. In this article, we will demonstrate how to build a remote document scanner app in SwiftUI to digitize documents over the network. The app leverages the Dynamic Web TWAIN REST API to communicate with scanners using protocols such as TWAIN, SANE, ICA, ESCL, and WIA.

What you’ll build: A cross-platform SwiftUI app for macOS and iOS that connects to a remote Dynamic Web TWAIN service and controls physical document scanners over a local network, displaying scanned pages in-app and saving them as PDF files.

Key Takeaways

  • Dynamic Web TWAIN’s REST API lets any Swift app control physical scanners (TWAIN, ESCL, SANE, ICA, WIA) over the network without installing platform-specific scanner drivers on the client device.
  • A single ScannerController.swift file wraps all REST calls — device enumeration, job creation, image streaming, and job deletion — using Swift’s async/await concurrency model.
  • SwiftUI’s conditional compilation (#if os(macOS)) isolates the small set of platform-specific APIs (NSImage vs. UIImage, NSSavePanel vs. UIDocumentPickerViewController) while sharing all scanner and networking logic.
  • The app targets both macOS and iOS from a single Xcode multiplatform project, requiring only App Sandbox entitlements and a local network usage description to function.

Common Developer Questions

  • How do I connect a SwiftUI app to a TWAIN or ESCL scanner over the network without native drivers?
  • What is the Dynamic Web TWAIN REST API and how do I call it from Swift using async/await?
  • How do I save scanned document images as a PDF in SwiftUI on both macOS and iOS?

Remote Document Scanner Demo Video

Prerequisites

  • Get a 30-day free trial license for Dynamic Web TWAIN.
  • Install the Dynamic Web TWAIN service on your local machine that has a connected scanner.
  • Navigate to http://127.0.0.1:18625/ to enable remote access by binding the IP address of your machine. Without this step, the service is inaccessible from other devices on the network. dynamsoft-service-config

Step 1: Set Up a New SwiftUI Project

  1. Create a new Multiplatform app project in Xcode to support both macOS and iOS.
  2. Navigate to Project Settings > Signing & Capabilities > App Sandbox, and enable:
    • Outgoing Connections (Client) and Incoming Connections (Server) (Required for macOS apps to communicate with the Dynamic Web TWAIN service).
    • User Selected File (Read/Write) permission (Allows saving scanned PDFs locally on macOS).

    app sandbox setting

  3. Navigate to Project Settings > Build Settings, and add the NSLocalNetworkUsageDescription key. This is required for the iOS app to request local network access to communicate with the Dynamic Web TWAIN service.

    local network usage description

Step 2: Convert the Dynamic Web TWAIN REST API Wrapper from C# to Swift

Previously, we wrote ScannerController.cs in C# to invoke the Dynamsoft RESTful API. Instead of rewriting it from scratch, we can quickly convert the C# code to Swift using AI tools like ChatGPT or Gemini.

Create the ScannerController.swift File

Create a new file named ScannerController.swift and paste the converted Swift code:

import SwiftUI

struct ScannerType {
    static let TWAINSCANNER: Int = 0x10
    static let WIASCANNER: Int = 0x20
    static let TWAINX64SCANNER: Int = 0x40
    static let ICASCANNER: Int = 0x80
    static let SANESCANNER: Int = 0x100
    static let ESCLSCANNER: Int = 0x200
    static let WIFIDIRECTSCANNER: Int = 0x400
    static let WIATWAINSCANNER: Int = 0x800
}

class ScannerController {
    static let SCAN_SUCCESS = "success"
    static let SCAN_ERROR = "error"

    private let httpClient = URLSession.shared

    func getDevices(host: String, scannerType: Int? = nil) async -> [[String: Any]] {
        var devices: [[String: Any]] = []

        do {
            let response = try await getDevicesHttpResponse(host: host, scannerType: scannerType)
            if response.statusCode == 200 {
                if let data = response.data, let responseBody = String(data: data, encoding: .utf8),
                    !responseBody.isEmpty
                {
                    devices = try JSONDecoder().decode([[String: AnyCodable]].self, from: data).map
                    { $0.mapValues { $0.value } }
                }
            }
        } catch {
            print(error.localizedDescription)
        }

        return devices
    }

    func getDevicesHttpResponse(host: String, scannerType: Int? = nil) async throws -> (
        statusCode: Int, data: Data?
    ) {
        var url = URL(string: "\(host)/DWTAPI/Scanners")!
        if let scannerType = scannerType {
            url = URL(string: "\(host)/DWTAPI/Scanners?type=\(scannerType)")!
        }

        var request = URLRequest(url: url)
        request.httpMethod = "GET"

        let (data, response) = try await httpClient.data(for: request)
        guard let httpResponse = response as? HTTPURLResponse else {
            throw NSError(domain: "InvalidResponse", code: 0, userInfo: nil)
        }
        return (httpResponse.statusCode, data)
    }

    func scanDocument(host: String, parameters: [String: Any]) async -> [String: String] {
        var dict: [String: String] = [:]

        do {
            let response = try await scanDocumentHttpResponse(host: host, parameters: parameters)
            if let data = response.data, let text = String(data: data, encoding: .utf8) {
                if response.statusCode == 200 || response.statusCode == 201 {
                    dict[ScannerController.SCAN_SUCCESS] = text
                } else {
                    dict[ScannerController.SCAN_ERROR] = text
                }
            }
        } catch {
            dict[ScannerController.SCAN_ERROR] = error.localizedDescription
        }

        return dict
    }

    func scanDocumentHttpResponse(host: String, parameters: [String: Any]) async throws -> (
        statusCode: Int, data: Data?
    ) {
        let url = URL(string: "\(host)/DWTAPI/ScanJobs")!
        var request = URLRequest(url: url)
        request.httpMethod = "POST"
        request.addValue("application/json", forHTTPHeaderField: "Content-Type")
        request.httpBody = try JSONSerialization.data(withJSONObject: parameters)

        let (data, response) = try await httpClient.data(for: request)
        guard let httpResponse = response as? HTTPURLResponse else {
            throw NSError(domain: "InvalidResponse", code: 0, userInfo: nil)
        }
        return (httpResponse.statusCode, data)
    }

    func deleteJob(host: String, jobId: String) async throws -> (statusCode: Int, data: Data?) {
        let url = URL(string: "\(host)/DWTAPI/ScanJobs/\(jobId)")!
        var request = URLRequest(url: url)
        request.httpMethod = "DELETE"

        let (data, response) = try await httpClient.data(for: request)
        guard let httpResponse = response as? HTTPURLResponse else {
            throw NSError(domain: "InvalidResponse", code: 0, userInfo: nil)
        }
        return (httpResponse.statusCode, data)
    }

    func getImageFile(host: String, jobId: String, directory: String) async -> String {
        do {
            let response = try await getImageStreamHttpResponse(host: host, jobId: jobId)
            if response.statusCode == 200, let data = response.data {
                let timestamp = Int(Date().timeIntervalSince1970 * 1000)
                let filename = "image_\(timestamp).jpg"
                let imagePath = URL(fileURLWithPath: directory).appendingPathComponent(filename)
                    .path
                try data.write(to: URL(fileURLWithPath: imagePath))
                return filename
            }
        } catch {
            print("No more images.")
        }

        return ""
    }

    func getImageFiles(host: String, jobId: String, directory: String) async -> [String] {
        var images: [String] = []

        while true {
            let filename = await getImageFile(host: host, jobId: jobId, directory: directory)
            if filename.isEmpty {
                break
            } else {
                images.append(filename)
            }
        }

        return images
    }

    func getImageStreams(host: String, jobId: String) async -> [[UInt8]] {
        var streams: [[UInt8]] = []

        while true {
            let bytes = await getImageStream(host: host, jobId: jobId)
            if bytes.isEmpty {
                break
            } else {
                streams.append(bytes)
            }
        }

        return streams
    }

    func getImageStream(host: String, jobId: String) async -> [UInt8] {
        do {
            let response = try await getImageStreamHttpResponse(host: host, jobId: jobId)

            if response.statusCode == 200, let data = response.data {
                return Array(data)
            } else if response.statusCode == 410 {
                return []
            }
        } catch {
            return []
        }

        return []
    }

    func getImageStreamHttpResponse(host: String, jobId: String) async throws -> (
        statusCode: Int, data: Data?
    ) {
        let timestamp = Int(Date().timeIntervalSince1970 * 1000)
        let url = URL(string: "\(host)/DWTAPI/ScanJobs/\(jobId)/NextDocument?\(timestamp)")!

        var request = URLRequest(url: url)
        request.httpMethod = "GET"

        let (data, response) = try await httpClient.data(for: request)
        guard let httpResponse = response as? HTTPURLResponse else {
            throw NSError(domain: "InvalidResponse", code: 0, userInfo: nil)
        }
        return (httpResponse.statusCode, data)
    }
}
...

Step 3: Build the Remote Document Scanner App in SwiftUI

Understand API Differences Between macOS and iOS

Although SwiftUI is cross-platform, macOS and iOS require different APIs for handling images and files:

macOS iOS Purpose
NSImage UIImage Image storage
NSSavePanel UIDocumentPickerViewController PDF saving UI
PDFDocument/PDFPage UIGraphicsPDFRenderer PDF rendering
Image(nsImage:) Image(uiImage:) SwiftUI Image display

Design the App Features and UI

App Features

  • List the available scanners via an IP address.
  • Select a scanner and start scanning.
  • Display the scanned images in a scroll view.
  • Save the scanned images as a PDF file.

UI Implementation in ContentView.swift

In ContentView, add the following UI components:

struct ContentView: View {
    @StateObject private var viewModel = ScannerViewModel()
    @State private var showAlert = false
    @State private var alertMessage = ""
    
    var body: some View {
        VStack {
            Picker("Select Scanner", selection: $viewModel.selectedScannerName) {
                ForEach(viewModel.rawScanners.indices, id: \.self) { index in
                    if let name = viewModel.rawScanners[index]["name"] as? String {
                        Text(name).tag(name as String?)
                    }
                }
            }
            .pickerStyle(.menu)
            .padding()
            
            HStack {
                Button("Fetch Scanners") {
                    Task { await viewModel.fetchScanners() }
                }
                Button("Scan Document") {
                    Task { await viewModel.scanDocument() }
                }
                .disabled(viewModel.selectedScannerName == nil)
            }
            .padding()
            
            ScrollViewReader { proxy in
                List {
                    ForEach(Array(viewModel.scannedImages.enumerated()), id: \.offset) {
                        index, image in
                        let image = viewModel.scannedImages[index]
                        #if os(macOS)
                        Image(nsImage: image).resizable()
                            .scaledToFit()
                            .frame(height: 400)
                            .id(index)
                        #else
                        Image(uiImage: image).resizable()
                            .scaledToFit()
                            .frame(height: 400)
                            .id(index)
                        #endif
                    }
                }
                .onChange(of: viewModel.scannedImages.count) {
                    withAnimation {
                        proxy.scrollTo(viewModel.scannedImages.count - 1, anchor: .bottom)
                    }
                }
            }
            
            Button("Save to PDF") {
                viewModel.saveImagesToPDF()
            }
            .padding()
        }
        .onAppear {
            Task { await viewModel.fetchScanners() }
        }
        .alert("Scanner Error", isPresented: $showAlert) {
            Button("OK") { }
        } message: {
            Text(alertMessage)
        }
        .frame(width: 500, height: 600)
    }
}

Explanation:

  • The Fetch Scanners button fetches the available scanners and lists them in the Picker component.
  • The Scan Document button triggers remote document scanning.
  • The ScrollViewReader component displays the scanned images.
  • The Save to PDF button saves the scanned images as a PDF file.

Implement the ScannerViewModel

The ScannerViewModel class serves the following purposes:

  • Acts as the central manager for scanner operations.
  • Bridges the UI (SwiftUI) with the scanner hardware/API.
  • Handles platform-specific implementations for macOS and iOS.

Key ScannerViewModel Functions

1. Initialize the Scanner Controller

The ScannerViewModel initializes the ScannerController, sets up the IP address, license key, and scanning parameters. Be sure to replace the IP and license key with your own values.

class ScannerViewModel: ObservableObject {
    @Published var rawScanners: [[String: Any]] = []
    @Published var selectedScannerName: String?
    @Published var scannedImages: [PlatformImage] = []
    
    private let scannerController = ScannerController()
    private let apiURL = "http://192.168.8.72:18622"
    private let licenseKey = "LICENSE-KEY"
    private let scanConfig: [String: Any] = [
        "IfShowUI": false,
        "PixelType": 2,
        "Resolution": 200,
        "IfFeederEnabled": false,
        "IfDuplexEnabled": false
    ]
}
2. Fetching Available Scanners

The function below retrieves a list of available scanners. You can specify a scanner type to filter the results.

func fetchScanners() async {
    let jsonArray = await scannerController.getDevices(
        host: apiURL,
        scannerType: ScannerType.TWAINX64SCANNER | ScannerType.ESCLSCANNER
    )
    
    await MainActor.run {
        rawScanners = jsonArray
        selectedScannerName = jsonArray.first?["name"] as? String
    }
}
3. Scanning a Document

The scanDocument() function sends a scan request to the selected scanner. It returns a job ID, which is later used to fetch scanned images.

func scanDocument() async {
    guard let scanner = rawScanners.first(where: { $0["name"] as? String == selectedScannerName }),
            let device = scanner["device"]
    else { return }
    
    let parameters: [String: Any] = [
        "license": licenseKey,
        "device": device,
        "config": scanConfig
    ]
    
    let result = await scannerController.scanDocument(
        host: apiURL,
        parameters: parameters
    )
    
    if let jobId = result[ScannerController.SCAN_SUCCESS] {
        await fetchImages(jobId: jobId)
    }
}
4. Fetching Scanned Images

This function retrieves the scanned images from the scanner and converts them into NSImage (macOS) or UIImage (iOS).

private func fetchImages(jobId: String) async {
    let streams = await scannerController.getImageStreams(host: apiURL, jobId: jobId)
    
    for bytes in streams {
        let data = Data(bytes: bytes, count: bytes.count)
        await MainActor.run {
            #if os(macOS)
            guard let image = NSImage(data: data) else { return }
            #else
            guard let image = UIImage(data: data) else { return }
            #endif
            scannedImages.append(image)
        }
    }
}
5. Saving Images as a PDF

This function saves scanned images as a PDF file and allows users to store it locally.

func saveImagesToPDF() {
    #if os(macOS)
    let pdfDocument = PDFDocument()
    for (index, image) in scannedImages.enumerated() {
        if let pdfPage = PDFPage(image: image) {
            pdfDocument.insert(pdfPage, at: index)
        }
    }
    
    let savePanel = NSSavePanel()
    savePanel.allowedContentTypes = [.pdf]
    savePanel.nameFieldStringValue = "ScannedDocument.pdf"
    
    if savePanel.runModal() == .OK, let url = savePanel.url {
        pdfDocument.write(to: url)
    }
    #else
    guard let pdfData = createPDFData() else { return }
    let tempURL = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("ScannedDocument.pdf")
    
    do {
        try pdfData.write(to: tempURL)
        let controller = UIDocumentPickerViewController(forExporting: [tempURL])
        if let windowScene = UIApplication.shared.connectedScenes
            .filter({ $0.activationState == .foregroundActive })
            .first as? UIWindowScene {
            
            windowScene.windows.first?.rootViewController?.present(controller, animated: true)
        }
    } catch {
        print("Save failed: \(error.localizedDescription)")
    }
    #endif
}

Run and Test the App

Run the app on macOS or iOS to test the scanner functionality.

SwiftUI remote document scanner running on macOS

Common Issues & Edge Cases

  • Service unreachable from the iOS/macOS client: Ensure remote access is enabled in the Dynamic Web TWAIN service dashboard at http://127.0.0.1:18625/ and that the host machine’s IP is bound. The firewall on the scanner host must allow inbound connections on port 18622.
  • Scanner not listed after fetching: The scanner type bitmask may not match the connected device. Try omitting the scannerType parameter or combining flags (e.g., ScannerType.TWAINX64SCANNER | ScannerType.ESCLSCANNER) to broaden discovery.
  • No images returned after a successful scan job: Verify the jobId from scanDocument() is non-empty. A 410 Gone response from NextDocument is the normal completion signal — it means all pages have been delivered. If zero images arrive, confirm the scanner has a document loaded and that the selected device string matches the physical scanner.

Source Code

https://github.com/yushulx/ios-swiftui-barcode-mrz-document-scanner/tree/main/examples/RemoteScanner