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:
1 parent
297fbaa19c
commit
378b8cb36f
7 files changed
+263
No files matched your search
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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 ?? "" }
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user