feat: consolidate iOS with API and web preserving history
Rebuild & Deploy / rebuild (push) Failing after 23s

This commit is contained in:
snowspeeder committed 2026-10-04 07:23:49 -04:00
commit d735ee72fa
102 files changed
+13077 -258

No files matched your search

+100
View File
@@ -0,0 +1,100 @@
import Foundation
struct AuthUser: Codable, Equatable {
let id: String
let username: String
let displayName: String?
let role: String
let status: String
enum CodingKeys: String, CodingKey {
case id, username, role, status
case displayName = "display_name"
}
}
struct AuthLoginResponse: Decodable {
let token: String
let user: AuthUser
let expiresAt: Double
enum CodingKeys: String, CodingKey {
case token, user
case expiresAt = "expires_at"
}
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
token = try c.decode(String.self, forKey: .token)
user = try c.decode(AuthUser.self, forKey: .user)
if let seconds = try? c.decode(Double.self, forKey: .expiresAt) { expiresAt = seconds }
else {
let raw = try c.decode(String.self, forKey: .expiresAt)
guard let date = ISO8601DateFormatter().date(from: raw) else { throw AuthError.invalidResponse }
expiresAt = date.timeIntervalSince1970
}
}
}
enum ConnectionFailure {
static func message(_ error: Error, server: URL) -> String {
let ns = error as NSError
let certificateCodes: Set<Int> = [-1200, -1201, -1202, -1203, -1204, -1205, -1206]
if ns.domain == NSURLErrorDomain && certificateCodes.contains(ns.code) {
return "The certificate for \(server.host ?? "this server") is not trusted. For your private Hope server, install the verified certificate profile in Safari, then enable it in Settings → General → About → Certificate Trust Settings. Do not continue if the server address is unfamiliar."
}
return error.localizedDescription
}
}
enum AuthError: LocalizedError {
case status(Int)
case invalidResponse
var errorDescription: String? {
switch self {
case .status(401): return "Your sign-in expired or the username and password were not accepted. Sign in again."
case .status(403): return "This account is not authorized. Contact your server administrator."
case .status: return "The server could not complete sign-in. Try again later."
case .invalidResponse: return "The server returned an unexpected authentication response."
}
}
}
final class AuthClient {
let config: ServerConfig
private let session: URLSession
init(config: ServerConfig, session: URLSession? = nil) {
self.config = config
self.session = session ?? TLSPinning.session()
}
private func request(_ path: String, method: String, token: String? = nil, body: Data? = nil) async throws -> Data {
var request = URLRequest(url: config.baseURL.appendingPathComponent(path))
request.httpMethod = method
request.timeoutInterval = 10
request.httpBody = body
if body != nil { request.setValue("application/json", forHTTPHeaderField: "Content-Type") }
if let token { request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") }
let (data, response) = try await session.data(for: request)
guard let http = response as? HTTPURLResponse else { throw AuthError.invalidResponse }
guard (200..<300).contains(http.statusCode) else { throw AuthError.status(http.statusCode) }
return data
}
func login(username: String, password: String) async throws -> AuthLoginResponse {
let body = try JSONSerialization.data(withJSONObject: ["username": username, "password": password])
return try JSONDecoder().decode(AuthLoginResponse.self, from: await request("api/auth/login", method: "POST", body: body))
}
func me(token: String) async throws -> AuthUser {
struct Response: Decodable { let user: AuthUser }
return try JSONDecoder().decode(Response.self, from: await request("api/auth/me", method: "GET", token: token)).user
}
func voices(token: String) async throws -> VoiceCatalogResponse {
#if DEBUG
if ProcessInfo.processInfo.arguments.contains("--ui-testing"),
!ProcessInfo.processInfo.arguments.contains("--live-testing") {
return VoiceCatalogResponse(defaultVoice: Voices.defaultID, voices: Voices.all,
catalogAvailable: false, message: "Offline test voice catalog.")
}
#endif
guard !token.isEmpty else { throw AuthError.status(401) }
return try JSONDecoder().decode(VoiceCatalogResponse.self, from: await request("voices", method: "GET", token: token))
}
func logout(token: String) async throws {
_ = try await request("api/auth/logout", method: "POST", token: token)
}
}
@@ -0,0 +1,43 @@
import Foundation
import Security
protocol TokenStore {
func token(origin: String) throws -> String?
func save(_ token: String?, origin: String) throws
}
struct KeychainTokenStore: TokenStore {
private func query(_ origin: String) -> [String: Any] {
[kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: "dev.shane.voice.auth",
kSecAttrAccount as String: origin]
}
func token(origin: String) throws -> String? {
var q = query(origin)
q[kSecReturnData as String] = true
q[kSecMatchLimit as String] = kSecMatchLimitOne
var result: CFTypeRef?
let status = SecItemCopyMatching(q as CFDictionary, &result)
if status == errSecItemNotFound { return nil }
guard status == errSecSuccess, let data = result as? Data else { throw StoreError.failed }
return String(data: data, encoding: .utf8)
}
func save(_ token: String?, origin: String) throws {
let q = query(origin)
let deletion = SecItemDelete(q as CFDictionary)
guard deletion == errSecSuccess || deletion == errSecItemNotFound else { throw StoreError.failed }
guard let token else { return }
var item = q
item[kSecValueData as String] = Data(token.utf8)
item[kSecAttrAccessible as String] = kSecAttrAccessibleWhenUnlockedThisDeviceOnly
guard SecItemAdd(item as CFDictionary, nil) == errSecSuccess else { throw StoreError.failed }
}
enum StoreError: LocalizedError {
case failed
var errorDescription: String? { "Could not securely save the sign-in on this iPhone." }
}
}
final class MemoryTokenStore: TokenStore {
private var values: [String: String] = [:]
func token(origin: String) throws -> String? { values[origin] }
func save(_ token: String?, origin: String) throws { values[origin] = token }
}
+57
View File
@@ -0,0 +1,57 @@
import Foundation
struct ServerConfig: Equatable {
let baseURL: URL
let livekitURL: URL
enum ConfigError: LocalizedError, Equatable {
case notHTTPS(String)
case invalidURL(String)
var errorDescription: String? {
switch self {
case .notHTTPS(let raw):
return "Server URL must use https: \(raw)"
case .invalidURL(let raw):
return "Invalid server URL: \(raw)"
}
}
}
static func parse(_ raw: String) -> Result<ServerConfig, ConfigError> {
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return .failure(.invalidURL(raw)) }
var candidate = trimmed
if !candidate.contains("://") { candidate = "https://" + candidate }
guard let components = URLComponents(string: candidate),
let scheme = components.scheme?.lowercased(),
let host = components.host, !host.isEmpty else {
return .failure(.invalidURL(raw))
}
if scheme != "https" { return .failure(.notHTTPS(raw)) }
guard components.user == nil, components.password == nil,
components.query == nil, components.fragment == nil else {
return .failure(.invalidURL(raw))
}
var c = components
c.scheme = "https"
c.host = host.lowercased()
if c.port == 443 { c.port = nil }
c.path = ""
guard let base = c.url else { return .failure(.invalidURL(raw)) }
c.scheme = "wss"
c.path = "/livekit"
guard let livekit = c.url else { return .failure(.invalidURL(raw)) }
return .success(ServerConfig(baseURL: base, livekitURL: livekit))
}
static func isPrivateIP(_ host: String) -> Bool {
let parts = host.split(separator: ".").compactMap { UInt8($0) }
guard parts.count == 4 else { return false }
return parts[0] == 10
|| (parts[0] == 172 && (16...31).contains(parts[1]))
|| (parts[0] == 192 && parts[1] == 168)
}
var host: String { baseURL.host ?? "" }
}
+72
View File
@@ -0,0 +1,72 @@
import Foundation
import CommonCrypto
import Security
/// Pins the self-signed cert of the LAN deployment (CN=192.168.86.2, regenerated
/// at container start — re-export the hash after server cert rotation, see README).
/// Extract with:
/// openssl s_client -connect 192.168.86.2:9178 </dev/null 2>/dev/null \
/// | openssl x509 -outform der | openssl dgst -sha256
enum TLSPinning {
static let pinnedCertSHA256Hex: Set<String> = [
"07907abde3bb9c685265909927627d9ea4705d7e7b27056eefe06e10b192af46"
]
static let host = "192.168.86.2"
static func session() -> URLSession {
let config = URLSessionConfiguration.ephemeral
config.urlCache = nil
config.httpCookieStorage = nil
config.urlCredentialStorage = nil
config.timeoutIntervalForRequest = 15
return URLSession(configuration: config, delegate: PinnedTrustDelegate(), delegateQueue: nil)
}
static func sha256Hex(_ data: Data) -> String {
var hash = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH))
data.withUnsafeBytes { buffer in
guard let base = buffer.baseAddress else { return }
CC_SHA256(base, CC_LONG(data.count), &hash)
}
return hash.map { String(format: "%02x", $0) }.joined()
}
static func matchesPinnedCert(_ trust: SecTrust) -> Bool {
guard let chain = SecTrustCopyCertificateChain(trust) as? [SecCertificate],
let leaf = chain.first,
let der = SecCertificateCopyData(leaf) as Data? else { return false }
return pinnedCertSHA256Hex.contains(sha256Hex(der))
}
}
final class PinnedTrustDelegate: NSObject, URLSessionDelegate, URLSessionTaskDelegate {
func urlSession(_ session: URLSession, task: URLSessionTask, willPerformHTTPRedirection response: HTTPURLResponse, newRequest request: URLRequest, completionHandler: @escaping (URLRequest?) -> Void) {
// Authentication/password-bearing requests must never be redirected to
// another endpoint or origin. Correct the configured server instead.
completionHandler(nil)
}
func urlSession(_ session: URLSession, didReceive challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {
guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust,
let trust = challenge.protectionSpace.serverTrust else {
completionHandler(.performDefaultHandling, nil)
return
}
// Apply the fixed self-signed pin only to the pinned LAN deployment
// host. Other explicitly configured HTTPS hosts use normal system
// trust evaluation (Finding 12). This allows public deployments and
// other LAN hosts with valid certificates while never weakening trust
// for the default self-signed host.
let host = challenge.protectionSpace.host
if host == TLSPinning.host {
if TLSPinning.matchesPinnedCert(trust) {
completionHandler(.useCredential, URLCredential(trust: trust))
} else {
completionHandler(.cancelAuthenticationChallenge, nil)
}
} else {
// Non-pinned host: let the system evaluate the certificate chain.
completionHandler(.performDefaultHandling, nil)
}
}
}
+93
View File
@@ -0,0 +1,93 @@
import Foundation
struct TokenResponse: Decodable, Equatable {
let token: String
let room: String
let user: User
let conversationID: String?
enum CodingKeys: String, CodingKey {
case token, room, user
case conversationID = "conversation_id"
}
struct User: Decodable, Equatable {
let id: String
let displayName: String?
enum CodingKeys: String, CodingKey {
case id
case displayName = "display_name"
}
}
}
enum TokenError: LocalizedError {
case invalidServerURL(String)
case httpStatus(Int, String)
case decodeFailed
case network(Error)
var errorDescription: String? {
switch self {
case .invalidServerURL(let raw):
return "Invalid server URL: \(raw)"
case .httpStatus(let code, let detail):
if code == 503 {
return "Server is starting up or the user file is missing (HTTP 503). Check the deployment on the server. \(detail)"
}
return "Token request failed with HTTP \(code). \(detail)"
case .decodeFailed:
return "Unexpected response from the token endpoint."
case .network(let error):
return "Could not reach the server: \(error.localizedDescription)"
}
}
}
final class TokenClient {
let config: ServerConfig
private let session: URLSession
var onUnauthorized: (() async -> Void)?
let bearerToken: String
var conversationID: String?
init(config: ServerConfig, bearerToken: String = "", session: URLSession? = nil) {
self.config = config
self.session = session ?? TLSPinning.session()
self.bearerToken = bearerToken
}
func cancel() { session.getAllTasks { $0.forEach { $0.cancel() } } }
func fetchToken() async throws -> TokenResponse {
#if DEBUG
if ProcessInfo.processInfo.arguments.contains("--ui-testing"),
!ProcessInfo.processInfo.arguments.contains("--live-testing") {
throw URLError(.cannotConnectToHost) // offline fixtures never contact any configured server
}
#endif
guard !bearerToken.isEmpty else { throw AuthError.status(401) }
var request = URLRequest(url: config.baseURL.appendingPathComponent("token"))
request.httpMethod = "POST"
if let conversationID {
request.httpBody = try JSONSerialization.data(withJSONObject: ["conversation_id": conversationID])
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
}
request.setValue("Bearer \(bearerToken)", forHTTPHeaderField: "Authorization")
request.timeoutInterval = 10
let (data, response) = try await session.data(for: request)
guard let http = response as? HTTPURLResponse else {
throw TokenError.decodeFailed
}
guard (200..<300).contains(http.statusCode) else {
if http.statusCode == 401 || http.statusCode == 403 { await onUnauthorized?() }
let detail = ""
throw TokenError.httpStatus(http.statusCode, detail)
}
do {
return try JSONDecoder().decode(TokenResponse.self, from: data)
} catch {
throw TokenError.decodeFailed
}
}
}