initial commit: Proximity iOS app proposal, full Swift code scaffold, reference backend, testing docs, generated Xcode project

This commit is contained in:
2026-08-12 00:22:01 +00:00
commit 9d1b0f8dd9
52 changed files with 6271 additions and 0 deletions

View 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)
}
}

View 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)
}
}

View 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
}
}
}