Skip to content

iOS (Swift)

A complete drop-in Swift client for Volta. No dependencies — just Foundation and URLSession.

import Foundation
enum VoltaError: LocalizedError {
case invalidURL
case httpError(Int, String)
case noToken
var errorDescription: String? {
switch self {
case .invalidURL: return "Invalid API URL"
case .httpError(let code, let msg): return "HTTP \(code): \(msg)"
case .noToken: return "Not authenticated"
}
}
}
struct IndexOption: Identifiable, Decodable {
let index: Int
let label: String?
let image: String?
var id: Int { index }
}
struct LayoutControl: Identifiable, Decodable {
let lid: Int
let address: String
let type: String
let props: [String: AnyCodable]
let options: [IndexOption]?
var id: Int { lid }
var label: String {
props["label"]?.stringValue ?? "\(type) \(lid)"
}
}
struct LayoutInfo: Decodable {
let layoutId: String
let activePageId: String
let controls: [LayoutControl]
let destinationCount: Int
}
// Minimal type-erased Decodable for mixed JSON values
struct AnyCodable: Decodable {
let value: Any
var stringValue: String? { value as? String }
var boolValue: Bool? { value as? Bool }
var doubleValue: Double? { value as? Double }
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
if let s = try? container.decode(String.self) { value = s }
else if let b = try? container.decode(Bool.self) { value = b }
else if let d = try? container.decode(Double.self) { value = d }
else if let i = try? container.decode(Int.self) { value = i }
else { value = "" }
}
}
@MainActor
class VoltaClient: ObservableObject {
@Published var isConnected = false
@Published var layoutInfo: LayoutInfo?
@Published var lastError: String?
private var token: String?
private let apiURL: String
private let apiKey: String
private let layoutId: String
init(apiURL: String, apiKey: String, layoutId: String) {
self.apiURL = apiURL
self.apiKey = apiKey
self.layoutId = layoutId
}
// MARK: - Connect
func connect() async {
lastError = nil
do {
layoutInfo = try await fetchLayoutInfo()
token = try await fetchToken()
isConnected = true
} catch {
lastError = error.localizedDescription
isConnected = false
}
}
// MARK: - Actions
func sendButton(lid: Int) async {
await sendAction(lid: lid, type: "Button", value: nil)
}
func sendSlider(lid: Int, value: Float) async {
await sendAction(lid: lid, type: "Slider", value: .float(value))
}
func sendToggle(lid: Int, value: Bool) async {
await sendAction(lid: lid, type: "Toggle", value: .bool(value))
}
func sendIndex(lid: Int, value: Int) async {
await sendAction(lid: lid, type: "Index", value: .int(value))
}
// MARK: - Internal
private func fetchLayoutInfo() async throws -> LayoutInfo {
let url = try buildURL("/layout/\(layoutId)")
var req = URLRequest(url: url)
req.setValue(apiKey, forHTTPHeaderField: "x-api-key")
let (data, resp) = try await URLSession.shared.data(for: req)
guard (resp as? HTTPURLResponse)?.statusCode == 200 else {
throw VoltaError.httpError(
(resp as? HTTPURLResponse)?.statusCode ?? 0,
String(data: data, encoding: .utf8) ?? ""
)
}
return try JSONDecoder().decode(LayoutInfo.self, from: data)
}
private func fetchToken() async throws -> String {
let url = try buildURL("/token")
var req = URLRequest(url: url)
req.httpMethod = "POST"
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
req.setValue(apiKey, forHTTPHeaderField: "x-api-key")
req.httpBody = try JSONEncoder().encode(["layoutId": layoutId])
let (data, resp) = try await URLSession.shared.data(for: req)
guard (resp as? HTTPURLResponse)?.statusCode == 200 else {
throw VoltaError.httpError(
(resp as? HTTPURLResponse)?.statusCode ?? 0,
String(data: data, encoding: .utf8) ?? ""
)
}
return try JSONDecoder().decode(TokenResponse.self, from: data).token
}
private func sendAction(lid: Int, type: String, value: ActionValue?) async {
guard let token else { lastError = "Not connected"; return }
do {
let url = try buildURL("/action")
var req = URLRequest(url: url)
req.httpMethod = "POST"
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
req.httpBody = try JSONEncoder().encode(
ActionRequest(layoutId: layoutId, lid: lid, type: type, value: value)
)
let (_, resp) = try await URLSession.shared.data(for: req)
guard (resp as? HTTPURLResponse)?.statusCode == 200 else { return }
} catch {
lastError = error.localizedDescription
}
}
private func buildURL(_ path: String) throws -> URL {
let base = apiURL.hasSuffix("/") ? String(apiURL.dropLast()) : apiURL
guard let url = URL(string: base + path) else { throw VoltaError.invalidURL }
return url
}
}
private struct TokenResponse: Decodable { let token: String }
private struct ActionRequest: Encodable {
let layoutId: String; let lid: Int; let type: String; let value: ActionValue?
}
private enum ActionValue: Encodable {
case float(Float), bool(Bool), int(Int)
func encode(to encoder: Encoder) throws {
var c = encoder.singleValueContainer()
switch self {
case .float(let v): try c.encode(v)
case .bool(let v): try c.encode(v)
case .int(let v): try c.encode(v)
}
}
}
let client = VoltaClient(
apiURL: "https://your-api-url.execute-api.eu-west-2.amazonaws.com",
apiKey: "your-api-key",
layoutId: "your-layout-id"
)
// Connect and discover controls
await client.connect()
// Send a button press
await client.sendButton(lid: 1)
// Send a slider value
await client.sendSlider(lid: 2, value: 0.75)
// Cast a vote (Index control)
await client.sendIndex(lid: 3, value: 1)

After connecting, client.layoutInfo?.controls contains all controls on the active page. You can dynamically render UI based on control type:

ForEach(client.layoutInfo?.controls ?? []) { control in
switch control.type {
case "Button":
Button(control.label) {
Task { await client.sendButton(lid: control.lid) }
}
case "Slider":
Slider(value: $sliderValue, in: 0...1) { _ in
Task { await client.sendSlider(lid: control.lid, value: sliderValue) }
}
case "Index":
ForEach(control.options ?? []) { option in
Button(option.label ?? "Option \(option.index + 1)") {
Task { await client.sendIndex(lid: control.lid, value: option.index) }
}
}
default:
Text("\(control.label) — unsupported type")
}
}

A complete SwiftUI demo app is available at demos/ios/VoltaDemo/ in the repository. It dynamically discovers controls and renders appropriate UI for each type, including image grids for sticker-style Index controls.