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.
This commit is contained in:
snowspeeder committed 2026-08-29 17:18:38 -04:00
1 parent 297fbaa19c
commit 378b8cb36f
7 files changed
+263

No files matched your search

+1
View File
@@ -1,4 +1,5 @@
schemaVersion: 1
enabledWorkflows: ["simulator", "ui-automation"]
sessionDefaults:
projectPath: /Users/shane/dev/voice-ios/Voice.xcodeproj
scheme: Voice_iOS
@@ -0,0 +1,86 @@
import SwiftUI
struct ConnectionTestView: View {
@ObservedObject var settings: SettingsStore
@State private var isBusy = false
@State private var result: Result<TokenResponse, Error>?
@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())
}
@@ -18,6 +18,11 @@ struct SettingsView: View {
Section("Interaction") {
Toggle("Text mode", isOn: $settings.textMode)
}
Section {
NavigationLink("Connection test") {
ConnectionTestView(settings: settings)
}
}
}
.navigationTitle("Settings")
}
+42
View File
@@ -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<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)) }
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 ?? "" }
}
+36
View File
@@ -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 2>/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()
}
}
+69
View File
@@ -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
}
}
}
+24
View File
@@ -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 = "<group>"; };
48F76881B4AE51DEE74511CC /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
6D0D153557BB1C756ED04C61 /* SettingsStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsStore.swift; sourceTree = "<group>"; };
9681E7410B684D2D38752A70 /* TokenClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TokenClient.swift; sourceTree = "<group>"; };
AC4B9073EE8EAB51F8C55760 /* ConnectionTestView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectionTestView.swift; sourceTree = "<group>"; };
D9B40076934C28BA4B92D808 /* TLSPinning.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TLSPinning.swift; sourceTree = "<group>"; };
F5683993A1DE677121FFA1F3 /* ServerConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerConfig.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@@ -37,11 +45,22 @@
0DF0591DF1D767F89099F62C /* Settings */ = {
isa = PBXGroup;
children = (
AC4B9073EE8EAB51F8C55760 /* ConnectionTestView.swift */,
48F76881B4AE51DEE74511CC /* SettingsView.swift */,
);
path = Settings;
sourceTree = "<group>";
};
6B2AB34274D9B37E77178C6C /* Networking */ = {
isa = PBXGroup;
children = (
F5683993A1DE677121FFA1F3 /* ServerConfig.swift */,
D9B40076934C28BA4B92D808 /* TLSPinning.swift */,
9681E7410B684D2D38752A70 /* TokenClient.swift */,
);
path = Networking;
sourceTree = "<group>";
};
6F99423E59017FED59B4D71D /* Products */ = {
isa = PBXGroup;
children = (
@@ -89,6 +108,7 @@
children = (
CE49A38E40AAF659A9281B85 /* App */,
8DA8BEF2EE3E0F4181A94EB9 /* Features */,
6B2AB34274D9B37E77178C6C /* Networking */,
);
path = Sources;
sourceTree = "<group>";
@@ -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;