Diagnostics
A support screen you can drop into any app. The top half lists the device model, connected Aila hardware, connection type, SDK and firmware versions, serial number, and kiosk power. The bottom half is a live console showing the SDK's own log output, filterable by category and exportable through the share sheet. It works with or without a kiosk.
Folder in the download: Diagnostics/. Scheme: Diagnostics.
What you'll see

How it works
System information
refreshDiagnostics() fills a SystemInfo value from a mix of synchronous and asynchronous SDK calls:
- Synchronous:
Aila_GetHardwareDescription(),Aila_GetConnectionInfo(),Aila_GetSDKVersion(), andAila_GetFirmwareVersionNumber()return immediately.mapConnectionTypeturns theAilaConnectionInfoenum into a label, and flags analog simplex as a problem since it means the kiosk connection is degraded. - Asynchronous:
Aila_GetHardwareSerialNumberandAila_GetChargerWattagetake completion handlers because they query the kiosk. Their results are merged into the existingSystemInfoon the main queue withwithUpdated(...).
Everything refreshes when AilaHardwareChanged fires, so connecting or disconnecting a kiosk updates the table.
Turning up SDK logging. In buildConfiguration(), config.debugLevel = .debug and config.logCategories = nil ask the SDK to log everything at debug level. This is what makes the console useful. Lower the level in production.
import Foundation
import UIKit
import SwiftUI
import Aila
private enum DiagnosticsConstants {
static let refreshDelay: TimeInterval = 0.3
}
struct SystemInfo: Identifiable {
let id = UUID()
let deviceName: String
let modelIdentifier: String
let ailaDevice: String
let connectionType: String
let sdkVersion: String
let serialNumber: String
let firmwareVersion: String
let powerStatus: String
let kioskPower: String
let powerManagement: String
var asKeyValuePairs: [(key: String, value: String)] {
[
("Model ID | Device Name", "\(modelIdentifier) | \(deviceName)"),
("Aila Device", ailaDevice),
("Connection Type", connectionType),
("SDK Version | Firmware Version", "\(sdkVersion) | \(firmwareVersion)"),
("Serial Number", serialNumber),
("Power Status | Power Management", "\(powerStatus) | \(powerManagement)"),
("Kiosk Power", kioskPower)
]
}
func withUpdated(serialNumber: String? = nil, kioskPower: String? = nil) -> SystemInfo {
SystemInfo(
deviceName: deviceName,
modelIdentifier: modelIdentifier,
ailaDevice: ailaDevice,
connectionType: connectionType,
sdkVersion: sdkVersion,
serialNumber: serialNumber ?? self.serialNumber,
firmwareVersion: firmwareVersion,
powerStatus: powerStatus,
kioskPower: kioskPower ?? self.kioskPower,
powerManagement: powerManagement
)
}
}
final class DiagnosticsViewModel: ObservableObject {
@Published private(set) var systemInfo: SystemInfo
@Published private(set) var isRefreshing = false
private var hardwareChangedObserver: NSObjectProtocol?
private let mainQueue = OperationQueue.main
private var modelIdentifier: String {
if let sim = ProcessInfo.processInfo.environment["SIMULATOR_MODEL_IDENTIFIER"] {
return sim
}
var uts = utsname()
uname(&uts)
let data = Data(bytes: &uts.machine, count: Int(_SYS_NAMELEN))
if let id = String(data: data, encoding: .ascii) {
return id.trimmingCharacters(in: .controlCharacters)
}
return "unknown"
}
init() {
systemInfo = SystemInfo(
deviceName: UIDevice.current.name,
modelIdentifier: "—",
ailaDevice: "—",
connectionType: "—",
sdkVersion: "—",
serialNumber: "Loading…",
firmwareVersion: "—",
powerStatus: "—",
kioskPower: "Loading…",
powerManagement: "Active"
)
}
func start() {
Aila_Init()
Aila_SetConfiguration(buildConfiguration())
Aila_Start()
refreshDiagnostics()
addHardwareChangedObserver()
}
private func buildConfiguration() -> AilaConfiguration {
let config = AilaConfiguration()
config.multiScanMode = .off
config.beepVolume = .volume3
config.debugLevel = .debug
config.logCategories = nil
config.setCode(.typeUPC, enabled: true)
config.setCode(.typeEAN, enabled: true)
config.setCode(.typeQR, enabled: true)
config.setCode(.type128, enabled: true)
config.setCode(.typePDF417, enabled: true)
config.scanCallback = { [weak self] results in
self?.handleScanResults(results)
}
return config
}
private func handleScanResults(_ results: [AilaScanObject]?) {
guard let results = results, !results.isEmpty else { return }
let first = results[0]
let data = first.data ?? "no data"
let type = first.typeDescription() ?? "no type"
print("Scanned — type: \(type), data: \(data)")
}
func stop() {
removeHardwareChangedObserver()
Aila_Stop()
}
private func refreshDiagnostics() {
isRefreshing = true
let powered = (UIDevice.current.batteryState == .charging || UIDevice.current.batteryState == .full)
let powerStatusText = powered ? "Powered" : "Not Powered"
systemInfo = SystemInfo(
deviceName: UIDevice.current.name,
modelIdentifier: modelIdentifier,
ailaDevice: Aila_GetHardwareDescription(),
connectionType: mapConnectionType(Aila_GetConnectionInfo()),
sdkVersion: Aila_GetSDKVersion(),
serialNumber: "Loading…",
firmwareVersion: Aila_GetFirmwareVersionNumber(),
powerStatus: powerStatusText,
kioskPower: "Loading…",
powerManagement: "Active"
)
fetchSerialNumber()
fetchKioskPower()
DispatchQueue.main.asyncAfter(deadline: .now() + DiagnosticsConstants.refreshDelay) { [weak self] in
self?.isRefreshing = false
}
}
private func mapConnectionType(_ type: AilaConnectionInfo) -> String {
switch type {
case .digital: return "Digital"
case .analogDuplex: return "Analog"
case .analogSimplex: return "Analog (problem!)"
default: return "Not Available"
}
}
private func fetchSerialNumber() {
Aila_GetHardwareSerialNumber { [weak self] serial in
self?.mainQueue.addOperation {
guard let self else { return }
let value = (serial ?? "").isEmpty ? "Not Available" : (serial ?? "")
self.systemInfo = self.systemInfo.withUpdated(serialNumber: value)
}
}
}
private func fetchKioskPower() {
Aila_GetChargerWattage { [weak self] wattage in
self?.mainQueue.addOperation {
guard let self else { return }
let value = wattage != 0 ? "\(wattage)W available" : "Not Available"
self.systemInfo = self.systemInfo.withUpdated(kioskPower: value)
}
}
}
private func addHardwareChangedObserver() {
removeHardwareChangedObserver()
hardwareChangedObserver = NotificationCenter.default.addObserver(
forName: .AilaHardwareChanged,
object: nil,
queue: mainQueue
) { [weak self] _ in
self?.refreshDiagnostics()
}
}
private func removeHardwareChangedObserver() {
if let observer = hardwareChangedObserver {
NotificationCenter.default.removeObserver(observer)
hardwareChangedObserver = nil
}
}
}
Log console
The SDK logs through Apple's unified logging system. ConsoleViewModel opens an OSLogStore scoped to the current process and polls it once a second for new OSLogEntryLog entries. Each entry is mapped to a LogLevel and, by matching on the log's category string, to an AilaLogCategory such as scan, MRZ, licensing, cloud, camera, or telemetry. The picker filters the list by category. Export writes the visible entries to a text file and presents a UIActivityViewController.
The console is self-contained. ConsoleView and ConsoleViewModel do not depend on the rest of the quickstart, so you can lift both files into your own app as a debug screen.
Diagnostics/ConsoleViewModel.swift
import Foundation
import SwiftUI
import Aila
import OSLog
private enum LogConstants {
static let fetchInterval: TimeInterval = 1.0
static let maxEntries = 1000
static let trimmedCount = 100
static let historyDuration: TimeInterval = 300
}
enum DiagnosticsError: LocalizedError {
case logStoreInitializationFailed(Error)
case logFetchFailed(Error)
case exportFailed(Error)
var errorDescription: String? {
switch self {
case .logStoreInitializationFailed(let error):
return "Failed to initialize log monitoring: \(error.localizedDescription)"
case .logFetchFailed(let error):
return "Failed to fetch logs: \(error.localizedDescription)"
case .exportFailed(let error):
return "Failed to export logs: \(error.localizedDescription)"
}
}
}
enum LogLevel: Identifiable, CaseIterable {
case error, warn, info, debug, bulk
var id: String { displayName }
var displayName: String {
switch self {
case .error: return "ERROR"
case .warn: return "WARN"
case .info: return "INFO"
case .debug: return "DEBUG"
case .bulk: return "BULK"
}
}
var color: Color {
switch self {
case .error: return .red
case .warn: return .orange
case .info: return .blue
case .debug: return .green
case .bulk: return .purple
}
}
}
extension AilaLogCategory: @retroactive Identifiable, @retroactive CaseIterable {
public static var allCases: [AilaLogCategory] {
[.general, .MRZ, .scan, .licensing, .cloud, .camera, .telemetry]
}
public var id: Int { rawValue }
var displayName: String {
switch self {
case .general: return "GEN"
case .MRZ: return "MRZ"
case .scan: return "SCAN"
case .licensing: return "LIC"
case .cloud: return "CLOUD"
case .camera: return "CAM"
case .telemetry: return "TEL"
@unknown default: return "UNK"
}
}
}
struct LogEntry: Identifiable, Equatable {
let id = UUID()
let timestamp: Date
let level: LogLevel
let category: AilaLogCategory?
let message: String
static func == (lhs: LogEntry, rhs: LogEntry) -> Bool {
lhs.id == rhs.id
}
}
final class ConsoleViewModel: ObservableObject {
@Published private(set) var logs: [LogEntry] = []
@Published var selectedLogCategory: AilaLogCategory?
@Published var showingExportShare = false
@Published private(set) var exportedFileURL: URL?
@Published private(set) var error: DiagnosticsError?
private var logTimer: Timer?
private var logStore: OSLogStore?
private var lastLogFetchTime: Date = Date()
private let mainQueue = OperationQueue.main
var filteredLogs: [LogEntry] {
if let selectedCategory = selectedLogCategory {
return logs.filter { $0.category == selectedCategory }
}
return logs
}
func start() {
do {
logStore = try OSLogStore(scope: .currentProcessIdentifier)
addLogEntry(level: .info, category: .general, message: "Log monitoring started")
fetchRecentLogs()
lastLogFetchTime = Date()
logTimer = Timer.scheduledTimer(withTimeInterval: LogConstants.fetchInterval, repeats: true) { [weak self] timer in
guard let self = self else {
timer.invalidate()
return
}
self.fetchLogs()
}
} catch {
self.error = .logStoreInitializationFailed(error)
}
}
func stop() {
logTimer?.invalidate()
logTimer = nil
logStore = nil
}
private func fetchLogs() {
guard let logStore = logStore else { return }
let currentTime = Date()
DispatchQueue.global(qos: .utility).async { [weak self] in
guard let self = self else { return }
do {
let position = logStore.position(timeIntervalSinceLatestBoot: self.lastLogFetchTime.timeIntervalSince1970)
let predicate = NSPredicate(format: "process == %@ AND date >= %@", ProcessInfo.processInfo.processName, self.lastLogFetchTime as NSDate)
let entries = try logStore.getEntries(with: [], at: position, matching: predicate)
let newLogEntries = self.processLogEntries(entries)
if !newLogEntries.isEmpty {
DispatchQueue.main.async {
self.updateLogs(with: newLogEntries)
self.lastLogFetchTime = currentTime
}
}
} catch {
DispatchQueue.main.async {
self.error = .logFetchFailed(error)
}
}
}
}
private func mapOSLogLevel(_ osLogLevel: OSLogEntryLog.Level) -> LogLevel {
switch osLogLevel {
case .undefined: return .info
case .debug: return .debug
case .info: return .info
case .notice: return .info
case .error: return .error
case .fault: return .error
@unknown default: return .info
}
}
private func extractAilaCategoryFromLog(_ entry: OSLogEntryLog) -> AilaLogCategory? {
let message = entry.composedMessage
let categoryMappings: [(String, AilaLogCategory)] = [
("[Aila Camera]", .camera),
("[Aila MRZ]", .MRZ),
("[Aila Scan]", .scan),
("[Aila Licensing]", .licensing),
("[Aila Cloud]", .cloud),
("[Aila Telemetry]", .telemetry),
("[Aila General]", .general),
("[Aila Unknown]", .general)
]
return categoryMappings.first { message.contains($0.0) }?.1
}
func addLogEntry(level: LogLevel, category: AilaLogCategory?, message: String) {
let entry = LogEntry(
timestamp: Date(),
level: level,
category: category,
message: message
)
logs.append(entry)
trimLogs()
}
func clearLogs() {
logs.removeAll()
}
func exportLogs() {
let logsToExport = filteredLogs
let categoryName = selectedLogCategory?.displayName ?? "All"
guard !logsToExport.isEmpty else {
print("No logs to export")
return
}
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "yyyy-MM-dd_HH-mm-ss"
let timestamp = dateFormatter.string(from: Date())
let fileName = "aila_logs_\(categoryName.lowercased())_\(timestamp).txt"
let logContent = logsToExport.map { logEntry in
let timestampString = DateFormatter.fullTimestampFormatter.string(from: logEntry.timestamp)
let categoryString = logEntry.category?.displayName ?? "—"
return "[\(timestampString)] [\(logEntry.level.displayName)] [\(categoryString)] \(logEntry.message)"
}.joined(separator: "\n")
do {
let documentsPath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
let fileURL = documentsPath.appendingPathComponent(fileName)
try logContent.write(to: fileURL, atomically: true, encoding: .utf8)
exportedFileURL = fileURL
showingExportShare = true
print("Logs exported to: \(fileURL.path)")
} catch {
self.error = .exportFailed(error)
}
}
private func fetchRecentLogs() {
guard let logStore = logStore else { return }
do {
let startTime = Date().addingTimeInterval(-LogConstants.historyDuration)
let predicate = NSPredicate(format: "process == %@ AND date >= %@", ProcessInfo.processInfo.processName, startTime as NSDate)
let entries = try logStore.getEntries(with: [], at: nil, matching: predicate)
let recentLogEntries = processLogEntries(entries)
updateLogs(with: recentLogEntries)
} catch {
self.error = .logFetchFailed(error)
}
}
private func processLogEntries<T: Sequence>(_ entries: T) -> [LogEntry] where T.Element == OSLogEntry {
return entries.compactMap { entry in
guard let logEntry = entry as? OSLogEntryLog,
let category = extractAilaCategoryFromLog(logEntry) else {
return nil
}
return LogEntry(
timestamp: logEntry.date,
level: mapOSLogLevel(logEntry.level),
category: category,
message: logEntry.composedMessage
)
}
}
private func updateLogs(with newEntries: [LogEntry]) {
logs.append(contentsOf: newEntries)
logs.sort(by: { $0.timestamp < $1.timestamp })
trimLogs()
}
private func trimLogs() {
if logs.count > LogConstants.maxEntries {
logs.removeFirst(logs.count - LogConstants.trimmedCount)
}
}
}
Diagnostics/ConsoleView.swift
import SwiftUI
import UIKit
private enum UIConstants {
static let systemInfoMinLabelWidth: CGFloat = 225
static let consoleHeight: CGFloat = 300
static let categoryPickerWidth: CGFloat = 120
static let logPadding: CGFloat = 8
static let cornerRadius: CGFloat = 12
static let borderWidth: CGFloat = 1
static let borderOpacity: Double = 0.3
static let timeColumnWidth: CGFloat = 60
static let levelColumnWidth: CGFloat = 50
static let categoryColumnWidth: CGFloat = 60
}
struct ConsoleView: View {
@ObservedObject var viewModel: ConsoleViewModel
@State private var isNearBottom = true
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
Text("Console Logs")
.font(.headline)
Spacer()
HStack {
Spacer()
Button("Export") {
viewModel.exportLogs()
}
.buttonStyle(.bordered)
Button("Clear") {
viewModel.clearLogs()
}
.buttonStyle(.bordered)
categoryPicker
}
}
.padding(.bottom, 4)
logScrollView
}
.padding()
.background(Color(.secondarySystemGroupedBackground))
.cornerRadius(12)
.sheet(isPresented: $viewModel.showingExportShare) {
if let fileURL = viewModel.exportedFileURL {
ShareSheet(activityItems: [fileURL])
}
}
}
private var categoryPicker: some View {
Picker("Category", selection: $viewModel.selectedLogCategory) {
Text("All").tag(Optional<AilaLogCategory>.none)
ForEach(AilaLogCategory.allCases) { category in
Text(category.displayName).tag(Optional(category))
}
}
.pickerStyle(.menu)
.frame(width: UIConstants.categoryPickerWidth)
}
private var logScrollView: some View {
ScrollView {
ScrollViewReader { proxy in
LazyVStack(alignment: .leading, spacing: 4) {
ForEach(viewModel.filteredLogs) { logEntry in
logEntryRow(logEntry)
.id(logEntry.id)
}
Color.clear
.frame(height: 1)
.id("bottom")
.onAppear {
isNearBottom = true
}
.onDisappear {
isNearBottom = false
}
}
.padding(UIConstants.logPadding)
.onChange(of: viewModel.filteredLogs) { logs in
if isNearBottom, let lastLog = logs.last {
withAnimation(.easeOut(duration: 0.3)) {
proxy.scrollTo(lastLog.id, anchor: .bottom)
}
}
}
}
}
.frame(height: UIConstants.consoleHeight)
.background(Color.black)
.clipShape(RoundedRectangle(cornerRadius: UIConstants.cornerRadius))
.overlay(
RoundedRectangle(cornerRadius: UIConstants.cornerRadius)
.stroke(Color.gray.opacity(UIConstants.borderOpacity), lineWidth: UIConstants.borderWidth)
)
}
private func logEntryRow(_ logEntry: LogEntry) -> some View {
LogEntryRowView(logEntry: logEntry)
}
}
struct LogEntryRowView: View {
let logEntry: LogEntry
var body: some View {
HStack(alignment: .top, spacing: 8) {
Text(DateFormatter.consoleTimeFormatter.string(from: logEntry.timestamp))
.font(.system(size: 11, design: .monospaced))
.foregroundColor(.gray)
.frame(width: UIConstants.timeColumnWidth, alignment: .leading)
Text(logEntry.level.displayName)
.font(.system(size: 11, weight: .medium, design: .monospaced))
.foregroundColor(logEntry.level.color)
.frame(width: UIConstants.levelColumnWidth, alignment: .leading)
Text(logEntry.category?.displayName ?? "—")
.font(.system(size: 11, weight: .medium, design: .monospaced))
.foregroundColor(logEntry.category != nil ? .cyan : .gray)
.frame(width: UIConstants.categoryColumnWidth, alignment: .leading)
Text(logEntry.message)
.font(.system(size: 11, design: .monospaced))
.foregroundColor(.white)
.frame(maxWidth: .infinity, alignment: .leading)
}
.padding(.vertical, 1)
}
}
struct ShareSheet: UIViewControllerRepresentable {
let activityItems: [Any]
func makeUIViewController(context: Context) -> UIActivityViewController {
let controller = UIActivityViewController(activityItems: activityItems, applicationActivities: nil)
return controller
}
func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {
// No updates needed
}
}
extension DateFormatter {
static let consoleTimeFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateFormat = "HH:mm:ss"
return formatter
}()
static let fullTimestampFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd HH:mm:ss.SSS"
return formatter
}()
}
Diagnostics/View.swift
import SwiftUI
private enum UIConstants {
static let systemInfoMinLabelWidth: CGFloat = 225
static let cornerRadius: CGFloat = 12
static let verticalPadding: CGFloat = 2
static let bottomPadding: CGFloat = 4
static let fontSize: CGFloat = 13
static let spacing: CGFloat = 16
}
struct DiagnosticsView: View {
@StateObject private var viewModel = DiagnosticsViewModel()
@StateObject private var consoleViewModel = ConsoleViewModel()
var body: some View {
NavigationStack {
VStack(alignment: .leading, spacing: UIConstants.spacing) {
systemInfoPanel
ConsoleView(viewModel: consoleViewModel)
}
.padding()
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color(.systemGroupedBackground))
.navigationTitle("Diagnostics")
.onAppear {
viewModel.start()
consoleViewModel.start()
}
.onDisappear {
viewModel.stop()
consoleViewModel.stop()
}
}
}
private var systemInfoPanel: some View {
VStack(alignment: .leading, spacing: 5) {
Text("System Information")
.font(.headline)
.padding(.bottom, UIConstants.bottomPadding)
ForEach(viewModel.systemInfo.asKeyValuePairs, id: \.key) { key, value in
systemInfoRow(label: key, value: value)
}
}
.font(.subheadline)
.padding()
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color(.secondarySystemGroupedBackground))
.cornerRadius(UIConstants.cornerRadius)
}
private func systemInfoRow(label: String, value: String) -> some View {
HStack(alignment: .top, spacing: 8) {
Text("\(label):")
.font(.system(size: UIConstants.fontSize, weight: .semibold))
.foregroundStyle(.secondary)
.frame(minWidth: UIConstants.systemInfoMinLabelWidth, alignment: .leading)
Text(value)
.font(.system(size: UIConstants.fontSize))
.frame(maxWidth: .infinity, alignment: .leading)
}
.padding(.vertical, UIConstants.verticalPadding)
}
}
The @retroactive conformances on AilaLogCategory require Swift 6 tooling (Xcode 16). On Xcode 15, remove the @retroactive keywords and the code compiles as before.
Try changing
- Set
config.logCategoriesto a specific set, such as[.scan, .licensing], and watch the console narrow. - Add
Aila_ComsTestto check that the remote status monitoring endpoint is reachable and show the result in the table. - Persist exported logs to a support inbox instead of the share sheet.
API used
- Aila_GetHardwareDescription
- Aila_GetConnectionInfo
- Aila_GetSDKVersion
- Aila_GetFirmwareVersionNumber
- Aila_GetHardwareSerialNumber
- Aila_GetChargerWattage
- AilaConfiguration for
debugLevelandlogCategories - Notifications for
AilaHardwareChanged
That's the last quickstart. Continue on to the API Reference.
Want the code? The full Xcode project is available as a zip on the Quickstarts overview.