From 378b8cb36f0fe4a3ff83001ab25fedb5d0a33c4a Mon Sep 17 00:00:00 2001 From: Shane Maynard Date: Sat, 29 Aug 2026 17:18:38 -0400 Subject: [PATCH] feat: server config, token client, TLS pinning, connection test (Phase 1) ServerConfig parses https base URL and derives wss livekit URL; TokenClient POSTs /token with clean 503/offline error states; TLSPinning holds optional cert SHA-384 pins (empty = system trust); Settings gains a Connection test screen proving the endpoint works. --- .xcodebuildmcp/config.yaml | 1 + .../Settings/ConnectionTestView.swift | 86 +++++++++++++++++++ Sources/Features/Settings/SettingsView.swift | 5 ++ Sources/Networking/ServerConfig.swift | 42 +++++++++ Sources/Networking/TLSPinning.swift | 36 ++++++++ Sources/Networking/TokenClient.swift | 69 +++++++++++++++ Voice.xcodeproj/project.pbxproj | 24 ++++++ 7 files changed, 263 insertions(+) create mode 100644 Sources/Features/Settings/ConnectionTestView.swift create mode 100644 Sources/Networking/ServerConfig.swift create mode 100644 Sources/Networking/TLSPinning.swift create mode 100644 Sources/Networking/TokenClient.swift diff --git a/.xcodebuildmcp/config.yaml b/.xcodebuildmcp/config.yaml index 95e36f9..841dceb 100644 --- a/.xcodebuildmcp/config.yaml +++ b/.xcodebuildmcp/config.yaml @@ -1,4 +1,5 @@ schemaVersion: 1 +enabledWorkflows: ["simulator", "ui-automation"] sessionDefaults: projectPath: /Users/shane/dev/voice-ios/Voice.xcodeproj scheme: Voice_iOS diff --git a/Sources/Features/Settings/ConnectionTestView.swift b/Sources/Features/Settings/ConnectionTestView.swift new file mode 100644 index 0000000..fe8abfa --- /dev/null +++ b/Sources/Features/Settings/ConnectionTestView.swift @@ -0,0 +1,86 @@ +import SwiftUI + +struct ConnectionTestView: View { + @ObservedObject var settings: SettingsStore + @State private var isBusy = false + @State private var result: Result? + @State private var urlError: String? + + var body: some View { + Form { + Section("Server") { + LabeledContent("URL") { + Text(settings.trimmedServerURL) + .font(.footnote.monospaced()) + .foregroundStyle(.secondary) + } + if let urlError { + Label(urlError, systemImage: "xmark.octagon") + .foregroundStyle(.red) + } + } + Section { + Button(action: runTest) { + HStack { + Spacer() + if isBusy { + ProgressView() + } else { + Text("Get token") + } + Spacer() + } + } + .disabled(isBusy || settings.trimmedServerURL.isEmpty) + } + if let result { + switch result { + case .success(let response): + Section("Success") { + LabeledContent("Room") { + Text(response.room) + .font(.footnote.monospaced()) + } + LabeledContent("User") { + Text(response.user.displayName ?? response.user.id) + } + Label("Token endpoint and TLS are working.", systemImage: "checkmark.circle") + .foregroundStyle(.green) + } + case .failure(let error): + Section("Error") { + Label(error.localizedDescription, systemImage: "xmark.octagon") + .foregroundStyle(.red) + .font(.footnote) + } + } + } + } + .navigationTitle("Connection test") + } + + private func runTest() { + result = nil + urlError = nil + switch ServerConfig.parse(settings.trimmedServerURL) { + case .success(let config): + isBusy = true + Task { + let client = TokenClient(config: config) + do { + let response = try await client.fetchToken() + result = .success(response) + } catch { + result = .failure(error) + } + isBusy = false + } + case .failure(let error): + urlError = error.localizedDescription + } + } +} + +#Preview { + ConnectionTestView(settings: SettingsStore()) +} diff --git a/Sources/Features/Settings/SettingsView.swift b/Sources/Features/Settings/SettingsView.swift index 1e0de5f..a2ff8fb 100644 --- a/Sources/Features/Settings/SettingsView.swift +++ b/Sources/Features/Settings/SettingsView.swift @@ -18,6 +18,11 @@ struct SettingsView: View { Section("Interaction") { Toggle("Text mode", isOn: $settings.textMode) } + Section { + NavigationLink("Connection test") { + ConnectionTestView(settings: settings) + } + } } .navigationTitle("Settings") } diff --git a/Sources/Networking/ServerConfig.swift b/Sources/Networking/ServerConfig.swift new file mode 100644 index 0000000..89ee797 --- /dev/null +++ b/Sources/Networking/ServerConfig.swift @@ -0,0 +1,42 @@ +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 { + 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)) } + var c = components + c.path = "" + c.fragment = nil + guard let base = c.url else { return .failure(.invalidURL(raw)) } + let portSuffix = components.port.map { ":\($0)" } ?? "" + let livekit = URL(string: "wss://\(host)\(portSuffix)/livekit")! + return .success(ServerConfig(baseURL: base, livekitURL: livekit)) + } + + var host: String { baseURL.host ?? "" } +} diff --git a/Sources/Networking/TLSPinning.swift b/Sources/Networking/TLSPinning.swift new file mode 100644 index 0000000..f79313a --- /dev/null +++ b/Sources/Networking/TLSPinning.swift @@ -0,0 +1,36 @@ +import Foundation +import CommonCrypto +import Security + +/// ATS exception for the LAN host is declared in Info.plist. If the deployment +/// uses a self-signed certificate, pin its SHA-384 here (extract with: +/// `openssl s_client -connect 192.168.86.2:9178 /dev/null | openssl x509 -outform der | shasum -a 384`). +/// While empty, standard system trust is used (works for the current deployment). +enum TLSPinning { + static let pinnedCertSHA384: [String] = [] + + static func validate(_ trust: SecTrust) -> Bool { + guard !pinnedCertSHA384.isEmpty else { return true } + var error: CFError? + guard SecTrustEvaluateWithError(trust, &error) else { return false } + let count = SecTrustGetCertificateCount(trust) + guard count > 0 else { return false } + guard let chain = SecTrustCopyCertificateChain(trust) as? [SecCertificate] else { + return false + } + for cert in chain { + guard let derData = SecCertificateCopyData(cert) as Data? else { continue } + if pinnedCertSHA384.contains(sha384Hex(derData)) { return true } + } + return false + } + + private static func sha384Hex(_ data: Data) -> String { + var hash = [UInt8](repeating: 0, count: Int(CC_SHA384_DIGEST_LENGTH)) + data.withUnsafeBytes { buffer in + guard let base = buffer.baseAddress else { return } + CC_SHA384(base, CC_LONG(data.count), &hash) + } + return hash.map { String(format: "%02x", $0) }.joined() + } +} diff --git a/Sources/Networking/TokenClient.swift b/Sources/Networking/TokenClient.swift new file mode 100644 index 0000000..17693bf --- /dev/null +++ b/Sources/Networking/TokenClient.swift @@ -0,0 +1,69 @@ +import Foundation + +struct TokenResponse: Decodable, Equatable { + let token: String + let room: String + let user: User + + 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 + + init(config: ServerConfig, session: URLSession = .shared) { + self.config = config + self.session = session + } + + func fetchToken() async throws -> TokenResponse { + var request = URLRequest(url: config.baseURL.appendingPathComponent("token")) + request.httpMethod = "POST" + 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 { + let detail = String(data: data, encoding: .utf8) ?? "" + throw TokenError.httpStatus(http.statusCode, detail) + } + do { + return try JSONDecoder().decode(TokenResponse.self, from: data) + } catch { + throw TokenError.decodeFailed + } + } +} diff --git a/Voice.xcodeproj/project.pbxproj b/Voice.xcodeproj/project.pbxproj index 0713e6e..84d21a7 100644 --- a/Voice.xcodeproj/project.pbxproj +++ b/Voice.xcodeproj/project.pbxproj @@ -8,9 +8,13 @@ /* Begin PBXBuildFile section */ 1ECD63F10A8EAF33FD2A5B62 /* VoiceApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FED8976B60FBC13BC980FCB /* VoiceApp.swift */; }; + 302BEC55CA9A10E49C307977 /* ServerConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5683993A1DE677121FFA1F3 /* ServerConfig.swift */; }; 36DF44D9A7896F18A064847D /* ConversationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F3FA9F420065D2127B0AEC0 /* ConversationView.swift */; }; + 720C26EC163AE46713B02DD8 /* TLSPinning.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9B40076934C28BA4B92D808 /* TLSPinning.swift */; }; + 8DE1363CC0119B5AF9E4E117 /* ConnectionTestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC4B9073EE8EAB51F8C55760 /* ConnectionTestView.swift */; }; 98BA1A0F92DB7B98A8BA7814 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 48F76881B4AE51DEE74511CC /* SettingsView.swift */; }; A94F6C509E3FE215115B2C58 /* SettingsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D0D153557BB1C756ED04C61 /* SettingsStore.swift */; }; + D18AD347DFF72AB6361165BA /* TokenClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9681E7410B684D2D38752A70 /* TokenClient.swift */; }; D9A0059E342955A06A021D6C /* LiveKit in Frameworks */ = {isa = PBXBuildFile; productRef = A6BAD55715F383C0DB5F306F /* LiveKit */; }; /* End PBXBuildFile section */ @@ -20,6 +24,10 @@ 3F3FA9F420065D2127B0AEC0 /* ConversationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationView.swift; sourceTree = ""; }; 48F76881B4AE51DEE74511CC /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; 6D0D153557BB1C756ED04C61 /* SettingsStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsStore.swift; sourceTree = ""; }; + 9681E7410B684D2D38752A70 /* TokenClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TokenClient.swift; sourceTree = ""; }; + AC4B9073EE8EAB51F8C55760 /* ConnectionTestView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectionTestView.swift; sourceTree = ""; }; + D9B40076934C28BA4B92D808 /* TLSPinning.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TLSPinning.swift; sourceTree = ""; }; + F5683993A1DE677121FFA1F3 /* ServerConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerConfig.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -37,11 +45,22 @@ 0DF0591DF1D767F89099F62C /* Settings */ = { isa = PBXGroup; children = ( + AC4B9073EE8EAB51F8C55760 /* ConnectionTestView.swift */, 48F76881B4AE51DEE74511CC /* SettingsView.swift */, ); path = Settings; sourceTree = ""; }; + 6B2AB34274D9B37E77178C6C /* Networking */ = { + isa = PBXGroup; + children = ( + F5683993A1DE677121FFA1F3 /* ServerConfig.swift */, + D9B40076934C28BA4B92D808 /* TLSPinning.swift */, + 9681E7410B684D2D38752A70 /* TokenClient.swift */, + ); + path = Networking; + sourceTree = ""; + }; 6F99423E59017FED59B4D71D /* Products */ = { isa = PBXGroup; children = ( @@ -89,6 +108,7 @@ children = ( CE49A38E40AAF659A9281B85 /* App */, 8DA8BEF2EE3E0F4181A94EB9 /* Features */, + 6B2AB34274D9B37E77178C6C /* Networking */, ); path = Sources; sourceTree = ""; @@ -156,9 +176,13 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 8DE1363CC0119B5AF9E4E117 /* ConnectionTestView.swift in Sources */, 36DF44D9A7896F18A064847D /* ConversationView.swift in Sources */, + 302BEC55CA9A10E49C307977 /* ServerConfig.swift in Sources */, A94F6C509E3FE215115B2C58 /* SettingsStore.swift in Sources */, 98BA1A0F92DB7B98A8BA7814 /* SettingsView.swift in Sources */, + 720C26EC163AE46713B02DD8 /* TLSPinning.swift in Sources */, + D18AD347DFF72AB6361165BA /* TokenClient.swift in Sources */, 1ECD63F10A8EAF33FD2A5B62 /* VoiceApp.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0;