feat: LiveKit voice session, control channel, transcripts (Phase 2)
- VoiceSession: token->connect with fresh-room retry on timeout, agent discovery, agent_ready wait, mic enable/mute, text send over lk.chat - ControlChannel: outbound set_voice/set_model/get_models/set_mode/ media_state; inbound agent_ready/models/model_changed/task_event - TranscriptHandler: transcription segments + transcript topic finals, partial/final dedupe per role - ConversationView/OrbView: status orb, mic/text-mode toggles, composer - ServerConfig: plain ws:// signaling for private IPs (self-signed TLS not injectable into SDK WebSocket); Info.plist local networking
This commit is contained in:
1 parent
378b8cb36f
commit
4986b6d7dc
14 files changed
+900
-50
No files matched your search
@@ -33,10 +33,25 @@ struct ServerConfig: Equatable {
|
||||
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")!
|
||||
// LAN deployment: LiveKit is reachable directly on :7880 (plain ws).
|
||||
// Real domains use wss via same origin. The SDK derives /rtc from the base.
|
||||
let livekit: URL
|
||||
if Self.isPrivateIP(host) {
|
||||
livekit = URL(string: "ws://\(host):7880")!
|
||||
} else {
|
||||
let portSuffix = components.port.map { ":\($0)" } ?? ""
|
||||
livekit = URL(string: "wss://\(host)\(portSuffix)/livekit")!
|
||||
}
|
||||
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 ?? "" }
|
||||
}
|
||||
@@ -2,35 +2,52 @@ 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).
|
||||
/// 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 pinnedCertSHA384: [String] = []
|
||||
static let pinnedCertSHA256Hex: Set<String> = [
|
||||
"07907abde3bb9c685265909927627d9ea4705d7e7b27056eefe06e10b192af46"
|
||||
]
|
||||
|
||||
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
|
||||
static let host = "192.168.86.2"
|
||||
|
||||
static func session() -> URLSession {
|
||||
let config = URLSessionConfiguration.default
|
||||
config.timeoutIntervalForRequest = 15
|
||||
return URLSession(configuration: config, delegate: PinnedTrustDelegate(), delegateQueue: nil)
|
||||
}
|
||||
|
||||
private static func sha384Hex(_ data: Data) -> String {
|
||||
var hash = [UInt8](repeating: 0, count: Int(CC_SHA384_DIGEST_LENGTH))
|
||||
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_SHA384(base, CC_LONG(data.count), &hash)
|
||||
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 {
|
||||
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
|
||||
}
|
||||
if TLSPinning.matchesPinnedCert(trust) {
|
||||
completionHandler(.useCredential, URLCredential(trust: trust))
|
||||
} else {
|
||||
completionHandler(.cancelAuthenticationChallenge, nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -43,9 +43,9 @@ final class TokenClient {
|
||||
let config: ServerConfig
|
||||
private let session: URLSession
|
||||
|
||||
init(config: ServerConfig, session: URLSession = .shared) {
|
||||
init(config: ServerConfig) {
|
||||
self.config = config
|
||||
self.session = session
|
||||
self.session = TLSPinning.session()
|
||||
}
|
||||
|
||||
func fetchToken() async throws -> TokenResponse {
|
||||
|
||||
Reference in new issue
Block a user