Archived
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
|
schemaVersion: 1
|
||||||
|
enabledWorkflows: ["simulator", "ui-automation"]
|
||||||
sessionDefaults:
|
sessionDefaults:
|
||||||
projectPath: /Users/shane/dev/voice-ios/Voice.xcodeproj
|
projectPath: /Users/shane/dev/voice-ios/Voice.xcodeproj
|
||||||
scheme: Voice_iOS
|
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") {
|
Section("Interaction") {
|
||||||
Toggle("Text mode", isOn: $settings.textMode)
|
Toggle("Text mode", isOn: $settings.textMode)
|
||||||
}
|
}
|
||||||
|
Section {
|
||||||
|
NavigationLink("Connection test") {
|
||||||
|
ConnectionTestView(settings: settings)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle("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 */
|
/* Begin PBXBuildFile section */
|
||||||
1ECD63F10A8EAF33FD2A5B62 /* VoiceApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FED8976B60FBC13BC980FCB /* VoiceApp.swift */; };
|
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 */; };
|
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 */; };
|
98BA1A0F92DB7B98A8BA7814 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 48F76881B4AE51DEE74511CC /* SettingsView.swift */; };
|
||||||
A94F6C509E3FE215115B2C58 /* SettingsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D0D153557BB1C756ED04C61 /* SettingsStore.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 */; };
|
D9A0059E342955A06A021D6C /* LiveKit in Frameworks */ = {isa = PBXBuildFile; productRef = A6BAD55715F383C0DB5F306F /* LiveKit */; };
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
@@ -20,6 +24,10 @@
|
|||||||
3F3FA9F420065D2127B0AEC0 /* ConversationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationView.swift; sourceTree = "<group>"; };
|
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>"; };
|
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>"; };
|
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 */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
/* Begin PBXFrameworksBuildPhase section */
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
@@ -37,11 +45,22 @@
|
|||||||
0DF0591DF1D767F89099F62C /* Settings */ = {
|
0DF0591DF1D767F89099F62C /* Settings */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
AC4B9073EE8EAB51F8C55760 /* ConnectionTestView.swift */,
|
||||||
48F76881B4AE51DEE74511CC /* SettingsView.swift */,
|
48F76881B4AE51DEE74511CC /* SettingsView.swift */,
|
||||||
);
|
);
|
||||||
path = Settings;
|
path = Settings;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
6B2AB34274D9B37E77178C6C /* Networking */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
F5683993A1DE677121FFA1F3 /* ServerConfig.swift */,
|
||||||
|
D9B40076934C28BA4B92D808 /* TLSPinning.swift */,
|
||||||
|
9681E7410B684D2D38752A70 /* TokenClient.swift */,
|
||||||
|
);
|
||||||
|
path = Networking;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
6F99423E59017FED59B4D71D /* Products */ = {
|
6F99423E59017FED59B4D71D /* Products */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
@@ -89,6 +108,7 @@
|
|||||||
children = (
|
children = (
|
||||||
CE49A38E40AAF659A9281B85 /* App */,
|
CE49A38E40AAF659A9281B85 /* App */,
|
||||||
8DA8BEF2EE3E0F4181A94EB9 /* Features */,
|
8DA8BEF2EE3E0F4181A94EB9 /* Features */,
|
||||||
|
6B2AB34274D9B37E77178C6C /* Networking */,
|
||||||
);
|
);
|
||||||
path = Sources;
|
path = Sources;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -156,9 +176,13 @@
|
|||||||
isa = PBXSourcesBuildPhase;
|
isa = PBXSourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
|
8DE1363CC0119B5AF9E4E117 /* ConnectionTestView.swift in Sources */,
|
||||||
36DF44D9A7896F18A064847D /* ConversationView.swift in Sources */,
|
36DF44D9A7896F18A064847D /* ConversationView.swift in Sources */,
|
||||||
|
302BEC55CA9A10E49C307977 /* ServerConfig.swift in Sources */,
|
||||||
A94F6C509E3FE215115B2C58 /* SettingsStore.swift in Sources */,
|
A94F6C509E3FE215115B2C58 /* SettingsStore.swift in Sources */,
|
||||||
98BA1A0F92DB7B98A8BA7814 /* SettingsView.swift in Sources */,
|
98BA1A0F92DB7B98A8BA7814 /* SettingsView.swift in Sources */,
|
||||||
|
720C26EC163AE46713B02DD8 /* TLSPinning.swift in Sources */,
|
||||||
|
D18AD347DFF72AB6361165BA /* TokenClient.swift in Sources */,
|
||||||
1ECD63F10A8EAF33FD2A5B62 /* VoiceApp.swift in Sources */,
|
1ECD63F10A8EAF33FD2A5B62 /* VoiceApp.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
|||||||
Reference in new issue
Block a user