iOS Integration
iOS Integration Guide
Section titled “iOS Integration Guide”Learn how to integrate the Roboscope 2 API into your iOS ARKit application.
Architecture Overview
Section titled “Architecture Overview”┌─────────────────────────────────────┐│ iOS App (SwiftUI) │├─────────────────────────────────────┤│ Views ││ ├─ ARSessionView (ARKit + RK) ││ ├─ SpacesView ││ ├─ SessionsView ││ └─ MarkerListView │├─────────────────────────────────────┤│ Services ││ ├─ NetworkManager ││ ├─ SpaceService ││ ├─ WorkSessionService ││ ├─ MarkerService ││ ├─ PresenceService ││ └─ LockService │├─────────────────────────────────────┤│ Models ││ ├─ Space ││ ├─ WorkSession ││ ├─ Marker ││ └─ AnyCodable │├─────────────────────────────────────┤│ ARKit / RealityKit │└─────────────────────────────────────┘ │ ▼┌─────────────────────────────────────┐│ REST API (Rust/Axum) │└─────────────────────────────────────┘Project Structure
Section titled “Project Structure”roboscope2/├── Models/│ ├── Space.swift│ ├── WorkSession.swift│ ├── Marker.swift│ └── AnyCodable.swift├── Services/│ ├── Network/│ │ ├── APIConfiguration.swift│ │ ├── NetworkManager.swift│ │ └── APIError.swift│ ├── SpaceService.swift│ ├── WorkSessionService.swift│ ├── MarkerService.swift│ ├── PresenceService.swift│ └── LockService.swift├── Views/│ ├── SpacesView.swift│ ├── SessionsView.swift│ ├── ARSessionView.swift│ └── Components/└── ViewModels/ └── ARSessionViewModel.swift1. Add Dependencies
Section titled “1. Add Dependencies”Using Swift Package Manager:
In Xcode:
- File → Add Package Dependencies
- Search for packages:
- Alamofire:
https://github.com/Alamofire/Alamofire.git(5.8.0+)
- Alamofire:
Or in Package.swift:
dependencies: [ .package(url: "https://github.com/Alamofire/Alamofire.git", from: "5.8.0")]2. Configure Info.plist
Section titled “2. Configure Info.plist”Add required privacy descriptions:
<key>NSCameraUsageDescription</key><string>Camera access is required for AR scanning and marker placement</string>
<key>NSLocalNetworkUsageDescription</key><string>Local network access is required to communicate with the API server</string>
<key>NSBonjourServices</key><array> <string>_http._tcp</string></array>3. Configure API
Section titled “3. Configure API”Create APIConfiguration.swift:
import Foundation
struct APIConfiguration { static var shared = APIConfiguration()
enum Environment { case development case production }
var environment: Environment = .development
var baseURL: String { switch environment { case .development: return "http://192.168.1.100:8080/api/v1" // Replace with your IP case .production: return "https://api.roboscope.example.com/api/v1" } }
var timeout: TimeInterval = 30.0}Core Models
Section titled “Core Models”Space Model
Section titled “Space Model”import Foundation
struct Space: Codable, Identifiable { let id: UUID let key: String let name: String let description: String? let modelGlbUrl: String? let modelUsdcUrl: String? let previewUrl: String? let scanUrl: String? let calibrationVector: [Double] let meta: [String: AnyCodable] let createdAt: Date let updatedAt: Date
enum CodingKeys: String, CodingKey { case id, key, name, description, meta case modelGlbUrl = "model_glb_url" case modelUsdcUrl = "model_usdc_url" case previewUrl = "preview_url" case scanUrl = "scan_url" case calibrationVector = "calibration_vector" case createdAt = "created_at" case updatedAt = "updated_at" }}
struct CreateSpace: Codable { let key: String let name: String let description: String? let modelGlbUrl: String? let modelUsdcUrl: String? let previewUrl: String? let scanUrl: String? let calibrationVector: [Double]
enum CodingKeys: String, CodingKey { case key, name, description case modelGlbUrl = "model_glb_url" case modelUsdcUrl = "model_usdc_url" case previewUrl = "preview_url" case scanUrl = "scan_url" case calibrationVector = "calibration_vector" }}WorkSession Model
Section titled “WorkSession Model”import Foundation
enum WorkSessionType: String, Codable { case inspection case repair case other}
enum WorkSessionStatus: String, Codable { case draft case active case done case archived}
struct WorkSession: Codable, Identifiable { let id: UUID let spaceId: UUID let sessionType: WorkSessionType let status: WorkSessionStatus let startedAt: Date? let completedAt: Date? let version: Int64 let meta: [String: AnyCodable] let createdAt: Date let updatedAt: Date
enum CodingKeys: String, CodingKey { case id, status, version, meta case spaceId = "space_id" case sessionType = "session_type" case startedAt = "started_at" case completedAt = "completed_at" case createdAt = "created_at" case updatedAt = "updated_at" }}
struct CreateWorkSession: Codable { let spaceId: UUID let sessionType: WorkSessionType let status: WorkSessionStatus? let startedAt: Date? let completedAt: Date?
enum CodingKeys: String, CodingKey { case status case spaceId = "space_id" case sessionType = "session_type" case startedAt = "started_at" case completedAt = "completed_at" }}Marker Model
Section titled “Marker Model”import Foundation
struct Marker: Codable, Identifiable { let id: UUID let workSessionId: UUID let label: String? let p1: [Double] let p2: [Double] let p3: [Double] let p4: [Double] let color: String? let version: Int64 let meta: [String: AnyCodable] let customProps: [String: AnyCodable] let calibratedData: CalibratedData? let createdAt: Date let updatedAt: Date
enum CodingKeys: String, CodingKey { case id, label, p1, p2, p3, p4, color, version, meta case workSessionId = "work_session_id" case customProps = "custom_props" case calibratedData = "calibrated_data" case createdAt = "created_at" case updatedAt = "updated_at" }}
struct CalibratedData: Codable { let p1: [Double] let p2: [Double] let p3: [Double] let p4: [Double] let center: [Double]}
struct CreateMarker: Codable { let workSessionId: UUID let label: String? let p1: [Double] let p2: [Double] let p3: [Double] let p4: [Double] let color: String? let customProps: [String: AnyCodable]? let calibratedData: CalibratedData?
enum CodingKeys: String, CodingKey { case label, p1, p2, p3, p4, color case workSessionId = "work_session_id" case customProps = "custom_props" case calibratedData = "calibrated_data" }}AnyCodable Helper
Section titled “AnyCodable Helper”import Foundation
struct AnyCodable: Codable { let value: Any
init(_ value: Any) { self.value = value }
init(from decoder: Decoder) throws { let container = try decoder.singleValueContainer()
if let int = try? container.decode(Int.self) { value = int } else if let double = try? container.decode(Double.self) { value = double } else if let string = try? container.decode(String.self) { value = string } else if let bool = try? container.decode(Bool.self) { value = bool } else if let array = try? container.decode([AnyCodable].self) { value = array.map { $0.value } } else if let dict = try? container.decode([String: AnyCodable].self) { value = dict.mapValues { $0.value } } else { value = NSNull() } }
func encode(to encoder: Encoder) throws { var container = encoder.singleValueContainer()
switch value { case let int as Int: try container.encode(int) case let double as Double: try container.encode(double) case let string as String: try container.encode(string) case let bool as Bool: try container.encode(bool) case let array as [Any]: try container.encode(array.map { AnyCodable($0) }) case let dict as [String: Any]: try container.encode(dict.mapValues { AnyCodable($0) }) default: try container.encodeNil() } }}Network Layer
Section titled “Network Layer”APIError
Section titled “APIError”import Foundation
enum APIError: Error, LocalizedError { case invalidURL case networkError(Error) case invalidResponse case decodingError(Error) case serverError(Int, String) case notFound case conflict(String) case validationError(String)
var errorDescription: String? { switch self { case .invalidURL: return "Invalid URL" case .networkError(let error): return "Network error: \(error.localizedDescription)" case .invalidResponse: return "Invalid server response" case .decodingError(let error): return "Failed to decode response: \(error.localizedDescription)" case .serverError(let code, let message): return "Server error (\(code)): \(message)" case .notFound: return "Resource not found" case .conflict(let message): return "Conflict: \(message)" case .validationError(let message): return "Validation error: \(message)" } }}NetworkManager
Section titled “NetworkManager”import Foundationimport Alamofire
final class NetworkManager { static let shared = NetworkManager()
private let session: Session private let decoder: JSONDecoder private let encoder: JSONEncoder
private init() { let configuration = URLSessionConfiguration.default configuration.timeoutIntervalForRequest = APIConfiguration.shared.timeout self.session = Session(configuration: configuration)
// Configure JSON decoder for dates self.decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 decoder.keyDecodingStrategy = .useDefaultKeys
// Configure JSON encoder for dates self.encoder = JSONEncoder() encoder.dateEncodingStrategy = .iso8601 encoder.keyEncodingStrategy = .useDefaultKeys }
// MARK: - GET Request
func get<T: Decodable>( endpoint: String, queryItems: [URLQueryItem]? = nil ) async throws -> T { let url = try buildURL(endpoint: endpoint, queryItems: queryItems)
return try await withCheckedThrowingContinuation { continuation in session.request(url, method: .get) .validate() .responseData { response in self.handleResponse(response, continuation: continuation) } } }
// MARK: - POST Request
func post<T: Decodable, U: Encodable>( endpoint: String, body: U ) async throws -> T { let url = try buildURL(endpoint: endpoint) let data = try encoder.encode(body)
return try await withCheckedThrowingContinuation { continuation in session.request( url, method: .post, parameters: [:], encoder: JSONParameterEncoder.default, headers: ["Content-Type": "application/json"] ) .uploadData(data) .validate() .responseData { response in self.handleResponse(response, continuation: continuation) } } }
// MARK: - PATCH Request
func patch<T: Decodable, U: Encodable>( endpoint: String, body: U ) async throws -> T { let url = try buildURL(endpoint: endpoint) let data = try encoder.encode(body)
return try await withCheckedThrowingContinuation { continuation in session.request( url, method: .patch, parameters: [:], encoder: JSONParameterEncoder.default, headers: ["Content-Type": "application/json"] ) .uploadData(data) .validate() .responseData { response in self.handleResponse(response, continuation: continuation) } } }
// MARK: - DELETE Request
func delete(endpoint: String) async throws { let url = try buildURL(endpoint: endpoint)
return try await withCheckedThrowingContinuation { continuation in session.request(url, method: .delete) .validate() .response { response in if let error = response.error { continuation.resume(throwing: APIError.networkError(error)) } else { continuation.resume() } } } }
// MARK: - Helper Methods
private func buildURL( endpoint: String, queryItems: [URLQueryItem]? = nil ) throws -> URL { let baseURL = APIConfiguration.shared.baseURL var urlString = baseURL + endpoint
if let queryItems = queryItems, !queryItems.isEmpty { var components = URLComponents(string: urlString) components?.queryItems = queryItems urlString = components?.url?.absoluteString ?? urlString }
guard let url = URL(string: urlString) else { throw APIError.invalidURL }
return url }
private func handleResponse<T: Decodable>( _ response: AFDataResponse<Data>, continuation: CheckedContinuation<T, Error> ) { switch response.result { case .success(let data): do { let decoded = try decoder.decode(T.self, from: data) continuation.resume(returning: decoded) } catch { continuation.resume(throwing: APIError.decodingError(error)) }
case .failure(let error): if let statusCode = response.response?.statusCode { switch statusCode { case 404: continuation.resume(throwing: APIError.notFound) case 409: let message = String(data: response.data ?? Data(), encoding: .utf8) ?? "" continuation.resume(throwing: APIError.conflict(message)) case 400: let message = String(data: response.data ?? Data(), encoding: .utf8) ?? "" continuation.resume(throwing: APIError.validationError(message)) default: let message = String(data: response.data ?? Data(), encoding: .utf8) ?? error.localizedDescription continuation.resume(throwing: APIError.serverError(statusCode, message)) } } else { continuation.resume(throwing: APIError.networkError(error)) } } }}Service Layer
Section titled “Service Layer”SpaceService
Section titled “SpaceService”import Foundation
final class SpaceService: ObservableObject { static let shared = SpaceService() private let networkManager = NetworkManager.shared
@Published var spaces: [Space] = [] @Published var isLoading = false
private init() {}
func listSpaces() async throws -> [Space] { isLoading = true defer { isLoading = false }
let spaces: [Space] = try await networkManager.get(endpoint: "/spaces") await MainActor.run { self.spaces = spaces } return spaces }
func getSpace(id: UUID) async throws -> Space { try await networkManager.get(endpoint: "/spaces/\(id.uuidString)") }
func createSpace(_ space: CreateSpace) async throws -> Space { let created: Space = try await networkManager.post( endpoint: "/spaces", body: space ) await MainActor.run { spaces.append(created) } return created }
func deleteSpace(id: UUID) async throws { try await networkManager.delete(endpoint: "/spaces/\(id.uuidString)") await MainActor.run { spaces.removeAll { $0.id == id } } }}MarkerService
Section titled “MarkerService”import Foundation
final class MarkerService: ObservableObject { static let shared = MarkerService() private let networkManager = NetworkManager.shared
@Published var markers: [Marker] = [] @Published var isLoading = false
private init() {}
func listMarkers(workSessionId: UUID? = nil) async throws -> [Marker] { isLoading = true defer { isLoading = false }
var queryItems: [URLQueryItem] = [] if let workSessionId = workSessionId { queryItems.append(URLQueryItem( name: "work_session_id", value: workSessionId.uuidString )) }
let markers: [Marker] = try await networkManager.get( endpoint: "/markers", queryItems: queryItems.isEmpty ? nil : queryItems )
await MainActor.run { self.markers = markers } return markers }
func createMarker(_ marker: CreateMarker) async throws -> Marker { let created: Marker = try await networkManager.post( endpoint: "/markers", body: marker ) await MainActor.run { markers.append(created) } return created }
func bulkCreateMarkers(_ markers: [CreateMarker]) async throws -> [Marker] { struct BulkRequest: Codable { let markers: [CreateMarker] }
let created: [Marker] = try await networkManager.post( endpoint: "/markers/bulk", body: BulkRequest(markers: markers) )
await MainActor.run { self.markers.append(contentsOf: created) } return created }}ARKit Integration
Section titled “ARKit Integration”Converting ARKit Coordinates to Marker Points
Section titled “Converting ARKit Coordinates to Marker Points”import ARKitimport RealityKit
extension SIMD3<Float> { func toDoubleArray() -> [Double] { [Double(x), Double(y), Double(z)] }}
func createMarkerFromARPoints( workSessionId: UUID, corners: [SIMD3<Float>], label: String?, color: String?) -> CreateMarker { guard corners.count == 4 else { fatalError("Marker requires exactly 4 corner points") }
return CreateMarker( workSessionId: workSessionId, label: label, p1: corners[0].toDoubleArray(), p2: corners[1].toDoubleArray(), p3: corners[2].toDoubleArray(), p4: corners[3].toDoubleArray(), color: color, customProps: nil, calibratedData: nil )}Placing Markers in AR
Section titled “Placing Markers in AR”import SwiftUIimport ARKitimport RealityKit
struct ARMarkerPlacementView: View { @StateObject private var arViewModel = ARViewModel() let workSessionId: UUID
var body: some View { ZStack { ARViewContainer(arViewModel: arViewModel) .edgesIgnoringSafeArea(.all)
VStack { Spacer()
if arViewModel.selectedCorners.count < 4 { Text("Tap to place corner \(arViewModel.selectedCorners.count + 1)/4") .padding() .background(Color.black.opacity(0.7)) .foregroundColor(.white) .cornerRadius(8) } else { Button("Create Marker") { Task { await createMarker() } } .padding() .background(Color.green) .foregroundColor(.white) .cornerRadius(8) } } .padding() } }
private func createMarker() async { let marker = createMarkerFromARPoints( workSessionId: workSessionId, corners: arViewModel.selectedCorners, label: "New Marker", color: "#FF0000" )
do { _ = try await MarkerService.shared.createMarker(marker) arViewModel.selectedCorners.removeAll() } catch { print("Failed to create marker: \(error)") } }}Best Practices
Section titled “Best Practices”1. Error Handling
Section titled “1. Error Handling”func loadData() async { do { let spaces = try await SpaceService.shared.listSpaces() // Use spaces } catch let error as APIError { switch error { case .notFound: showAlert("Space not found") case .conflict(let message): showAlert("Conflict: \(message)") case .validationError(let message): showAlert("Validation error: \(message)") default: showAlert(error.localizedDescription) } } catch { showAlert("Unknown error: \(error.localizedDescription)") }}2. Optimistic Concurrency
Section titled “2. Optimistic Concurrency”func updateSession(_ session: WorkSession) async throws { var updateRequest = UpdateWorkSession( status: .done, completedAt: Date(), version: session.version // Include current version )
do { let updated = try await WorkSessionService.shared.updateWorkSession( id: session.id, update: updateRequest ) // Success } catch APIError.conflict { // Version mismatch - refetch and retry let fresh = try await WorkSessionService.shared.getWorkSession(id: session.id) updateRequest.version = fresh.version // Retry update }}3. Bulk Operations
Section titled “3. Bulk Operations”// Instead of creating markers one by onefor corner in markerCorners { try await MarkerService.shared.createMarker(corner) // Slow!}
// Use bulk createtry await MarkerService.shared.bulkCreateMarkers(markerCorners) // Fast!4. Presence & Locking
Section titled “4. Presence & Locking”class SessionViewModel: ObservableObject { private var heartbeatTask: Task<Void, Never>?
func joinSession(id: UUID) async { // Join presence try? await PresenceService.shared.joinSession(id)
// Start heartbeat heartbeatTask = Task { while !Task.isCancelled { try? await Task.sleep(nanoseconds: 10_000_000_000) // 10s try? await PresenceService.shared.sendHeartbeat(sessionId: id) } } }
func leaveSession(id: UUID) async { // Cancel heartbeat heartbeatTask?.cancel()
// Leave presence try? await PresenceService.shared.leaveSession(id) }
deinit { heartbeatTask?.cancel() }}