diff --git a/Package.swift b/Package.swift index 1b69791..df29d81 100644 --- a/Package.swift +++ b/Package.swift @@ -18,6 +18,12 @@ let package = Package( name: "InkletPresentationKit", path: "Sources/InkletPresentationKit" ), + // Bluetooth setup of a hardware display (Wi-Fi + claim). The same + // sources are compiled into the iPhone app. + .target( + name: "InkletSetupKit", + path: "Sources/InkletSetupKit" + ), .target( name: "InkletPresentationWidget", dependencies: ["InkletPresentationKit"], @@ -29,6 +35,7 @@ let package = Package( dependencies: [ "InkletPresentationKit", "InkletPresentationWidget", + "InkletSetupKit", .product(name: "Sparkle", package: "Sparkle"), ], path: "Sources/InkletMac", @@ -44,6 +51,11 @@ let package = Package( dependencies: ["InkletPresentationKit"], path: "Tests/InkletPresentationKitTests" ), + .testTarget( + name: "InkletSetupKitTests", + dependencies: ["InkletSetupKit"], + path: "Tests/InkletSetupKitTests" + ), .testTarget( name: "InkletMacTests", dependencies: ["InkletMac", "InkletPresentationKit"], diff --git a/Scripts/build-app.sh b/Scripts/build-app.sh index 20ebbbe..9c54da8 100755 --- a/Scripts/build-app.sh +++ b/Scripts/build-app.sh @@ -121,6 +121,8 @@ cat > "$APP/Contents/Info.plist" <CFBundleURLNamecom.iminklet.mac.widgets CFBundleURLSchemesinklet-mac + NSBluetoothAlwaysUsageDescription + inklet Portal uses Bluetooth to set up a new display: it gives the display your Wi-Fi network and adds it to your account. NSLocationUsageDescription inklet Portal uses your approximate location to show the weather on Home. NSLocationWhenInUseUsageDescription diff --git a/Sources/InkletMac/Models/AppModel.swift b/Sources/InkletMac/Models/AppModel.swift index a7a5fbb..0751db3 100644 --- a/Sources/InkletMac/Models/AppModel.swift +++ b/Sources/InkletMac/Models/AppModel.swift @@ -318,6 +318,29 @@ final class AppModel { return devices.first { $0.id == device.id } ?? device } + /// Claims a hardware display with the bind URI it handed over during + /// Bluetooth setup, and puts it at the top of the list. + func bindDisplay(uri: String) async throws -> Device { + guard let credential = DisplayBindCredential(uri: uri) else { + throw DisplayBindError.invalidCredential + } + let dto: DeviceDTO + do { + dto = try await InkletAPI.shared.bindDisplay(credential) + } catch { + if APIError.endsSession(error) { await session?.invalidate() } + throw error + } + let device = Device(dto: dto) + if let index = devices.firstIndex(where: { $0.id == device.id }) { + devices[index] = device + } else { + devices.insert(device, at: 0) + } + loadPreview(for: device) + return device + } + func unbind(_ device: Device) async { do { try await InkletAPI.shared.unbind(deviceID: device.id) diff --git a/Sources/InkletMac/Services/DisplayBind.swift b/Sources/InkletMac/Services/DisplayBind.swift new file mode 100644 index 0000000..9949041 --- /dev/null +++ b/Sources/InkletMac/Services/DisplayBind.swift @@ -0,0 +1,64 @@ +import Foundation + +/// A hardware display's claim credential: `inklet://bind?v=2&kid=…&hw=…&cv=…&p=…`, +/// the URI its NFC tag carries (backend: internal/nfc). A Mac can't read the +/// tag, so the display hands the same URI over during Bluetooth setup, inside +/// the session secured by the code on its screen. +/// +/// Only the shape is checked here; the backend verifies the proof and answers +/// every unusable credential with the same INVALID_NFC_CREDENTIAL. +struct DisplayBindCredential: Equatable, Sendable { + let keyID: String + let hwID: String + let credentialVersion: Int + let proof: String + + init?(uri: String) { + guard let components = URLComponents(string: uri), + components.scheme == "inklet", components.host == "bind" else { return nil } + func value(_ name: String) -> String? { + components.queryItems?.first { $0.name == name }?.value + } + guard value("v") == "2", + let kid = value("kid"), !kid.isEmpty, + let hw = value("hw"), hw.count == 32, + let cv = value("cv").flatMap(Int.init), cv >= 1, + let proof = value("p"), !proof.isEmpty + else { return nil } + keyID = kid + hwID = hw + credentialVersion = cv + self.proof = proof + } + + /// Body of `POST /api/devices/bind/nfc`. + var requestBody: [String: Any] { + ["version": 2, "keyId": keyID, "hwId": hwID, "credentialVersion": credentialVersion, "proof": proof] + } +} + +/// One case per backend code; the wording is ours, the codes are the contract. +enum DisplayBindError: LocalizedError, Equatable, Sendable { + case invalidCredential + case alreadyBound + case notFound + case other(String) + + var errorDescription: String? { + switch self { + case .invalidCredential: "This display's pairing credential wasn't accepted. Contact inklet support." + case .alreadyBound: "This display already belongs to another inklet account. Its owner has to remove it first." + case .notFound: "inklet doesn't know this display yet. Contact inklet support." + case .other(let message): message + } + } + + static func from(status: Int, code: String?, message: String?) -> DisplayBindError { + switch code { + case "INVALID_NFC_CREDENTIAL": .invalidCredential + case "DEVICE_ALREADY_BOUND": .alreadyBound + case "DEVICE_NOT_FOUND": .notFound + default: .other(message ?? "Couldn't add the display (\(status)).") + } + } +} diff --git a/Sources/InkletMac/Services/InkletAPI.swift b/Sources/InkletMac/Services/InkletAPI.swift index 11917a2..4dd12ba 100644 --- a/Sources/InkletMac/Services/InkletAPI.swift +++ b/Sources/InkletMac/Services/InkletAPI.swift @@ -263,6 +263,25 @@ actor InkletAPI { } } + /// `POST /api/devices/bind/nfc`: claim a hardware display with its bind + /// credential (handed over by the display during Bluetooth setup). + func bindDisplay(_ credential: DisplayBindCredential) async throws -> DeviceDTO { + let (data, status) = try await authedResponse("api/devices/bind/nfc", method: "POST", + jsonObject: credential.requestBody) + switch status { + case 200...299: + guard let response = try? JSONDecoder().decode(DisplayBindResponseDTO.self, from: data) else { + throw APIError.decoding + } + return response.device + case 401: + throw APIError.sessionExpired + default: + let envelope = serverError(data) + throw DisplayBindError.from(status: status, code: envelope.code, message: envelope.message) + } + } + /// `POST /api/app/v1/displays/{id}/advance`: show the next queued item. /// Returns false on an empty queue, which is not an error. No AI, no quota. func advanceDisplay(displayID: String) async throws -> Bool { diff --git a/Sources/InkletMac/Services/Wire.swift b/Sources/InkletMac/Services/Wire.swift index f969abf..32e533c 100644 --- a/Sources/InkletMac/Services/Wire.swift +++ b/Sources/InkletMac/Services/Wire.swift @@ -66,6 +66,13 @@ struct Quote0BindResponseDTO: Codable, Sendable { var status: String? } +/// `POST /api/devices/bind/nfc`: status is "bound" or "already_bound" (the +/// same account claiming its own display again). +struct DisplayBindResponseDTO: Codable, Sendable { + var device: DeviceDTO + var status: String? +} + // MARK: - Time enum InkletTime { diff --git a/Sources/InkletMac/Views/PairDisplayView.swift b/Sources/InkletMac/Views/PairDisplayView.swift index c2f92be..1ff5b4c 100644 --- a/Sources/InkletMac/Views/PairDisplayView.swift +++ b/Sources/InkletMac/Views/PairDisplayView.swift @@ -1,64 +1,351 @@ +import InkletSetupKit import SwiftUI -/// Pairing is NFC-only (docs/api/nfc-v2-protocol.md): the six-character code -/// route now answers 410, and claiming a display means tapping its tag with a -/// phone. A Mac has no NFC radio, so this page explains the flow and gets out of -/// the way — anything else here would be a button that can only fail. +/// Setting up an inklet display from the Mac over Bluetooth: it gives the +/// display its Wi-Fi and claims it for this account in one pass. A display +/// only opens its 3-minute setup window when it can't get online and the user +/// holds its touch strip; it then shows a QR code with an 8-character code +/// under it. A Mac has no camera flow here, so the user picks the display from +/// the list and types the code instead. struct PairDisplayView: View { @Environment(AppModel.self) private var model @Binding var selection: SidebarItem? + @StateObject private var flow = InkletSetupFlow() var body: some View { - VStack(spacing: 24) { - Image(systemName: "wave.3.right.circle") - .font(.system(size: 46, weight: .light)) - .foregroundStyle(Ink.muted) - - VStack(spacing: 8) { - Text("Pair a new display") - .font(.brand(30)) - .foregroundStyle(Ink.text) - Text("inklet displays are claimed by tapping the NFC tag on the back with your iPhone. Your Mac can't read NFC, so this one step happens on the phone.") - .font(.system(size: 14)) - .foregroundStyle(Ink.secondary) - .multilineTextAlignment(.center) - .frame(maxWidth: 420) + ScrollView { + VStack(alignment: .leading, spacing: 24) { + VStack(alignment: .leading, spacing: 8) { + Text("Set up a display").font(.brand(34)).foregroundStyle(Ink.text) + Text("Your Mac connects to the display over Bluetooth, gives it your Wi-Fi, and adds it to your account.") + .font(.system(size: 14)).foregroundStyle(Ink.secondary) + .fixedSize(horizontal: false, vertical: true) + } + stepView + if let error = flow.errorMessage { + Text(error).font(.system(size: 13)).foregroundStyle(Ink.danger) + .fixedSize(horizontal: false, vertical: true) + } } + .frame(maxWidth: 640, alignment: .leading).padding(32) + .frame(maxWidth: .infinity, alignment: .topLeading) + } + .scrollIndicators(.never) + .foregroundStyle(Ink.text) + .background(Ink.bg) + .navigationTitle("Pair Display") + .onAppear { + flow.bindHandler = { uri in try await model.bindDisplay(uri: uri).id } + flow.start() + } + .onDisappear { flow.stop() } + // Once setup is over, a display that joins the list — claimed here, or + // from the iPhone's NFC tap and then Refresh — is the one to open. + // Refresh keeps the server's order, so look for the new id rather + // than trusting a position. + .onChange(of: model.devices.map(\.id)) { previous, current in + guard case .finished = flow.step, + let added = current.first(where: { !previous.contains($0) }) else { return } + selection = .device(added) + } + } + + @ViewBuilder private var stepView: some View { + switch flow.step { + case .pickDisplay: + NearbyDisplaysCard(central: flow.central) { flow.choose($0) } + case .enterCode(let display): + CodeEntryCard(display: display, + onSubmit: { code in Task { await flow.submitCode(code) } }, + onBack: { flow.restart() }) + case .connecting(let display): + ProgressCard(title: "Connecting to \(display.name)…", + detail: "Your Mac may ask to pair with the display. Allow it.") + case .scanningWiFi: + ProgressCard(title: "Looking for Wi-Fi…", detail: "The display is listing the networks it can see.") + case .pickNetwork: + NetworkPickerCard(networks: flow.networks, + onJoin: { ssid, password in Task { await flow.join(ssid: ssid, password: password) } }, + onRescan: { Task { await flow.rescanWiFi() } }) + case .joining(let ssid): + ProgressCard(title: "Joining \(ssid)…", detail: "This can take up to half a minute.") + case .binding: + ProgressCard(title: "Adding the display to your account…", detail: nil) + case .finished(let outcome): + FinishedCard(outcome: outcome, + onOpen: { id in selection = .device(id) }, + onRefresh: { Task { await model.refresh() } }, + onAnother: { flow.restart() }) + } + } +} +// MARK: - Steps + +private struct NearbyDisplaysCard: View { + @ObservedObject var central: InkletSetupCentral + let onChoose: (InkletNearbyDisplay) -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 18) { InkCard(padding: 18) { VStack(alignment: .leading, spacing: 14) { - step(1, "Open the inklet app on your iPhone.") - step(2, "Hold the top of the phone against the tag on the back of the display.") - step(3, "Confirm the pairing, then hit Refresh below — the display shows up in the sidebar.") + SectionLabel("On the display") + PairStep(1, "It shows WIFI OFFLINE or SET UP WIFI when it can't get online.") + PairStep(2, "Hold the touch strip for 3 seconds. A QR code and a code appear.") + PairStep(3, "Choose it below. Bluetooth stays on for 3 minutes.") } } - .frame(width: 420) - - Button { - Task { await model.refresh() } - } label: { - Text(model.isRefreshing ? "Refreshing…" : "Refresh") - .font(.system(size: 14, weight: .medium)) - .foregroundStyle(Ink.bg) - .padding(.horizontal, 18) - .padding(.vertical, 10) - .background(Ink.text, in: .rect(cornerRadius: Ink.controlCorner)) + InkCard(padding: 18) { + VStack(alignment: .leading, spacing: 12) { + HStack { + SectionLabel("Nearby displays") + Spacer() + if central.unavailableReason == nil { ProgressView().controlSize(.small) } + } + if let reason = central.unavailableReason { + Text(reason.errorDescription ?? "Bluetooth is unavailable.") + .font(.system(size: 13)).foregroundStyle(Ink.danger) + } else if central.displays.isEmpty { + Text("Searching… Displays appear here while their setup screen is showing.") + .font(.system(size: 13)).foregroundStyle(Ink.secondary) + } else { + ForEach(central.displays) { display in + Button { onChoose(display) } label: { + HStack(spacing: 12) { + Image(systemName: "rectangle.inset.filled").font(.system(size: 20, weight: .light)) + Text(display.name).font(.system(size: 14, weight: .medium, design: .monospaced)) + Spacer() + SignalBars(bars: display.rssi > -60 ? 3 : display.rssi > -75 ? 2 : 1) + Image(systemName: "chevron.right").font(.system(size: 12)).foregroundStyle(Ink.muted) + } + .padding(12) + .background(Ink.input, in: .rect(cornerRadius: Ink.controlCorner)) + .contentShape(.rect) + } + .buttonStyle(.plain) + } + } + } } - .buttonStyle(.plain) - .disabled(model.isRefreshing) + // A display that is already online never opens a setup window, so + // this page can't reach it; its NFC tag still can. + Text("Already online? Tap the NFC tag on its back with the inklet app on your iPhone, and it shows up in the sidebar.") + .font(.system(size: 12)).foregroundStyle(Ink.muted) + .fixedSize(horizontal: false, vertical: true) } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(Ink.bg) - .navigationTitle("Pair Display") - // Landing on a freshly-paired display is the whole point of the button - // above, so jump to it as soon as one shows up. - .onChange(of: model.devices.count) { previous, current in - guard current > previous, let newest = model.devices.last else { return } - selection = .device(newest.id) + } +} + +private struct CodeEntryCard: View { + let display: InkletNearbyDisplay + let onSubmit: (String) -> Void + let onBack: () -> Void + @State private var code = "" + + var body: some View { + VStack(alignment: .leading, spacing: 18) { + Button(action: onBack) { Label("Nearby displays", systemImage: "chevron.left") } + .buttonStyle(.plain).font(.system(size: 13)).foregroundStyle(Ink.secondary) + InkCard(padding: 20) { + VStack(alignment: .leading, spacing: 12) { + Text("Code shown on \(display.name)") + .font(.system(size: 13, weight: .medium)).foregroundStyle(Ink.secondary) + TextField("ABCD2345", text: $code) + .textFieldStyle(.plain).font(.system(size: 22, weight: .medium, design: .monospaced)) + .padding(12).background(Ink.input, in: .rect(cornerRadius: Ink.controlCorner)) + .autocorrectionDisabled() + .onSubmit(submit) + .onChange(of: code) { _, value in + let cleaned = String(value.uppercased().filter { $0.isLetter || $0.isNumber }.prefix(8)) + if cleaned != value { code = cleaned } + } + .accessibilityLabel("Setup code") + Text("It's printed under the QR code, next to the display's name.") + .font(.system(size: 12)).foregroundStyle(Ink.muted) + } + } + PrimaryButton(title: "Connect", enabled: code.count == 8, action: submit) + } + } + + private func submit() { + guard code.count == 8 else { return } + onSubmit(code) + } +} + +private struct NetworkPickerCard: View { + let networks: [InkletWiFiNetwork] + let onJoin: (String, String) -> Void + let onRescan: () -> Void + @State private var selected: InkletWiFiNetwork? + @State private var otherSSID = "" + @State private var choosingOther = false + @State private var password = "" + + private var ssid: String { choosingOther ? otherSSID.trimmingCharacters(in: .whitespaces) : (selected?.ssid ?? "") } + private var needsPassword: Bool { choosingOther || (selected?.secured ?? false) } + // WPA needs 8+ characters; a typed-in network may be open, so allow empty there. + private var canJoin: Bool { !ssid.isEmpty && (!needsPassword || password.count >= 8 || (choosingOther && password.isEmpty)) } + + var body: some View { + VStack(alignment: .leading, spacing: 18) { + InkCard(padding: 18) { + VStack(alignment: .leading, spacing: 10) { + HStack { + SectionLabel("Wi-Fi the display can see") + Spacer() + Button("Scan again", action: onRescan).buttonStyle(.plain) + .font(.system(size: 12)).foregroundStyle(Ink.secondary) + } + ForEach(networks) { network in + networkRow(network) + } + Button { + choosingOther = true + selected = nil + } label: { + rowLabel(title: "Other network…", selected: choosingOther) { EmptyView() } + } + .buttonStyle(.plain) + } + } + if choosingOther || selected != nil { + InkCard(padding: 20) { + VStack(alignment: .leading, spacing: 12) { + if choosingOther { + Text("Network name").font(.system(size: 13, weight: .medium)).foregroundStyle(Ink.secondary) + TextField("Network", text: $otherSSID) + .textFieldStyle(.plain).font(.system(size: 15)) + .padding(12).background(Ink.input, in: .rect(cornerRadius: Ink.controlCorner)) + .autocorrectionDisabled() + } + if needsPassword { + Text("Password").font(.system(size: 13, weight: .medium)).foregroundStyle(Ink.secondary) + SecureField("Wi-Fi password", text: $password) + .textFieldStyle(.plain).font(.system(size: 15)) + .padding(12).background(Ink.input, in: .rect(cornerRadius: Ink.controlCorner)) + .onSubmit { if canJoin { onJoin(ssid, password) } } + } + Text("Sent to the display over the encrypted setup session; your Mac doesn't keep it.") + .font(.system(size: 12)).foregroundStyle(Ink.muted) + } + } + PrimaryButton(title: "Join \(ssid.isEmpty ? "network" : ssid)", enabled: canJoin) { + onJoin(ssid, password) + } + } + } + } + + private func networkRow(_ network: InkletWiFiNetwork) -> some View { + Button { + selected = network + choosingOther = false + password = "" + } label: { + rowLabel(title: network.ssid, selected: selected == network) { + HStack(spacing: 8) { + if network.secured { Image(systemName: "lock.fill").font(.system(size: 11)).foregroundStyle(Ink.muted) } + SignalBars(bars: network.bars) + } + } + } + .buttonStyle(.plain) + } + + private func rowLabel(title: String, selected: Bool, + @ViewBuilder trailing: () -> Trailing) -> some View { + HStack(spacing: 10) { + Image(systemName: selected ? "checkmark.circle.fill" : "circle") + .foregroundStyle(selected ? Ink.text : Ink.border) + Text(title).font(.system(size: 14)).lineLimit(1) + Spacer() + trailing() + } + .padding(.vertical, 8).padding(.horizontal, 10) + .background(selected ? Ink.input : .clear, in: .rect(cornerRadius: Ink.controlCorner)) + .contentShape(.rect) + } +} + +private struct ProgressCard: View { + let title: String + let detail: String? + + var body: some View { + InkCard(padding: 22) { + HStack(spacing: 14) { + ProgressView().controlSize(.small) + VStack(alignment: .leading, spacing: 4) { + Text(title).font(.system(size: 15, weight: .medium)) + if let detail { Text(detail).font(.system(size: 13)).foregroundStyle(Ink.secondary) } + } + Spacer(minLength: 0) + } + } + } +} + +private struct FinishedCard: View { + let outcome: InkletSetupFlow.Outcome + let onOpen: (String) -> Void + let onRefresh: () -> Void + let onAnother: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 18) { + InkCard(padding: 22) { + VStack(alignment: .leading, spacing: 8) { + Label(title, systemImage: isSuccess ? "checkmark.circle.fill" : "wifi") + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(isSuccess ? Ink.online : Ink.text) + Text(detail).font(.system(size: 13)).foregroundStyle(Ink.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + HStack(spacing: 14) { + if case .bound(let id?) = outcome { + PrimaryButton(title: "Open display", enabled: true) { onOpen(id) } + } else if !isSuccess { + PrimaryButton(title: "Refresh", enabled: true, action: onRefresh) + } + Button("Set up another display", action: onAnother).buttonStyle(.plain) + .font(.system(size: 13)).foregroundStyle(Ink.secondary) + } + } + } + + private var isSuccess: Bool { + if case .bound = outcome { return true } + return false + } + + private var title: String { + isSuccess ? "Your display is set up" : "The display is on Wi-Fi" + } + + private var detail: String { + switch outcome { + case .bound: + "It's restarting on your Wi-Fi and will show your content in a moment." + case .wifiOnly: + "It didn't hand over a pairing credential, so it isn't in your account yet. Tap the NFC tag on its back with the inklet app on your iPhone, then hit Refresh." + case .bindFailed(let message): + "But it couldn't be added to your account. \(message)" } } +} + +// MARK: - Bits + +private struct PairStep: View { + let number: Int + let text: String + init(_ number: Int, _ text: String) { self.number = number; self.text = text } - private func step(_ number: Int, _ text: String) -> some View { + var body: some View { HStack(alignment: .top, spacing: 12) { Text("\(number)") .font(.system(size: 12, weight: .medium)) @@ -73,3 +360,39 @@ struct PairDisplayView: View { } } } + +private struct SignalBars: View { + let bars: Int + var body: some View { + HStack(alignment: .bottom, spacing: 2) { + ForEach(1...3, id: \.self) { level in + RoundedRectangle(cornerRadius: 1) + .fill(level <= bars ? Ink.text : Ink.border) + .frame(width: 3, height: CGFloat(4 + level * 3)) + } + } + .accessibilityLabel("Signal \(bars) of 3") + } +} + +private struct PrimaryButton: View { + let title: String + let enabled: Bool + let action: () -> Void + + var body: some View { + Button(action: action) { + HStack(spacing: 9) { + Text(title) + Image(systemName: "arrow.right") + } + .font(.system(size: 14, weight: .medium)).foregroundStyle(Ink.bg) + .padding(.horizontal, 20).padding(.vertical, 13) + .background(Ink.text, in: .rect(cornerRadius: Ink.controlCorner)) + .opacity(enabled ? 1 : 0.4) + } + .buttonStyle(.plain) + .keyboardShortcut(.defaultAction) + .disabled(!enabled) + } +} diff --git a/Sources/InkletSetupKit/SetupCentral.swift b/Sources/InkletSetupKit/SetupCentral.swift new file mode 100644 index 0000000..9d7f451 --- /dev/null +++ b/Sources/InkletSetupKit/SetupCentral.swift @@ -0,0 +1,199 @@ +import Combine +import CoreBluetooth +import Foundation + +/// Finds inklet displays that are in their Bluetooth setup window and opens a +/// setup session with one. Displays only advertise for 3 minutes after the +/// user holds the touch strip on an offline display; nothing advertises the +/// rest of the time. +@MainActor +public final class InkletSetupCentral: NSObject, ObservableObject { + /// GATT service of the display's setup window (firmware: inklet_prov.cpp). + public static let serviceUUID = CBUUID(string: "916F2646-B8AC-4CB2-AD93-897B29D2D87D") + public static let namePrefix = "INKLET-" + + @Published public private(set) var bluetoothState: CBManagerState = .unknown + @Published public private(set) var displays: [InkletNearbyDisplay] = [] + + // Created on first use: creating it is what shows the Bluetooth prompt. + private lazy var central = CBCentralManager(delegate: self, queue: .main) + private var peripherals: [UUID: CBPeripheral] = [:] + private var lastSeen: [UUID: Date] = [:] + private var wantsScan = false + private var pruneTask: Task? + private var pendingConnect: (id: UUID, continuation: CheckedContinuation)? + private weak var session: InkletSetupSession? + + public override init() { + super.init() + } + + /// Why Bluetooth can't be used right now, if it can't. + public var unavailableReason: InkletSetupError? { + switch bluetoothState { + case .poweredOff: return .bluetoothOff + case .unauthorized: return .bluetoothUnauthorized + case .unsupported: return .bluetoothUnsupported + default: return nil + } + } + + public func startScanning() { + wantsScan = true + if central.state == .poweredOn { beginScan() } + } + + public func stopScanning() { + wantsScan = false + pruneTask?.cancel() + pruneTask = nil + if central.state == .poweredOn { central.stopScan() } + } + + /// The nearby display advertising `name` (from the setup QR code), waiting + /// up to `timeout` for it to show up. + public func waitForDisplay(named name: String, timeout: TimeInterval = 10) async throws -> InkletNearbyDisplay { + startScanning() + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if let reason = unavailableReason { throw reason } + if let match = displays.first(where: { $0.name.caseInsensitiveCompare(name) == .orderedSame }) { + return match + } + try await Task.sleep(for: .milliseconds(250)) + } + throw InkletSetupError.displayNotFound + } + + /// Connects and maps the display's setup endpoints. The session is not yet + /// secured: call `establish(code:)` next. + public func connect(_ display: InkletNearbyDisplay, timeout: TimeInterval = 15) async throws -> InkletSetupSession { + if let reason = unavailableReason { throw reason } + guard let peripheral = peripherals[display.id] else { throw InkletSetupError.displayNotFound } + stopScanning() + session?.close() + + try await awaitStep(timeout: timeout, onTimeout: { [weak self] in + self?.failPendingConnect(.connectionFailed) + }) { c in + pendingConnect = (peripheral.identifier, c) + central.connect(peripheral) + } + + let session = InkletSetupSession(display: display, peripheral: peripheral, central: self) + self.session = session + do { + try await session.mapEndpoints() + } catch { + session.close() + throw error + } + return session + } + + func cancelConnection(_ peripheral: CBPeripheral) { + if central.state == .poweredOn { central.cancelPeripheralConnection(peripheral) } + } + + private func beginScan() { + central.scanForPeripherals(withServices: nil, + options: [CBCentralManagerScanOptionAllowDuplicatesKey: true]) + pruneTask?.cancel() + pruneTask = Task { [weak self] in + while !Task.isCancelled { + try? await Task.sleep(for: .seconds(2)) + self?.pruneStale() + } + } + } + + /// A display stops advertising when its setup window closes; drop it. + private func pruneStale() { + let cutoff = Date().addingTimeInterval(-8) + let stale = lastSeen.filter { $0.value < cutoff }.map(\.key) + guard !stale.isEmpty else { return } + for id in stale { lastSeen[id] = nil } + displays.removeAll { stale.contains($0.id) } + } + + private func failPendingConnect(_ error: InkletSetupError) { + guard let pending = pendingConnect else { return } + pendingConnect = nil + if let peripheral = peripherals[pending.id] { cancelConnection(peripheral) } + pending.continuation.resume(throwing: error) + } +} + +extension InkletSetupCentral: @preconcurrency CBCentralManagerDelegate { + public func centralManagerDidUpdateState(_ central: CBCentralManager) { + bluetoothState = central.state + if central.state == .poweredOn { + if wantsScan { beginScan() } + } else { + displays = [] + if let reason = unavailableReason { failPendingConnect(reason) } + } + } + + public func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, + advertisementData: [String: Any], rssi RSSI: NSNumber) { + let advertisedName = advertisementData[CBAdvertisementDataLocalNameKey] as? String ?? peripheral.name + let services = advertisementData[CBAdvertisementDataServiceUUIDsKey] as? [CBUUID] ?? [] + let named = advertisedName?.uppercased().hasPrefix(Self.namePrefix) ?? false + guard named || services.contains(Self.serviceUUID) else { return } + + let rssi = RSSI.intValue + guard rssi < 0 else { return } // 127 = "not available" + peripherals[peripheral.identifier] = peripheral + lastSeen[peripheral.identifier] = Date() + let name = advertisedName ?? "INKLET" + if let i = displays.firstIndex(where: { $0.id == peripheral.identifier }) { + if displays[i].name != name || abs(displays[i].rssi - rssi) >= 6 { + displays[i] = InkletNearbyDisplay(id: peripheral.identifier, name: name, rssi: rssi) + } + } else { + displays.append(InkletNearbyDisplay(id: peripheral.identifier, name: name, rssi: rssi)) + } + } + + public func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) { + guard let pending = pendingConnect, pending.id == peripheral.identifier else { return } + pendingConnect = nil + pending.continuation.resume() + } + + public func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, + error: Error?) { + guard pendingConnect?.id == peripheral.identifier else { return } + failPendingConnect(.connectionFailed) + } + + // Only ever about a connection that was established. It can arrive late — + // after a wrong code the display drops the link and the app is already + // reconnecting — so it must not touch a connect still in flight; a failed + // connect is reported through didFailToConnect instead. + public func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, + error: Error?) { + if let session, session.peripheral.identifier == peripheral.identifier { + session.handleDisconnect() + } + } +} + +/// One delegate-driven Bluetooth step: `start` kicks it off and stores the +/// continuation for the delegate callback to resume. If it is still pending +/// after `timeout`, `onTimeout` runs (on the main actor) and must resume it +/// with an error — and must be a no-op when the step already finished. +@MainActor +func awaitStep( + timeout: TimeInterval, + onTimeout: @escaping @MainActor () -> Void, + _ start: (CheckedContinuation) -> Void +) async throws -> T { + let timer = Task { @MainActor in + try? await Task.sleep(for: .seconds(timeout)) + if !Task.isCancelled { onTimeout() } + } + defer { timer.cancel() } + return try await withCheckedThrowingContinuation(start) +} diff --git a/Sources/InkletSetupKit/SetupFlow.swift b/Sources/InkletSetupKit/SetupFlow.swift new file mode 100644 index 0000000..3d79f50 --- /dev/null +++ b/Sources/InkletSetupKit/SetupFlow.swift @@ -0,0 +1,191 @@ +import Combine +import Foundation + +/// The whole "set up a display over Bluetooth" conversation, UI-agnostic so the +/// iPhone and Mac apps walk the same steps: +/// +/// pick display (or scan its QR) → enter the code on its screen → pick the +/// Wi-Fi it should join → wait for it to join → claim it for this account. +/// +/// Claiming uses the display's bind credential (the `inklet://bind?…` URI its +/// NFC tag carries), handed over inside the code-secured session; the app +/// supplies `bindHandler` to post it to `POST /api/devices/bind/nfc`. +@MainActor +public final class InkletSetupFlow: ObservableObject { + public enum Step: Equatable { + case pickDisplay + case enterCode(InkletNearbyDisplay) + case connecting(InkletNearbyDisplay) + case scanningWiFi + case pickNetwork + case joining(String) + case binding + case finished(Outcome) + } + + public enum Outcome: Equatable { + /// On Wi-Fi and claimed; the new device's id when the server returned it. + case bound(deviceID: String?) + /// On Wi-Fi, but the display carries no bind credential (or the app + /// has no way to use it): it still has to be claimed via its NFC tag. + case wifiOnly + /// On Wi-Fi, but the server refused the claim (message is user-facing). + case bindFailed(String) + } + + public let central = InkletSetupCentral() + @Published public private(set) var step: Step = .pickDisplay + @Published public private(set) var networks: [InkletWiFiNetwork] = [] + @Published public private(set) var info: InkletDisplayInfo? + /// The last thing that went wrong, worded for the user. Cleared on the + /// next action. + @Published public var errorMessage: String? + + /// Claims the display with its bind URI; returns the new device id if known. + public var bindHandler: (@MainActor (String) async throws -> String?)? + + private var session: InkletSetupSession? + private var display: InkletNearbyDisplay? + + public init() {} + + public func start() { + central.startScanning() + } + + /// Leaving the setup screen: drop the connection and stop scanning. + public func stop() { + session?.close() + session = nil + central.stopScanning() + } + + /// Back to the list of nearby displays. + public func restart() { + session?.close() + session = nil + display = nil + info = nil + networks = [] + errorMessage = nil + step = .pickDisplay + central.startScanning() + } + + public func choose(_ display: InkletNearbyDisplay) { + errorMessage = nil + self.display = display + step = .enterCode(display) + } + + /// The setup QR carries both the display's name and its code. + public func useQRCode(_ text: String) async { + guard let qr = InkletSetupQR(text) else { + errorMessage = "That isn't an inklet setup code." + return + } + errorMessage = nil + do { + let match = try await central.waitForDisplay(named: qr.name) + display = match + await connect(to: match, code: qr.code) + } catch { + fail(error, backTo: .pickDisplay) + } + } + + public func submitCode(_ code: String) async { + guard case .enterCode(let display) = step else { return } + let trimmed = code.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + await connect(to: display, code: trimmed) + } + + public func rescanWiFi() async { + guard let session else { return restart() } + // A second tap (or Return plus the default button) while a request is + // out would talk over it. + guard step == .pickNetwork else { return } + errorMessage = nil + step = .scanningWiFi + do { + networks = try await session.scanWiFi() + step = .pickNetwork + } catch { + fail(error, backTo: .pickNetwork) + } + } + + public func join(ssid: String, password: String) async { + guard let session else { return restart() } + guard step == .pickNetwork else { return } + errorMessage = nil + step = .joining(ssid) + do { + try await session.joinWiFi(ssid: ssid, password: password) + } catch let error as InkletSetupError where error == .wifiWrongPassword || error == .wifiNotFound { + // The display stays in setup; let the user fix it and try again. + errorMessage = error.errorDescription + step = .pickNetwork + return + } catch { + fail(error, backTo: .pickNetwork) + return + } + // The display reboots onto Wi-Fi a few seconds from now; this session + // is over either way. + session.close() + self.session = nil + central.stopScanning() + + guard let uri = info?.bind, !uri.isEmpty, let bindHandler else { + step = .finished(.wifiOnly) + return + } + step = .binding + do { + step = .finished(.bound(deviceID: try await bindHandler(uri))) + } catch { + let message = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription + step = .finished(.bindFailed(message)) + } + } + + private func connect(to display: InkletNearbyDisplay, code: String) async { + errorMessage = nil + step = .connecting(display) + session?.close() + do { + let session = try await central.connect(display) + self.session = session + try await session.establish(code: code) + // Before Wi-Fi: once the display joins a network it reboots and + // this session ends. + info = try await session.deviceInfo() + step = .scanningWiFi + networks = try await session.scanWiFi() + step = .pickNetwork + } catch { + session?.close() + session = nil + if (error as? InkletSetupError) == .wrongCode { + errorMessage = InkletSetupError.wrongCode.errorDescription + step = .enterCode(display) + } else { + fail(error, backTo: .pickDisplay) + } + } + } + + private func fail(_ error: Error, backTo fallback: Step) { + errorMessage = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription + if (error as? InkletSetupError) == .disconnected || session?.isOpen != true { + session?.close() + session = nil + step = .pickDisplay + central.startScanning() + } else { + step = fallback + } + } +} diff --git a/Sources/InkletSetupKit/SetupModels.swift b/Sources/InkletSetupKit/SetupModels.swift new file mode 100644 index 0000000..9b0b9a4 --- /dev/null +++ b/Sources/InkletSetupKit/SetupModels.swift @@ -0,0 +1,91 @@ +import Foundation + +/// An inklet display that is advertising its Bluetooth setup window. Displays +/// only do so for 3 minutes after the user holds the touch strip while the +/// display is offline, so anything in this list is asking to be set up. +public struct InkletNearbyDisplay: Identifiable, Hashable, Sendable { + public let id: UUID + /// "INKLET-2078": the name printed on the setup screen next to the code. + public let name: String + public var rssi: Int +} + +public struct InkletWiFiNetwork: Identifiable, Hashable, Sendable { + public var id: String { ssid } + public let ssid: String + public let rssi: Int + public let channel: Int + /// False for open networks: no password field needed. + public let secured: Bool + + /// 0...3 bars for a signal icon. + public var bars: Int { + switch rssi { + case (-55)...: return 3 + case (-67)...: return 2 + case (-80)...: return 1 + default: return 0 + } + } +} + +/// What the display reports about itself over the secured session. `bind` is +/// the display's claim credential — the same `inklet://bind?v=2&…` URI its NFC +/// tag carries — when the factory issued one. +public struct InkletDisplayInfo: Decodable, Equatable, Sendable { + public let hwId: String + public let thingName: String + public let firmware: String? + public let bind: String? +} + +/// The setup screen's QR code: Espressif's standard provisioning JSON, +/// `{"ver":"v1","name":"INKLET-2078","pop":"Q4VV4MA5","transport":"ble"}`. +public struct InkletSetupQR: Equatable, Sendable { + public let name: String + public let code: String + + public init?(_ text: String) { + guard let data = text.data(using: .utf8), + let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let name = obj["name"] as? String, let pop = obj["pop"] as? String, + (obj["transport"] as? String ?? "ble") == "ble", !name.isEmpty, !pop.isEmpty + else { return nil } + self.name = name + self.code = pop + } +} + +public enum InkletSetupError: LocalizedError, Equatable { + case bluetoothOff + case bluetoothUnauthorized + case bluetoothUnsupported + case displayNotFound + case connectionFailed + case disconnected + case wrongCode + case wifiWrongPassword + case wifiNotFound + case wifiTimeout + case timeout + case missingEndpoint(String) + case protocolViolation(String) + + public var errorDescription: String? { + switch self { + case .bluetoothOff: return "Bluetooth is off. Turn it on to set up your display." + case .bluetoothUnauthorized: return "inklet needs Bluetooth access to set up your display. Allow it in Settings." + case .bluetoothUnsupported: return "This device doesn't support Bluetooth Low Energy." + case .displayNotFound: return "Couldn't find that display. Make sure the setup screen is still showing." + case .connectionFailed: return "Couldn't connect to the display. Move closer and try again." + case .disconnected: return "The display disconnected. If the setup screen timed out, hold the touch strip for 3 seconds to start again." + case .wrongCode: return "That code doesn't match. Check the code printed under the QR code on the display." + case .wifiWrongPassword: return "The Wi-Fi password is incorrect." + case .wifiNotFound: return "The display can't see that Wi-Fi network. It needs to be in range." + case .wifiTimeout: return "The display didn't manage to join the Wi-Fi network." + case .timeout: return "The display stopped responding." + case .missingEndpoint(let name): return "This display's firmware doesn't support \(name)." + case .protocolViolation(let what): return "Unexpected reply from the display (\(what))." + } + } +} diff --git a/Sources/InkletSetupKit/SetupProto.swift b/Sources/InkletSetupKit/SetupProto.swift new file mode 100644 index 0000000..b23f6fc --- /dev/null +++ b/Sources/InkletSetupKit/SetupProto.swift @@ -0,0 +1,108 @@ +import Foundation + +// Just enough protobuf for Espressif's provisioning messages (protocomm +// session + network_provisioning config/scan): varint and length-delimited +// fields and nested messages. Field numbers are taken from the .proto files in +// esp-idf/components/protocomm/proto and +// idf-extra-components/network_provisioning/proto. + +struct ProtoWriter { + private(set) var data = Data() + + mutating func varint(_ field: Int, _ value: UInt64) { + tag(field, wireType: 0) + appendVarint(value) + } + + mutating func bool(_ field: Int, _ value: Bool) { + varint(field, value ? 1 : 0) + } + + mutating func bytes(_ field: Int, _ value: Data) { + tag(field, wireType: 2) + appendVarint(UInt64(value.count)) + data.append(value) + } + + /// A nested message. Always emitted, even when empty: an empty message is + /// how a protobuf `oneof` says which command this is. + mutating func message(_ field: Int, _ build: (inout ProtoWriter) -> Void = { _ in }) { + var inner = ProtoWriter() + build(&inner) + bytes(field, inner.data) + } + + private mutating func tag(_ field: Int, wireType: UInt64) { + appendVarint(UInt64(field) << 3 | wireType) + } + + private mutating func appendVarint(_ value: UInt64) { + var v = value + while v >= 0x80 { + data.append(UInt8(v & 0x7F) | 0x80) + v >>= 7 + } + data.append(UInt8(v)) + } +} + +struct ProtoMessage { + private var varints: [Int: [UInt64]] = [:] + private var blobs: [Int: [Data]] = [:] + + init(_ input: Data) throws { + let bytes = [UInt8](input) + var i = 0 + + func readVarint() throws -> UInt64 { + var result: UInt64 = 0 + var shift: UInt64 = 0 + while true { + guard i < bytes.count, shift < 64 else { throw InkletSetupError.protocolViolation("truncated varint") } + let b = bytes[i] + i += 1 + result |= UInt64(b & 0x7F) << shift + if b & 0x80 == 0 { return result } + shift += 7 + } + } + + while i < bytes.count { + let key = try readVarint() + let field = Int(key >> 3) + switch key & 7 { + case 0: + varints[field, default: []].append(try readVarint()) + case 1: + guard bytes.count - i >= 8 else { throw InkletSetupError.protocolViolation("truncated fixed64") } + i += 8 + case 2: + let length = try readVarint() + guard length <= UInt64(bytes.count - i) else { throw InkletSetupError.protocolViolation("truncated field") } + let end = i + Int(length) + blobs[field, default: []].append(Data(bytes[i..= 4 else { throw InkletSetupError.protocolViolation("truncated fixed32") } + i += 4 + default: + throw InkletSetupError.protocolViolation("unknown wire type") + } + } + } + + /// proto3 leaves zero values off the wire, so a missing scalar reads as 0. + func varint(_ field: Int) -> UInt64 { varints[field]?.last ?? 0 } + func int32(_ field: Int) -> Int32 { Int32(truncatingIfNeeded: Int64(bitPattern: varint(field))) } + func bool(_ field: Int) -> Bool { varint(field) != 0 } + func bytes(_ field: Int) -> Data? { blobs[field]?.last } + func has(_ field: Int) -> Bool { blobs[field] != nil || varints[field] != nil } + + func message(_ field: Int) throws -> ProtoMessage? { + try bytes(field).map { try ProtoMessage($0) } + } + + func messages(_ field: Int) throws -> [ProtoMessage] { + try (blobs[field] ?? []).map { try ProtoMessage($0) } + } +} diff --git a/Sources/InkletSetupKit/SetupSecurity.swift b/Sources/InkletSetupKit/SetupSecurity.swift new file mode 100644 index 0000000..1f8961e --- /dev/null +++ b/Sources/InkletSetupKit/SetupSecurity.swift @@ -0,0 +1,69 @@ +import CommonCrypto +import CryptoKit +import Foundation + +/// Espressif protocomm "Security 1" (sec1.proto): an X25519 key agreement whose +/// shared secret is XORed with SHA-256 of the proof of possession (the code +/// printed on the display), then a single AES-256-CTR keystream — IV = the +/// device's random — consumed in order by every byte sent *and* received for +/// the rest of the session. Both ends advance the same stream, so requests and +/// responses must be processed strictly in turn. +final class SetupSecurity1 { + private let privateKey = Curve25519.KeyAgreement.PrivateKey() + private var cryptor: CCCryptorRef? + + var clientPublicKey: Data { privateKey.publicKey.rawRepresentation } + + deinit { + if let cryptor { CCCryptorRelease(cryptor) } + } + + func start(devicePublicKey: Data, deviceRandom: Data, proofOfPossession: String) throws { + guard devicePublicKey.count == 32, deviceRandom.count == 16 else { + throw InkletSetupError.protocolViolation("bad session parameters") + } + let peer = try Curve25519.KeyAgreement.PublicKey(rawRepresentation: devicePublicKey) + let shared = try privateKey.sharedSecretFromKeyAgreement(with: peer) + var key = shared.withUnsafeBytes { Data($0) } + if !proofOfPossession.isEmpty { + let digest = SHA256.hash(data: Data(proofOfPossession.utf8)) + key = Data(zip(key, digest).map { $0 ^ $1 }) + } + try installCipher(key: key, iv: deviceRandom) + } + + /// AES-256-CTR with a big-endian 128-bit counter starting at `iv`. + func installCipher(key: Data, iv deviceRandom: Data) throws { + var ref: CCCryptorRef? + let status = key.withUnsafeBytes { k in + deviceRandom.withUnsafeBytes { iv in + CCCryptorCreateWithMode( + CCOperation(kCCEncrypt), CCMode(kCCModeCTR), CCAlgorithm(kCCAlgorithmAES), + CCPadding(ccNoPadding), iv.baseAddress, k.baseAddress, key.count, + nil, 0, 0, CCModeOptions(kCCModeOptionCTR_BE), &ref) + } + } + guard status == kCCSuccess, let ref else { + throw InkletSetupError.protocolViolation("cipher setup failed (\(status))") + } + if let cryptor { CCCryptorRelease(cryptor) } + cryptor = ref + } + + /// Encrypts or decrypts (CTR is symmetric) the next bytes of the stream. + func apply(_ input: Data) throws -> Data { + guard let cryptor else { throw InkletSetupError.protocolViolation("no secure session") } + guard !input.isEmpty else { return input } + var output = Data(count: input.count) + var moved = 0 + let status = output.withUnsafeMutableBytes { out in + input.withUnsafeBytes { inp in + CCCryptorUpdate(cryptor, inp.baseAddress, input.count, out.baseAddress, input.count, &moved) + } + } + guard status == kCCSuccess, moved == input.count else { + throw InkletSetupError.protocolViolation("cipher failed (\(status))") + } + return output + } +} diff --git a/Sources/InkletSetupKit/SetupSession.swift b/Sources/InkletSetupKit/SetupSession.swift new file mode 100644 index 0000000..1648657 --- /dev/null +++ b/Sources/InkletSetupKit/SetupSession.swift @@ -0,0 +1,343 @@ +import CoreBluetooth +import Foundation + +/// A Bluetooth setup session with one display, speaking Espressif's +/// network_provisioning protocol: every endpoint is a GATT characteristic the +/// app writes a request to and then reads the reply from. +/// +/// Typical use: `establish(code:)` → `deviceInfo()` → `scanWiFi()` → +/// `joinWiFi(ssid:password:)`. Read the device info before joining: once the +/// display is on Wi-Fi it reboots within seconds and the session ends. +@MainActor +public final class InkletSetupSession: NSObject { + public let display: InkletNearbyDisplay + let peripheral: CBPeripheral + private weak var central: InkletSetupCentral? + + private var endpoints: [String: CBCharacteristic] = [:] + private var security: SetupSecurity1? + private var pending: CheckedContinuation? + private var stepID = 0 + private var closed = false + + /// Endpoint names by the 16-bit id the firmware's provisioning manager + /// assigns them; used only if a characteristic has no name descriptor. + private static let fallbackNames: [UInt16: String] = [ + 0xFF4F: "prov-ctrl", 0xFF50: "prov-scan", 0xFF51: "prov-session", + 0xFF52: "prov-config", 0xFF53: "proto-ver", 0xFF54: "inklet-device", + ] + + init(display: InkletNearbyDisplay, peripheral: CBPeripheral, central: InkletSetupCentral) { + self.display = display + self.peripheral = peripheral + self.central = central + super.init() + peripheral.delegate = self + } + + public var isSecured: Bool { security != nil } + /// False once the link is gone or the session gave up on it. + public var isOpen: Bool { !closed } + + public func close() { + guard !closed else { return } + closed = true + finishStep(.failure(InkletSetupError.disconnected)) + central?.cancelConnection(peripheral) + } + + // MARK: Session + + /// Runs the Security 1 handshake with the code printed on the display. + /// Throws `.wrongCode` when the display rejects it. + public func establish(code: String) async throws { + let pop = code.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() + let sec = SetupSecurity1() + + var cmd0 = ProtoWriter() + cmd0.varint(2, 1) // sec_ver = SecScheme1 + cmd0.message(11) { sec1 in // sec1 + sec1.varint(1, 0) // msg = Session_Command0 + sec1.message(20) { $0.bytes(1, sec.clientPublicKey) } // sc0.client_pubkey + } + let reply0 = try ProtoMessage(await transact("prov-session", cmd0.data)) + guard let sr0 = try reply0.message(11)?.message(21) else { + throw InkletSetupError.protocolViolation("no session response") + } + guard sr0.varint(1) == 0, let devicePublicKey = sr0.bytes(2), let deviceRandom = sr0.bytes(3) else { + throw InkletSetupError.protocolViolation("session refused (\(sr0.varint(1)))") + } + try sec.start(devicePublicKey: devicePublicKey, deviceRandom: deviceRandom, proofOfPossession: pop) + + var cmd1 = ProtoWriter() + cmd1.varint(2, 1) + let clientVerify = try sec.apply(devicePublicKey) + cmd1.message(11) { sec1 in + sec1.varint(1, 2) // msg = Session_Command1 + sec1.message(22) { $0.bytes(2, clientVerify) } // sc1.client_verify_data + } + // A wrong code makes the display's check fail; it drops the request + // (an ATT error or an empty reply) rather than answering. + let reply1: ProtoMessage + do { + reply1 = try ProtoMessage(await transact("prov-session", cmd1.data)) + } catch InkletSetupError.disconnected { + throw InkletSetupError.wrongCode + } catch is CBATTError { + throw InkletSetupError.wrongCode + } + guard let sr1 = try reply1.message(11)?.message(23), sr1.varint(1) == 0, + let deviceVerify = sr1.bytes(3), + try sec.apply(deviceVerify) == sec.clientPublicKey + else { throw InkletSetupError.wrongCode } + security = sec + } + + /// Who the display is, and its bind credential if the factory issued one. + public func deviceInfo() async throws -> InkletDisplayInfo { + let reply = try await secureTransact("inklet-device", Data("{}".utf8)) + do { + return try JSONDecoder().decode(InkletDisplayInfo.self, from: reply) + } catch { + throw InkletSetupError.protocolViolation("device info") + } + } + + // MARK: Wi-Fi + + /// Networks the display itself can see (it may be in a different spot, and + /// on a different band, than this phone or Mac), strongest first. + public func scanWiFi() async throws -> [InkletWiFiNetwork] { + var start = ProtoWriter() + start.varint(1, 0) // TypeCmdScanWifiStart + start.message(10) { cmd in // cmd_scan_wifi_start + cmd.bool(1, true) // blocking + cmd.bool(2, false) // passive + cmd.varint(3, 0) // group_channels + cmd.varint(4, 120) // period_ms + } + try checkScanStatus(try ProtoMessage(await secureTransact("prov-scan", start.data, timeout: 30))) + + var count = 0 + for _ in 0..<30 { + var status = ProtoWriter() + status.varint(1, 2) // TypeCmdScanWifiStatus + status.message(12) + let reply = try ProtoMessage(await secureTransact("prov-scan", status.data)) + try checkScanStatus(reply) + guard let st = try reply.message(13) else { throw InkletSetupError.protocolViolation("scan status") } + if st.bool(1) { // scan_finished + count = Int(st.varint(2)) // result_count + break + } + try await Task.sleep(for: .milliseconds(500)) + } + + var best: [String: InkletWiFiNetwork] = [:] + var index = 0 + while index < count { + let batch = min(4, count - index) + var request = ProtoWriter() + request.varint(1, 4) // TypeCmdScanWifiResult + request.message(14) { cmd in + cmd.varint(1, UInt64(index)) // start_index + cmd.varint(2, UInt64(batch)) // count + } + let reply = try ProtoMessage(await secureTransact("prov-scan", request.data)) + try checkScanStatus(reply) + guard let results = try reply.message(15) else { throw InkletSetupError.protocolViolation("scan results") } + for entry in try results.messages(1) { + let ssid = String(decoding: entry.bytes(1) ?? Data(), as: UTF8.self) + guard !ssid.isEmpty else { continue } // hidden networks + let network = InkletWiFiNetwork(ssid: ssid, rssi: Int(entry.int32(3)), + channel: Int(entry.varint(2)), secured: entry.varint(5) != 0) + if let known = best[ssid], known.rssi >= network.rssi { continue } + best[ssid] = network + } + index += batch + } + return best.values.sorted { $0.rssi > $1.rssi } + } + + /// Hands the display a network and waits until it has actually joined it. + /// Throws `.wifiWrongPassword` / `.wifiNotFound` so the user can retry in + /// the same session. + public func joinWiFi(ssid: String, password: String) async throws { + var set = ProtoWriter() + set.varint(1, 2) // TypeCmdSetWifiConfig + set.message(12) { cmd in + cmd.bytes(1, Data(ssid.utf8)) // ssid + cmd.bytes(2, Data(password.utf8)) // passphrase + } + let setReply = try ProtoMessage(await secureTransact("prov-config", set.data)) + guard let setResp = try setReply.message(13), setResp.varint(1) == 0 else { + throw InkletSetupError.protocolViolation("set Wi-Fi refused") + } + + var apply = ProtoWriter() + apply.varint(1, 4) // TypeCmdApplyWifiConfig + apply.message(14) + let applyReply = try ProtoMessage(await secureTransact("prov-config", apply.data)) + guard let applyResp = try applyReply.message(15), applyResp.varint(1) == 0 else { + throw InkletSetupError.protocolViolation("apply Wi-Fi refused") + } + + for _ in 0..<45 { + try await Task.sleep(for: .seconds(1)) + var get = ProtoWriter() + get.varint(1, 0) // TypeCmdGetWifiStatus + get.message(10) + let reply = try ProtoMessage(await secureTransact("prov-config", get.data)) + guard let status = try reply.message(11) else { throw InkletSetupError.protocolViolation("Wi-Fi status") } + // Connected (0) is proto3's default and so never on the wire; the + // wifi_connected details (field 11) are what really says so. + if status.has(11) { return } + switch status.varint(2) { // wifi_sta_state + case 3: // ConnectionFailed + throw status.varint(10) == 0 ? InkletSetupError.wifiWrongPassword : InkletSetupError.wifiNotFound + default: + continue // Connecting / Disconnected (retrying) + } + } + throw InkletSetupError.wifiTimeout + } + + private func checkScanStatus(_ reply: ProtoMessage) throws { + let status = reply.varint(2) + if status != 0 { throw InkletSetupError.protocolViolation("scan refused (\(status))") } + } + + // MARK: Transport + + func handleDisconnect() { + closed = true + finishStep(.failure(InkletSetupError.disconnected)) + } + + /// Reads each characteristic's user-description descriptor, which carries + /// the endpoint name ("prov-session", "prov-config", …). + func mapEndpoints() async throws { + try await step { peripheral.discoverServices([InkletSetupCentral.serviceUUID]) } + guard let service = peripheral.services?.first(where: { $0.uuid == InkletSetupCentral.serviceUUID }) else { + throw InkletSetupError.missingEndpoint("Bluetooth setup") + } + try await step { peripheral.discoverCharacteristics(nil, for: service) } + let nameUUID = CBUUID(string: CBUUIDCharacteristicUserDescriptionString) + for characteristic in service.characteristics ?? [] { + try await step { peripheral.discoverDescriptors(for: characteristic) } + if let descriptor = characteristic.descriptors?.first(where: { $0.uuid == nameUUID }) { + try await step { peripheral.readValue(for: descriptor) } + if let name = Self.descriptorString(descriptor.value), !name.isEmpty { + endpoints[name] = characteristic + continue + } + } + if let name = Self.fallbackName(for: characteristic.uuid) { + endpoints[name] = characteristic + } + } + guard endpoints["prov-session"] != nil, endpoints["prov-config"] != nil else { + throw InkletSetupError.missingEndpoint("Wi-Fi setup") + } + } + + private func secureTransact(_ endpoint: String, _ request: Data, timeout: TimeInterval = 15) async throws -> Data { + guard let security else { throw InkletSetupError.protocolViolation("session not established") } + guard endpoints[endpoint] != nil else { throw InkletSetupError.missingEndpoint(endpoint) } + // Encrypting the request advances the shared keystream. If its reply + // never arrives, this end and the display no longer agree on where the + // stream is, and everything after would decrypt to noise: end the + // session rather than keep talking past it. + let reply: Data + do { + reply = try await transact(endpoint, try security.apply(request), timeout: timeout) + } catch { + close() + throw error + } + return try security.apply(reply) + } + + private func transact(_ endpoint: String, _ request: Data, timeout: TimeInterval = 15) async throws -> Data { + guard let characteristic = endpoints[endpoint] else { throw InkletSetupError.missingEndpoint(endpoint) } + try await step(timeout: timeout) { peripheral.writeValue(request, for: characteristic, type: .withResponse) } + return try await dataStep(timeout: timeout) { peripheral.readValue(for: characteristic) } + } + + private func step(timeout: TimeInterval = 10, _ start: () -> Void) async throws { + _ = try await dataStep(timeout: timeout, start) + } + + private func dataStep(timeout: TimeInterval = 10, _ start: () -> Void) async throws -> Data { + guard !closed else { throw InkletSetupError.disconnected } + // One request at a time: there is a single continuation slot, and the + // keystream has to be consumed in order. + guard pending == nil else { throw InkletSetupError.protocolViolation("busy") } + stepID += 1 + let id = stepID + return try await awaitStep(timeout: timeout, onTimeout: { [weak self] in + guard let self, self.stepID == id else { return } + self.finishStep(.failure(InkletSetupError.timeout)) + }) { continuation in + pending = continuation + start() + } + } + + private func finishStep(_ result: Result) { + guard let continuation = pending else { return } + pending = nil + continuation.resume(with: result) + } + + private func finishStep(error: Error?, value: Data? = nil) { + if let error { + finishStep(.failure(error)) + } else { + finishStep(.success(value ?? Data())) + } + } + + private static func descriptorString(_ value: Any?) -> String? { + if let string = value as? String { return string } + if let data = value as? Data { return String(data: data, encoding: .utf8) } + return nil + } + + private static func fallbackName(for uuid: CBUUID) -> String? { + let bytes = [UInt8](uuid.data) + guard bytes.count == 16 else { return nil } + return fallbackNames[UInt16(bytes[2]) << 8 | UInt16(bytes[3])] + } +} + +extension InkletSetupSession: @preconcurrency CBPeripheralDelegate { + public func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) { + finishStep(error: error) + } + + public func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, + error: Error?) { + finishStep(error: error) + } + + public func peripheral(_ peripheral: CBPeripheral, didDiscoverDescriptorsFor characteristic: CBCharacteristic, + error: Error?) { + finishStep(error: error) + } + + public func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor descriptor: CBDescriptor, + error: Error?) { + finishStep(error: error) + } + + public func peripheral(_ peripheral: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic, + error: Error?) { + finishStep(error: error) + } + + public func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, + error: Error?) { + finishStep(error: error, value: characteristic.value) + } +} diff --git a/Tests/InkletSetupKitTests/SetupKitTests.swift b/Tests/InkletSetupKitTests/SetupKitTests.swift new file mode 100644 index 0000000..54de05d --- /dev/null +++ b/Tests/InkletSetupKitTests/SetupKitTests.swift @@ -0,0 +1,93 @@ +import Foundation +import Testing +@testable import InkletSetupKit + +private func hex(_ s: String) -> Data { + var data = Data() + var i = s.startIndex + while i < s.endIndex { + let j = s.index(i, offsetBy: 2) + data.append(UInt8(s[i..