Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand All @@ -29,6 +35,7 @@ let package = Package(
dependencies: [
"InkletPresentationKit",
"InkletPresentationWidget",
"InkletSetupKit",
.product(name: "Sparkle", package: "Sparkle"),
],
path: "Sources/InkletMac",
Expand All @@ -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"],
Expand Down
2 changes: 2 additions & 0 deletions Scripts/build-app.sh
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,8 @@ cat > "$APP/Contents/Info.plist" <<PLIST
<key>CFBundleURLName</key><string>com.iminklet.mac.widgets</string>
<key>CFBundleURLSchemes</key><array><string>inklet-mac</string></array>
</dict></array>
<key>NSBluetoothAlwaysUsageDescription</key>
<string>inklet Portal uses Bluetooth to set up a new display: it gives the display your Wi-Fi network and adds it to your account.</string>
<key>NSLocationUsageDescription</key>
<string>inklet Portal uses your approximate location to show the weather on Home.</string>
<key>NSLocationWhenInUseUsageDescription</key>
Expand Down
23 changes: 23 additions & 0 deletions Sources/InkletMac/Models/AppModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
64 changes: 64 additions & 0 deletions Sources/InkletMac/Services/DisplayBind.swift
Original file line number Diff line number Diff line change
@@ -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)).")
}
}
}
19 changes: 19 additions & 0 deletions Sources/InkletMac/Services/InkletAPI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
7 changes: 7 additions & 0 deletions Sources/InkletMac/Services/Wire.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading
Loading