initial commit: Proximity iOS app proposal, full Swift code scaffold, reference backend, testing docs, generated Xcode project
This commit is contained in:
147
Proximity/Services/Network/APIClient.swift
Normal file
147
Proximity/Services/Network/APIClient.swift
Normal file
@@ -0,0 +1,147 @@
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
|
||||
/// Backend client for the Proximity relay.
|
||||
///
|
||||
/// The server's role is deliberately narrow and privacy-preserving:
|
||||
/// - It **relays anonymous tokens** so two present devices can discover each
|
||||
/// other (especially for UWB, which needs a peer token),
|
||||
/// - It **correlates mutual encounters** (token A near token B) without ever
|
||||
/// learning identities,
|
||||
/// - It **delivers E2E-encrypted messages** (ciphertext only).
|
||||
///
|
||||
/// The server never stores precise location history or identity by default.
|
||||
struct APIClient {
|
||||
|
||||
private let baseURL: URL
|
||||
private let session = URLSession.shared
|
||||
|
||||
/// Point at the production API.
|
||||
init() {
|
||||
self.baseURL = URL(string: "https://api.proximity.app")!
|
||||
}
|
||||
|
||||
/// Point at a custom backend (e.g. the local reference server).
|
||||
init(baseURL: URL) {
|
||||
self.baseURL = baseURL
|
||||
}
|
||||
|
||||
// MARK: - Auth
|
||||
|
||||
/// Exchange a provider ID token for a Proximity session.
|
||||
func exchangeToken(_ result: AuthResult) async throws -> Session {
|
||||
var request = URLRequest(url: baseURL.appendingPathComponent("v1/auth/exchange"))
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
request.httpBody = try JSONEncoder().encode([
|
||||
"provider": result.providerID.rawValue,
|
||||
"idToken": result.idToken,
|
||||
"nonce": result.nonce ?? ""
|
||||
])
|
||||
let (data, _) = try await session.data(for: request)
|
||||
return try JSONDecoder().decode(Session.self, from: data)
|
||||
}
|
||||
|
||||
/// Revoke a Proximity session.
|
||||
func revokeSession(_ session: Session) async {
|
||||
var request = URLRequest(url: baseURL.appendingPathComponent("v1/auth/revoke"))
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("Bearer \(session.token)", forHTTPHeaderField: "Authorization")
|
||||
_ = try? await session.data(for: request)
|
||||
}
|
||||
|
||||
// MARK: - Token relay
|
||||
|
||||
/// Register our current anonymous token so nearby peers can find us.
|
||||
func registerToken(_ token: String, tier: DistanceTier) async throws {
|
||||
var request = URLRequest(url: baseURL.appendingPathComponent("v1/token"))
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
request.httpBody = try JSONEncoder().encode([
|
||||
"token": token,
|
||||
"tier": tier.rawValue
|
||||
])
|
||||
_ = try await session.data(for: request)
|
||||
}
|
||||
|
||||
/// Fetch anonymous tokens of present users near our coarse location.
|
||||
func fetchNearbyTokens(geohash: String, tier: DistanceTier) async throws -> [String] {
|
||||
var components = URLComponents(
|
||||
url: baseURL.appendingPathComponent("v1/nearby"),
|
||||
resolvingAgainstBaseURL: false
|
||||
)!
|
||||
components.queryItems = [
|
||||
URLQueryItem(name: "geohash", value: geohash),
|
||||
URLQueryItem(name: "tier", value: String(tier.rawValue))
|
||||
]
|
||||
let (data, _) = try await session.data(from: components.url!)
|
||||
return try JSONDecoder().decode([String].self, from: data)
|
||||
}
|
||||
|
||||
// MARK: - Encounters
|
||||
|
||||
/// Send a "wave" to a silhouette — signals interest in mutual reveal.
|
||||
func wave(to remoteToken: String) async {
|
||||
var request = URLRequest(url: baseURL.appendingPathComponent("v1/wave"))
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
request.httpBody = try? JSONEncoder().encode(["token": remoteToken])
|
||||
_ = try? await session.data(for: request)
|
||||
}
|
||||
|
||||
/// Block a token — permanently removes it from the graph.
|
||||
func block(token: String) async {
|
||||
var request = URLRequest(url: baseURL.appendingPathComponent("v1/block"))
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
request.httpBody = try? JSONEncoder().encode(["token": token])
|
||||
_ = try? await session.data(for: request)
|
||||
}
|
||||
|
||||
/// Report a local encounter so the server can correlate the mutual side.
|
||||
func reportEncounter(_ encounter: Encounter) async {
|
||||
// Fire-and-forget; failures are non-critical.
|
||||
var request = URLRequest(url: baseURL.appendingPathComponent("v1/encounter"))
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
request.httpBody = try? JSONEncoder().encode(encounter)
|
||||
_ = try? await session.data(for: request)
|
||||
}
|
||||
|
||||
// MARK: - Reveal / key exchange
|
||||
|
||||
/// Upload our E2E public key so a mutual peer can fetch it.
|
||||
func uploadPublicKey(_ key: Curve25519.KeyAgreement.PublicKey) async throws {
|
||||
var request = URLRequest(url: baseURL.appendingPathComponent("v1/peer-key"))
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
request.httpBody = try JSONEncoder().encode([
|
||||
"publicKey": key.rawRepresentation.base64EncodedString()
|
||||
])
|
||||
_ = try await session.data(for: request)
|
||||
}
|
||||
|
||||
/// Fetch the peer's public key for a mutual encounter, via the relay.
|
||||
func fetchPeerPublicKey(for remoteToken: String) async throws -> Curve25519.KeyAgreement.PublicKey {
|
||||
var components = URLComponents(
|
||||
url: baseURL.appendingPathComponent("v1/peer-key"),
|
||||
resolvingAgainstBaseURL: false
|
||||
)!
|
||||
components.queryItems = [URLQueryItem(name: "token", value: remoteToken)]
|
||||
let (data, _) = try await session.data(from: components.url!)
|
||||
struct KeyResponse: Decodable { let publicKey: Data }
|
||||
let response = try JSONDecoder().decode(KeyResponse.self, from: data)
|
||||
return try Curve25519.KeyAgreement.PublicKey(rawRepresentation: response.publicKey)
|
||||
}
|
||||
|
||||
// MARK: - Messaging (E2E)
|
||||
|
||||
/// Send an encrypted message. Only ciphertext leaves the device.
|
||||
func sendMessage(_ message: ChatMessage) async throws {
|
||||
var request = URLRequest(url: baseURL.appendingPathComponent("v1/message"))
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
request.httpBody = try JSONEncoder().encode(message)
|
||||
_ = try await session.data(for: request)
|
||||
}
|
||||
}
|
||||
44
Proximity/Services/Network/GraphStore.swift
Normal file
44
Proximity/Services/Network/GraphStore.swift
Normal file
@@ -0,0 +1,44 @@
|
||||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
/// Local persistence for the encounter graph using SwiftData (SQLite-backed).
|
||||
///
|
||||
/// The graph lives **on-device** by default. The user is in control of what
|
||||
/// syncs to the server. Encounters are stored as anonymous silhouettes until
|
||||
/// mutual consent promotes them to revealed connections.
|
||||
@Model
|
||||
final class GraphStore {
|
||||
|
||||
var encounters: [Encounter] = []
|
||||
var connections: [Connection] = []
|
||||
var messages: [ChatMessage] = []
|
||||
|
||||
init() {}
|
||||
|
||||
// MARK: - Encounters
|
||||
|
||||
func addEncounter(_ encounter: Encounter) {
|
||||
encounters.insert(encounter, at: 0)
|
||||
}
|
||||
|
||||
func encounter(byToken token: String) -> Encounter? {
|
||||
encounters.first { $0.remoteAnonToken == token }
|
||||
}
|
||||
|
||||
func updateStatus(_ status: EncounterStatus, for token: String) {
|
||||
guard let idx = encounters.firstIndex(where: { $0.remoteAnonToken == token }) else { return }
|
||||
encounters[idx].status = status
|
||||
}
|
||||
|
||||
// MARK: - Connections
|
||||
|
||||
func addConnection(_ connection: Connection) {
|
||||
connections.insert(connection, at: 0)
|
||||
}
|
||||
|
||||
// MARK: - Messages
|
||||
|
||||
func addMessage(_ message: ChatMessage) {
|
||||
messages.append(message)
|
||||
}
|
||||
}
|
||||
132
Proximity/Services/Network/RealtimeClient.swift
Normal file
132
Proximity/Services/Network/RealtimeClient.swift
Normal file
@@ -0,0 +1,132 @@
|
||||
import Foundation
|
||||
import Combine
|
||||
|
||||
/// WebSocket client for real-time encounter correlation and messaging.
|
||||
///
|
||||
/// This is the "live wire" of the interaction moment. When two present users
|
||||
/// are near each other, the server pushes a `mutual` event so both clients
|
||||
/// can trigger the reveal in real time. Revealed messages also arrive here
|
||||
/// (E2E ciphertext only — the server never sees plaintext).
|
||||
///
|
||||
/// The client is a shared, observable singleton-style service so that both
|
||||
/// `RevealFlow` (mutual waves) and `ChatViewModel` (messages) consume the
|
||||
/// same socket. It auto-reconnects with backoff.
|
||||
@MainActor
|
||||
final class RealtimeClient: ObservableObject {
|
||||
|
||||
// MARK: - Published state
|
||||
|
||||
@Published private(set) var isConnected = false
|
||||
|
||||
// MARK: - Events (Combine publishers)
|
||||
|
||||
/// Emits a remote anonymous token when the server reports a mutual wave.
|
||||
let mutualEncounter = PassthroughSubject<String, Never>()
|
||||
|
||||
/// Emits an incoming encrypted message.
|
||||
let incomingMessage = PassthroughSubject<ChatMessage, Never>()
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private let socketURL = URL(string: "wss://api.proximity.app/ws")!
|
||||
private var task: URLSessionWebSocketTask?
|
||||
private var authToken: String?
|
||||
private var reconnectAttempts = 0
|
||||
private var reconnectTask: Task<Void, Never>?
|
||||
private var isActive = false
|
||||
|
||||
// MARK: - Lifecycle
|
||||
|
||||
/// Connect using the current session token. Idempotent.
|
||||
func connect(token: String) {
|
||||
authToken = token
|
||||
isActive = true
|
||||
reconnectAttempts = 0
|
||||
openSocket()
|
||||
}
|
||||
|
||||
func disconnect() {
|
||||
isActive = false
|
||||
reconnectTask?.cancel()
|
||||
task?.cancel(with: .goingAway, reason: nil)
|
||||
task = nil
|
||||
isConnected = false
|
||||
}
|
||||
|
||||
private func openSocket() {
|
||||
guard isActive else { return }
|
||||
task?.cancel()
|
||||
|
||||
var request = URLRequest(url: socketURL)
|
||||
if let authToken {
|
||||
request.setValue("Bearer \(authToken)", forHTTPHeaderField: "Authorization")
|
||||
}
|
||||
let session = URLSession(configuration: .default)
|
||||
let task = session.webSocketTask(with: request)
|
||||
self.task = task
|
||||
task.resume()
|
||||
receiveLoop()
|
||||
}
|
||||
|
||||
// MARK: - Receiving
|
||||
|
||||
private func receiveLoop() {
|
||||
task?.receive { [weak self] result in
|
||||
guard let self else { return }
|
||||
switch result {
|
||||
case .success(let message):
|
||||
self.isConnected = true
|
||||
self.reconnectAttempts = 0
|
||||
switch message {
|
||||
case .data(let data):
|
||||
self.handle(data)
|
||||
case .string(let string):
|
||||
self.handle(Data(string.utf8))
|
||||
@unknown default:
|
||||
break
|
||||
}
|
||||
self.receiveLoop()
|
||||
case .failure:
|
||||
self.handleDisconnect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handleDisconnect() {
|
||||
isConnected = false
|
||||
guard isActive else { return }
|
||||
|
||||
// Exponential backoff reconnect.
|
||||
let delay = min(pow(2.0, Double(reconnectAttempts)), 30.0)
|
||||
reconnectAttempts += 1
|
||||
reconnectTask = Task { [weak self] in
|
||||
try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
|
||||
guard !Task.isCancelled else { return }
|
||||
self?.openSocket()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Event parsing
|
||||
|
||||
private func handle(_ data: Data) {
|
||||
struct Event: Decodable {
|
||||
let type: String
|
||||
let remoteToken: String?
|
||||
let message: ChatMessage?
|
||||
}
|
||||
guard let event = try? JSONDecoder().decode(Event.self, from: data) else { return }
|
||||
|
||||
switch event.type {
|
||||
case "mutual":
|
||||
if let token = event.remoteToken {
|
||||
mutualEncounter.send(token)
|
||||
}
|
||||
case "message":
|
||||
if let message = event.message {
|
||||
incomingMessage.send(message)
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user