diff --git a/Sources/App/AuthStore.swift b/Sources/App/AuthStore.swift new file mode 100644 index 0000000..6ea56e3 --- /dev/null +++ b/Sources/App/AuthStore.swift @@ -0,0 +1,103 @@ +import Foundation + +@MainActor +final class AuthStore: ObservableObject { + @Published private(set) var user: AuthUser? + @Published private(set) var token: String? + @Published private(set) var isBusy = false + @Published private(set) var error: String? + var disconnectAccount: (() async -> Void)? + let config: ServerConfig + private let client: AuthClient + private let tokens: TokenStore + private var generation = 0 + private var operation: Task? + private var loginOperation: Task? + let fixture: Bool + + init(config: ServerConfig, client: AuthClient? = nil, tokens: TokenStore = KeychainTokenStore(), fixture: Bool = false) { + self.config = config + self.client = client ?? AuthClient(config: config) + self.tokens = tokens + self.fixture = fixture + if fixture { + user = AuthUser(id: "offline-fixture", username: "fixture", displayName: "Offline fixture", role: "user", status: "ready") + token = "offline-fixture-not-a-server-token" + } + } + func restore() async { + guard !fixture, !isBusy, user == nil else { return } + generation += 1 + let current = generation + isBusy = true + do { + guard let saved = try tokens.token(origin: config.baseURL.absoluteString) else { isBusy = false; return } + let task = Task { try await client.me(token: saved) } + operation = task + let verified = try await task.value + guard current == generation, !Task.isCancelled else { return } + guard ["ready", "provisioning"].contains(verified.status) else { throw AuthError.status(403) } + user = verified + token = saved + } catch { + guard current == generation else { return } + if let authError = error as? AuthError, + case .status(let status) = authError, status == 401 || status == 403 { + try? tokens.save(nil, origin: config.baseURL.absoluteString) + self.error = "Please sign in to continue." + } else { + // An outage is not revocation. Preserve the origin-scoped bearer + // for a subsequent launch/retry, but expose no unverified user UI. + self.error = "Could not verify your saved sign-in. Check the connection or sign in again." + } + } + guard current == generation else { return } + isBusy = false + operation = nil + } + func login(username: String, password: String) async { + guard !isBusy else { return } + if fixture { + user = AuthUser(id: "offline-" + username, username: username, displayName: username, role: "user", status: "ready") + token = "offline-fixture-not-a-server-token" + return + } + generation += 1 + let current = generation + isBusy = true + error = nil + do { + let task = Task { try await client.login(username: username, password: password) } + loginOperation = task + let response = try await task.value + guard current == generation, !Task.isCancelled else { return } + guard !response.token.isEmpty, ["ready", "provisioning"].contains(response.user.status), response.expiresAt > Date().timeIntervalSince1970 else { throw AuthError.status(403) } + try tokens.save(response.token, origin: config.baseURL.absoluteString) + token = response.token + user = response.user + } catch { + guard current == generation else { return } + self.error = error.localizedDescription + } + guard current == generation else { return } + isBusy = false + loginOperation = nil + } + func logout(disconnect: () async -> Void) async { + generation += 1 + loginOperation?.cancel() + loginOperation = nil + operation?.cancel() + operation = nil + let previous = token + token = nil // immediately disables bootstrap; account UI stays until transport closes + isBusy = true + await disconnect() + try? tokens.save(nil, origin: config.baseURL.absoluteString) + user = nil + disconnectAccount = nil + error = nil + if let previous, !fixture { try? await client.logout(token: previous) } + isBusy = false + } +} diff --git a/Sources/App/SettingsStore.swift b/Sources/App/SettingsStore.swift index 92b221b..8b99e62 100644 --- a/Sources/App/SettingsStore.swift +++ b/Sources/App/SettingsStore.swift @@ -8,9 +8,6 @@ final class SettingsStore: ObservableObject { @Published var voiceID: String { didSet { AppPreferences.store.set(voiceID, forKey: Keys.voiceID) } } - @Published var modelID: String? { - didSet { AppPreferences.store.set(modelID, forKey: Keys.modelID) } - } @Published var textMode: Bool { didSet { AppPreferences.store.set(textMode, forKey: Keys.textMode) } } @@ -18,7 +15,6 @@ final class SettingsStore: ObservableObject { private enum Keys { static let serverURL = "serverURL" static let voiceID = "voiceID" - static let modelID = "modelID" static let textMode = "textMode" } @@ -41,7 +37,6 @@ final class SettingsStore: ObservableObject { } let storedVoice = d.string(forKey: Keys.voiceID) voiceID = Voices.all.contains { $0.id == storedVoice } ? (storedVoice ?? "") : Voices.defaultID - modelID = d.string(forKey: Keys.modelID) textMode = d.bool(forKey: Keys.textMode) } @@ -64,7 +59,6 @@ enum AppPreferences { if ProcessInfo.processInfo.arguments.contains("--live-testing") { defaults.set(SettingsStore.defaultServerURL, forKey: "serverURL") defaults.set(true, forKey: "textMode") - defaults.set("qwen2.5-vl-3b", forKey: "modelID") } return defaults } diff --git a/Sources/App/VoiceApp.swift b/Sources/App/VoiceApp.swift index fa2e68f..d70f220 100644 --- a/Sources/App/VoiceApp.swift +++ b/Sources/App/VoiceApp.swift @@ -6,12 +6,12 @@ struct VoiceApp: App { @StateObject private var settings = SettingsStore() init() { - LiveKitSDK.setLogLevel(.debug) + LiveKitSDK.setLogLevel(.error) } var body: some Scene { WindowGroup { - ConversationView(settings: settings) + AuthenticationRoot(settings: settings) .tint(Nearlight.rosewood) } } diff --git a/Sources/Features/Conversation/ConversationView.swift b/Sources/Features/Conversation/ConversationView.swift index 370e9d3..9b53dd2 100644 --- a/Sources/Features/Conversation/ConversationView.swift +++ b/Sources/Features/Conversation/ConversationView.swift @@ -4,26 +4,32 @@ import SwiftUI /// Owns one session across all tabs. Changing tabs never ends a conversation. struct ConversationView: View { @ObservedObject var settings: SettingsStore + @ObservedObject var auth: AuthStore + let user: AuthUser + let bearerToken: String @State private var session: VoiceSession @State private var activeServerURL: String @State private var pendingServerURL: String? @State private var isReplacingSession = false @State private var selectedTab = 0 - init(settings: SettingsStore) { + init(settings: SettingsStore, auth: AuthStore, user: AuthUser, bearerToken: String) { + self.auth = auth + self.user = user + self.bearerToken = bearerToken self.settings = settings let url = settings.trimmedServerURL _activeServerURL = State(initialValue: url) - _session = State(initialValue: Self.makeSession(url, settings: settings)) + _session = State(initialValue: Self.makeSession(url, settings: settings, bearerToken: bearerToken, userID: user.id)) } - private static func makeSession(_ url: String, settings: SettingsStore) -> VoiceSession { + private static func makeSession(_ url: String, settings: SettingsStore, bearerToken: String, userID: String) -> VoiceSession { guard case .success(let config) = ServerConfig.parse(url) else { preconditionFailure("Stored server URL failed validation; init sanitizes to a valid value.") } - let session = VoiceSession(config: config, tokenClient: TokenClient(config: config), settings: settings) + let session = VoiceSession(config: config, tokenClient: TokenClient(config: config, bearerToken: bearerToken), settings: settings, userID: userID) #if DEBUG - if ProcessInfo.processInfo.arguments.contains("--ui-fixtures") { + if userID == "offline-fixture", ProcessInfo.processInfo.arguments.contains("--ui-fixtures") { session.transcriptHandler.receiveDataTranscript(role: .user, text: "Help me plan a quiet weekend.") session.transcriptHandler.receiveDataTranscript(role: .agent, text: "Let’s leave some room to breathe. A walk, a good book, and time for your favorite music would be a lovely start.") session.taskStore.injectSampleEvents() @@ -45,7 +51,12 @@ struct ConversationView: View { TabView(selection: $selectedTab) { NavigationStack { ConversationContent(settings: settings, session: session) - .toolbar { settingsLink } + .toolbar { + settingsLink + ToolbarItem(placement: .topBarLeading) { + Button("Sign out") { Task { await auth.logout { await session.disconnect() } } } + } + } } .tabItem { Label("Talk", systemImage: "bubble.left.and.bubble.right") } .tag(0) @@ -57,7 +68,16 @@ struct ConversationView: View { .onChange(of: settings.textMode) { _, enabled in Task { await session.applyTextMode(enabled) } } - .onChange(of: settings.serverURL) { _, value in replaceServer(value) } + .onChange(of: settings.serverURL) { _, value in + if auth.fixture { replaceServer(value) } + } + .onAppear { + auth.disconnectAccount = { [weak session] in await session?.disconnect() } + session.onUnauthorized = { [weak auth, weak session] in + await auth?.logout { await session?.disconnect() } + } + } + .disabled(auth.isBusy || auth.token == nil) } @ToolbarContentBuilder private var settingsLink: some ToolbarContent { @@ -84,7 +104,7 @@ struct ConversationView: View { guard next != activeServerURL else { continue } await session.disconnect() activeServerURL = next - session = Self.makeSession(next, settings: settings) + session = Self.makeSession(next, settings: settings, bearerToken: bearerToken, userID: user.id) } isReplacingSession = false } @@ -167,6 +187,10 @@ private struct ConversationContent: View { if failureNotice != nil { failureBanner } } else { statusStrip + if let activity = session.assistantActivity { + Label(activity, systemImage: "clock").font(.callout) + .padding().accessibilityIdentifier("assistant.progress") + } if session.isReconnecting { reconnectBanner } if failureNotice != nil { failureBanner } TranscriptListView(events: transcripts.events, onClear: transcripts.clearAll) @@ -610,4 +634,4 @@ extension VoiceSession { } } -#Preview { ConversationView(settings: SettingsStore()) } +#Preview { AuthenticationRoot(settings: SettingsStore()) } diff --git a/Sources/Features/Conversation/LoginView.swift b/Sources/Features/Conversation/LoginView.swift new file mode 100644 index 0000000..ef41004 --- /dev/null +++ b/Sources/Features/Conversation/LoginView.swift @@ -0,0 +1,97 @@ +import SwiftUI + +struct LoginView: View { + @ObservedObject var auth: AuthStore + @ObservedObject var settings: SettingsStore + @State private var username = "" + @State private var password = "" + @State private var server = "" + @State private var serverError: String? + var body: some View { + NavigationStack { + Form { + Section { + Text("Sign in to Hope").font(.title2.bold()) + Text("Use the account provided by your server administrator. Hermes is managed by your server.") + .foregroundStyle(.secondary) + } + Section("Account") { + TextField("Username", text: $username).textContentType(.username) + .textInputAutocapitalization(.never).autocorrectionDisabled() + SecureField("Password", text: $password).textContentType(.password) + Button("Sign in") { + let suppliedPassword = password + password = "" + Task { await auth.login(username: username, password: suppliedPassword) } + } + .disabled(auth.isBusy || username.isEmpty || password.isEmpty) + if auth.isBusy { ProgressView("Signing in…") } + if let error = auth.error { Text(error).foregroundStyle(.red) } + } + Section("Server") { + TextField("HTTPS server address", text: $server).keyboardType(.URL) + .textInputAutocapitalization(.never).autocorrectionDisabled() + Button("Save server address") { + switch ServerConfig.parse(server) { + case .success(let config): settings.serverURL = config.baseURL.absoluteString + case .failure(let error): serverError = error.localizedDescription + } + }.disabled(auth.isBusy) + if let serverError { Text(serverError).foregroundStyle(.red) } + Text("Connections use HTTPS and secure WebSockets. A private server certificate may require an installed, trusted CA on this iPhone.") + .font(.footnote).foregroundStyle(.secondary) + } + } + .navigationTitle("Hope") + .onAppear { server = settings.serverURL } + } + } +} + +struct AuthenticationRoot: View { + @ObservedObject var settings: SettingsStore + @State private var auth: AuthStore + @State private var replacement: Task? + init(settings: SettingsStore) { + self.settings = settings + _auth = State(initialValue: Self.makeAuth(settings)) + } + private static func makeAuth(_ settings: SettingsStore) -> AuthStore { + guard case .success(let config) = ServerConfig.parse(settings.serverURL) else { preconditionFailure("Invalid configured origin") } + #if DEBUG + if ProcessInfo.processInfo.arguments.contains("--ui-testing"), !ProcessInfo.processInfo.arguments.contains("--live-testing") { + return AuthStore(config: config, tokens: MemoryTokenStore(), fixture: true) + } + #endif + return AuthStore(config: config) + } + var body: some View { + RootContent(settings: settings, auth: auth) + .task(id: auth.config.baseURL) { await auth.restore() } + .onChange(of: settings.serverURL) { _, _ in + guard !auth.fixture else { return } + guard replacement == nil else { return } + replacement = Task { @MainActor in + await auth.logout { await auth.disconnectAccount?() } + guard !Task.isCancelled else { return } + // Read latest origin after teardown; intermediate edits never + // restore credentials or connect an intermediate account. + auth = Self.makeAuth(settings) + replacement = nil + } + } + } +} +private struct RootContent: View { + @ObservedObject var settings: SettingsStore + @ObservedObject var auth: AuthStore + var body: some View { + if let user = auth.user { + ConversationView(settings: settings, auth: auth, user: user, bearerToken: auth.token ?? "") + .id(auth.config.baseURL.absoluteString + ":" + user.id) + + } else { + LoginView(auth: auth, settings: settings) + } + } +} diff --git a/Sources/Features/Settings/ConnectionTestView.swift b/Sources/Features/Settings/ConnectionTestView.swift index c2ed6a6..618e04b 100644 --- a/Sources/Features/Settings/ConnectionTestView.swift +++ b/Sources/Features/Settings/ConnectionTestView.swift @@ -2,6 +2,7 @@ import SwiftUI struct ConnectionTestView: View { @ObservedObject var settings: SettingsStore + var bearerToken: String = "" @State private var isBusy = false @State private var result: Result? @State private var urlError: String? @@ -67,7 +68,7 @@ struct ConnectionTestView: View { case .success(let config): isBusy = true Task { - let client = TokenClient(config: config) + let client = TokenClient(config: config, bearerToken: bearerToken) do { let response = try await client.fetchToken() result = .success(response) diff --git a/Sources/Features/Settings/SettingsView.swift b/Sources/Features/Settings/SettingsView.swift index 16c4c38..834d424 100644 --- a/Sources/Features/Settings/SettingsView.swift +++ b/Sources/Features/Settings/SettingsView.swift @@ -13,7 +13,6 @@ struct SettingsView: View { @State private var urlError: String? @State private var showVoicePicker = false @State private var savedURLConfirmation: String? - @State private var showModelPicker = false @FocusState private var urlFocused: Bool init(settings: SettingsStore, session: VoiceSession) { self.settings = settings @@ -46,19 +45,6 @@ struct SettingsView: View { } footer: { Text("Voice and typing preferences are saved on this iPhone. You can switch modes from the Talk tab.") } - Section { - if session.isReady, !session.models.isEmpty { - Button { - showModelPicker = true - } label: { - LabeledContent("Model", value: session.selectedModel ?? settings.modelID ?? "Server default") - } - } else { - LabeledContent("Model", value: settings.modelID ?? "Server default") - Text("Connect from Talk to see the models available on your server.") - .font(.footnote).foregroundStyle(Nearlight.quiet) - } - } header: { Text("Intelligence") } Section { Text("Server URL") .font(.footnote.weight(.semibold)) @@ -106,7 +92,7 @@ struct SettingsView: View { .foregroundStyle(Nearlight.quiet) } NavigationLink("Connection test") { - ConnectionTestView(settings: settings) + ConnectionTestView(settings: settings, bearerToken: session.bootstrapBearerToken) } } header: { Text("Connection") @@ -120,9 +106,7 @@ struct SettingsView: View { .sheet(isPresented: $showVoicePicker) { VoicePickerView(settings: settings, session: session) } - .sheet(isPresented: $showModelPicker) { - ModelPickerView(settings: settings, session: session) - } + } /// Validates the draft and persists it only on success. On failure the @@ -190,51 +174,6 @@ struct VoicePickerView: View { } } -struct ModelPickerView: View { - @ObservedObject var settings: SettingsStore - @ObservedObject var session: VoiceSession - @Environment(\.dismiss) private var dismiss - - private var activeModelID: String? { - if !session.models.isEmpty, - let selected = session.selectedModel ?? settings.modelID, - session.models.contains(where: { $0.id == selected }) { - return selected - } - return session.models.first?.id - } - - var body: some View { - NavigationStack { - List { - ForEach(session.models, id: \.id) { model in - Button { - settings.modelID = model.id - Task { await session.selectModel(model.id) } - dismiss() - } label: { - HStack { - Text(model.name ?? model.id) - if activeModelID == model.id { - Spacer() - Image(systemName: "checkmark") - .foregroundStyle(Nearlight.rosewood) - } - } - } - .accessibilityAddTraits(activeModelID == model.id ? .isSelected : []) - } - } - .navigationTitle("Model") - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("Cancel") { dismiss() } - } - } - } - } -} - #Preview { let previewSettings = SettingsStore() guard case .success(let previewConfig) = ServerConfig.parse(SettingsStore.defaultServerURL) else { diff --git a/Sources/LiveKit/ControlChannel.swift b/Sources/LiveKit/ControlChannel.swift index 6b54aee..f7afd46 100644 --- a/Sources/LiveKit/ControlChannel.swift +++ b/Sources/LiveKit/ControlChannel.swift @@ -6,15 +6,8 @@ import LiveKit @MainActor final class ControlChannel: NSObject, RoomDelegate { - struct ModelInfo: Equatable { - let id: String - let name: String? - } - enum InboundEvent { case agentReady - case models([ModelInfo], selected: String?) - case modelChanged(String) case taskEvent(TaskEventPayload) } @@ -43,22 +36,6 @@ final class ControlChannel: NSObject, RoomDelegate { session?.agentReadyReceived() } return true - case "models": - var models: [ModelInfo] = [] - for item in json["models"]?.arrayValue ?? [] { - if let obj = item.objectValue, let id = obj["id"]?.stringValue { - models.append(ModelInfo(id: id, name: obj["name"]?.stringValue)) - } else if let id = item.stringValue { - models.append(ModelInfo(id: id, name: nil)) - } - } - onEvent?(.models(models, selected: json["selected"]?.stringValue)) - return true - case "model_changed": - if let model = json["model"]?.stringValue { - onEvent?(.modelChanged(model)) - } - return true default: return false } @@ -97,14 +74,6 @@ final class ControlChannel: NSObject, RoomDelegate { try await publish(["type": "set_voice", "voice": voiceID], topic: "voice-control", room: room) } - func setModel(_ modelID: String, room: Room) async throws { - try await publish(["type": "set_model", "model": modelID], topic: "voice-control", room: room) - } - - func getModels(room: Room) async throws { - try await publish(["type": "get_models"], topic: "voice-control", room: room) - } - func setMode(_ mode: String, room: Room) async throws { try await publish(["type": "set_mode", "mode": mode], topic: "conversation-control", room: room) } diff --git a/Sources/LiveKit/TranscriptHandler.swift b/Sources/LiveKit/TranscriptHandler.swift index 2f67452..26ab0b9 100644 --- a/Sources/LiveKit/TranscriptHandler.swift +++ b/Sources/LiveKit/TranscriptHandler.swift @@ -30,6 +30,7 @@ final class TranscriptHandler: ObservableObject { /// history; live events append/update here. Trimmed to 50 finals. @Published private(set) var events: [TranscriptEvent] = [] + var acceptsRoom: ((Room) -> Bool)? let history: TranscriptHistory // MARK: Cross-source dedupe state @@ -204,8 +205,9 @@ extension TranscriptHandler: RoomDelegate { TranscriptEvent(id: $0.id, role: resolvedRole, text: $0.text, isFinal: $0.isFinal, timestamp: $0.lastReceivedTime) } Task { @MainActor [weak self] in + guard let self, self.acceptsRoom?(room) ?? true else { return } for event in events { - self?.receiveSegment(id: event.id, role: event.role, text: event.text, isFinal: event.isFinal) + self.receiveSegment(id: event.id, role: event.role, text: event.text, isFinal: event.isFinal) } } } diff --git a/Sources/LiveKit/TranscriptHistory.swift b/Sources/LiveKit/TranscriptHistory.swift index bde81bc..d8d9a99 100644 --- a/Sources/LiveKit/TranscriptHistory.swift +++ b/Sources/LiveKit/TranscriptHistory.swift @@ -16,11 +16,14 @@ struct HistoryEntry: Codable, Equatable { final class TranscriptHistory: ObservableObject { @Published private(set) var entries: [HistoryEntry] = [] - static let key = "voice_history" + static let key = "voice_history_isolated_tests" + private let storageKey: String static let maxEntries = 50 - init() { - if let raw = AppPreferences.store.string(forKey: Self.key), + init(origin: String? = nil, userID: String? = nil) { + // Length-prefixed parts prevent ambiguous origin/account combinations. + storageKey = origin.flatMap { origin in userID.map { "voice_history_v2:\(origin.utf8.count):\(origin):\($0)" } } ?? Self.key + if let raw = AppPreferences.store.string(forKey: storageKey), let data = raw.data(using: .utf8), let decoded = try? JSONDecoder().decode([HistoryEntry].self, from: data) { entries = Array(decoded.suffix(Self.maxEntries)) @@ -45,6 +48,6 @@ final class TranscriptHistory: ObservableObject { private func persist() { guard let data = try? JSONEncoder().encode(entries) else { return } - AppPreferences.store.set(String(decoding: data, as: UTF8.self), forKey: Self.key) + AppPreferences.store.set(String(decoding: data, as: UTF8.self), forKey: storageKey) } } diff --git a/Sources/LiveKit/VoiceSession.swift b/Sources/LiveKit/VoiceSession.swift index 1e8eb65..a36a7c7 100644 --- a/Sources/LiveKit/VoiceSession.swift +++ b/Sources/LiveKit/VoiceSession.swift @@ -36,8 +36,8 @@ final class VoiceSession: ObservableObject { @Published private(set) var isMuted = false @Published private(set) var micError: String? @Published private(set) var isReconnecting = false - @Published private(set) var models: [ControlChannel.ModelInfo] = [] - @Published private(set) var selectedModel: String? + @Published private(set) var assistantActivity: String? + private var workingRequests: [String: String] = [:] /// Identity of the current room. Every async callback that could outlive a /// disconnect/reconnect compares its captured generation against this before @@ -45,7 +45,7 @@ final class VoiceSession: ObservableObject { private(set) var sessionGeneration = 0 private(set) var room = Room() let controlChannel = ControlChannel() - let transcriptHandler = TranscriptHandler() + let transcriptHandler: TranscriptHandler let stateModel = ConversationStateModel() let taskStore = TaskStore() private let config: ServerConfig @@ -85,10 +85,15 @@ final class VoiceSession: ObservableObject { /// terminal failure) and ignored. Cleared when the new room connects. private var isTearingDown = false - init(config: ServerConfig, tokenClient: TokenClient, settings: SettingsStore) { + init(config: ServerConfig, tokenClient: TokenClient, settings: SettingsStore, userID: String? = nil) { + transcriptHandler = TranscriptHandler(history: TranscriptHistory(origin: config.baseURL.absoluteString, userID: userID)) self.config = config self.tokenClient = tokenClient self.settings = settings + transcriptHandler.acceptsRoom = { [weak self] source in + guard let self else { return false } + return self.room === source && !self.isTearingDown && self.state == .ready + } room.add(delegate: self) room.add(delegate: controlChannel) room.add(delegate: transcriptHandler) @@ -111,21 +116,8 @@ final class VoiceSession: ObservableObject { // Restrict inbound control-event application to active lifecycle states // so late events from a failed/tearing-down room cannot repopulate stale // fields (agent, models, mic state) into a fresh retry (Iteration 08 Item 3). - guard state == .connecting || state == .waitingAgent || state == .ready else { return } + guard !isTearingDown, state == .connecting || state == .waitingAgent || state == .ready else { return } switch event { - case .models(let received, let selected): - models = received - // The persisted preference is deterministic; an early `models.selected` - // event must not silently replace it before sync-on-join has run. - if selectedModel == nil, settings.modelID == nil, let selected { - selectedModel = selected - settings.modelID = selected - } - logger.log("models received count=\(received.count) selected=\(selected ?? "nil", privacy: .public)") - case .modelChanged(let model): - selectedModel = model - settings.modelID = model - logger.log("model_changed applied: \(model, privacy: .public)") case .taskEvent(let payload): taskStore.apply(payload) default: @@ -133,6 +125,13 @@ final class VoiceSession: ObservableObject { } } + var onUnauthorized: (() async -> Void)? { + get { tokenClient.onUnauthorized } + set { tokenClient.onUnauthorized = newValue } + } + + var bootstrapBearerToken: String { tokenClient.bearerToken } + var isReady: Bool { state == .ready } /// Clear all per-room/session ephemeral published fields so the failed or @@ -150,8 +149,8 @@ final class VoiceSession: ObservableObject { isMuted = false userWantsMicMuted = false micError = nil - models = [] - selectedModel = nil + workingRequests.removeAll() + assistantActivity = nil isReconnecting = false reconnectAttempted = false micMutedByInterruption = false @@ -206,7 +205,7 @@ final class VoiceSession: ObservableObject { let result: Result do { let token = try await tokenClient.fetchToken() - logger.log("token ok room=\(token.room, privacy: .public)") + guard generation == sessionGeneration, !Task.isCancelled, !isTearingDown else { return } try await room.connect(url: config.livekitURL.absoluteString, token: token.token, connectOptions: makeConnectOptions()) result = .success(()) } catch { @@ -327,7 +326,7 @@ final class VoiceSession: ObservableObject { } func agentReadyReceived() { - guard state == .waitingAgent, sessionGeneration > 0 else { + guard !isTearingDown, state == .waitingAgent, sessionGeneration > 0 else { logger.log("agent_ready ignored in state \(String(describing: self.state), privacy: .public)") return } @@ -351,7 +350,7 @@ final class VoiceSession: ObservableObject { private func syncSettingsOnJoin(generation: Int) async { guard generation == sessionGeneration, isReady else { return } // Each sync operation is independent and best-effort: a failed set_voice - // or invalid persisted model must not prevent set_mode from being sent. + // must not prevent set_mode from being sent. Hermes is server-managed. let voiceID = settings.voiceID do { try await controlChannel.setVoice(voiceID, room: room) @@ -361,17 +360,6 @@ final class VoiceSession: ObservableObject { logger.error("sync-on-join set_voice failed: \(String(describing: error), privacy: .public)") } guard generation == sessionGeneration else { return } - let model = settings.modelID ?? selectedModel - if let model { - do { - try await controlChannel.setModel(model, room: room) - guard generation == sessionGeneration else { return } - logger.log("sync-on-join set_model sent: \(model, privacy: .public)") - } catch { - logger.error("sync-on-join set_model failed: \(String(describing: error), privacy: .public)") - } - } - guard generation == sessionGeneration else { return } // Sync the agent's conversation mode with the persisted setting. let mode = settings.textMode ? "text" : "voice" do { @@ -396,19 +384,6 @@ final class VoiceSession: ObservableObject { } } - func selectModel(_ id: String) async { - settings.modelID = id - guard isReady else { return } - let generation = sessionGeneration - do { - try await controlChannel.setModel(id, room: room) - guard generation == sessionGeneration else { return } - logger.log("set_model sent: \(id, privacy: .public)") - } catch { - logger.error("set_model failed: \(String(describing: error), privacy: .public)") - } - } - private func scanForAgent() { for participant in room.remoteParticipants.values { if isAgentParticipant(participant) { @@ -427,6 +402,18 @@ final class VoiceSession: ObservableObject { } func enableMicrophone() async { + let predecessor = micOpTask + let generation = sessionGeneration + let task = Task { @MainActor in + await predecessor?.value + guard !Task.isCancelled, generation == sessionGeneration, isReady, !settings.textMode else { return } + await enableMicrophoneSerialized() + } + micOpTask = task + await task.value + } + + private func enableMicrophoneSerialized() async { // Text mode invariant: the microphone must never be enabled while text // mode is active. This enforces the privacy contract at the API level, // not just in the view layer (Finding 4). @@ -436,9 +423,15 @@ final class VoiceSession: ObservableObject { } let generation = sessionGeneration for attempt in 0..<3 { + guard generation == sessionGeneration, isReady, !settings.textMode, !Task.isCancelled else { return } do { try await room.localParticipant.setMicrophone(enabled: true) guard generation == sessionGeneration else { return } + if settings.textMode || Task.isCancelled { + try? await room.localParticipant.setMicrophone(enabled: false) + isMuted = true + return + } isMuted = false userWantsMicMuted = false micError = nil @@ -461,6 +454,18 @@ final class VoiceSession: ObservableObject { } func setMuted(_ muted: Bool) async { + let predecessor = micOpTask + let generation = sessionGeneration + let task = Task { @MainActor in + await predecessor?.value + guard !Task.isCancelled, generation == sessionGeneration else { return } + await setMutedSerialized(muted) + } + micOpTask = task + await task.value + } + + private func setMutedSerialized(_ muted: Bool) async { guard isReady else { return } // Text mode invariant: unmuting (enabling the mic) is not permitted // while text mode is active. Muting is always allowed. @@ -472,6 +477,11 @@ final class VoiceSession: ObservableObject { do { try await room.localParticipant.setMicrophone(enabled: !muted) guard generation == sessionGeneration else { return } + if !muted && settings.textMode { + try? await room.localParticipant.setMicrophone(enabled: false) + isMuted = true + return + } isMuted = muted // Track explicit user mute intent separately from hardware state. // Interruption begin operations use this after awaiting the @@ -502,11 +512,14 @@ final class VoiceSession: ObservableObject { /// current session. func applyTextMode(_ enabled: Bool) async { guard isReady else { return } + guard enabled == settings.textMode else { return } if enabled { await setMuted(true) + guard settings.textMode else { return } await setMode("text") } else { await setMode("voice") + guard !settings.textMode else { return } await enableMicrophone() } } @@ -518,6 +531,7 @@ final class VoiceSession: ObservableObject { } func disconnect() async { + tokenClient.cancel() // Set the teardown guard first so any .disconnected callback from our // own room.disconnect() is ignored (not treated as a terminal failure). isTearingDown = true @@ -651,6 +665,11 @@ final class VoiceSession: ObservableObject { do { try await self.room.localParticipant.setMicrophone(enabled: true) guard !Task.isCancelled, self.sessionGeneration == generation else { return } + if self.settings.textMode { + try? await self.room.localParticipant.setMicrophone(enabled: false) + self.isMuted = true + return + } self.isMuted = false logger.log("audio interruption ended, resuming mic (seq=\(currentSeq))") } catch { @@ -666,6 +685,20 @@ final class VoiceSession: ObservableObject { } } + func receiveAssistantProgress(_ data: Data) { + struct Progress: Decodable { let type: String; let request_id: String; let status: String; let message: String } + guard let progress = try? JSONDecoder().decode(Progress.self, from: data), + progress.type == "assistant_progress", !progress.request_id.isEmpty else { return } + switch progress.status { + case "working": workingRequests[progress.request_id] = progress.message.isEmpty ? "Hope is working on your request…" : progress.message + case "completed": workingRequests.removeValue(forKey: progress.request_id) + case "failed": workingRequests.removeValue(forKey: progress.request_id) + default: return + } + assistantActivity = workingRequests.values.sorted().first + if progress.status == "failed" { assistantActivity = "Hope could not complete that request. Please try again." } + } + func tickAudioLevels(local: Float, remote: Float) { stateModel.tick(localAudioLevel: local, remoteAudioLevel: remote) } @@ -718,9 +751,12 @@ extension VoiceSession: RoomDelegate { } nonisolated func room(_ room: Room, participant: RemoteParticipant?, didReceiveData data: Data, forTopic topic: String, encryptionType: EncryptionType) { - logger.log("data topic=\(topic, privacy: .public) bytes=\(data.count) payload=\(String(data: data.prefix(300), encoding: .utf8) ?? "", privacy: .public)") Task { @MainActor [weak self] in - guard let self, self.room === room else { return } + guard let self, self.room === room, !self.isTearingDown, self.state == .ready || self.state == .waitingAgent else { return } + if topic == "assistant-progress" { + self.receiveAssistantProgress(data) + return + } // Secondary transcript source (finals only). if topic == "transcript", let json = try? JSONDecoder().decode([String: JSONValue].self, from: data), diff --git a/Sources/Networking/AuthClient.swift b/Sources/Networking/AuthClient.swift new file mode 100644 index 0000000..b2778ed --- /dev/null +++ b/Sources/Networking/AuthClient.swift @@ -0,0 +1,77 @@ +import Foundation + +struct AuthUser: Codable, Equatable { + let id: String + let username: String + let displayName: String? + let role: String + let status: String + enum CodingKeys: String, CodingKey { + case id, username, role, status + case displayName = "display_name" + } +} +struct AuthLoginResponse: Decodable { + let token: String + let user: AuthUser + let expiresAt: Double + enum CodingKeys: String, CodingKey { + case token, user + case expiresAt = "expires_at" + } + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + token = try c.decode(String.self, forKey: .token) + user = try c.decode(AuthUser.self, forKey: .user) + if let seconds = try? c.decode(Double.self, forKey: .expiresAt) { expiresAt = seconds } + else { + let raw = try c.decode(String.self, forKey: .expiresAt) + guard let date = ISO8601DateFormatter().date(from: raw) else { throw AuthError.invalidResponse } + expiresAt = date.timeIntervalSince1970 + } + } + +} +enum AuthError: LocalizedError { + case status(Int) + case invalidResponse + var errorDescription: String? { + switch self { + case .status(401): return "Your sign-in expired or the username and password were not accepted. Sign in again." + case .status(403): return "This account is not authorized. Contact your server administrator." + case .status: return "The server could not complete sign-in. Try again later." + case .invalidResponse: return "The server returned an unexpected authentication response." + } + } +} +final class AuthClient { + let config: ServerConfig + private let session: URLSession + init(config: ServerConfig, session: URLSession? = nil) { + self.config = config + self.session = session ?? TLSPinning.session() + } + private func request(_ path: String, method: String, token: String? = nil, body: Data? = nil) async throws -> Data { + var request = URLRequest(url: config.baseURL.appendingPathComponent(path)) + request.httpMethod = method + request.timeoutInterval = 10 + request.httpBody = body + if body != nil { request.setValue("application/json", forHTTPHeaderField: "Content-Type") } + if let token { request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") } + let (data, response) = try await session.data(for: request) + guard let http = response as? HTTPURLResponse else { throw AuthError.invalidResponse } + guard (200..<300).contains(http.statusCode) else { throw AuthError.status(http.statusCode) } + return data + } + func login(username: String, password: String) async throws -> AuthLoginResponse { + let body = try JSONSerialization.data(withJSONObject: ["username": username, "password": password]) + return try JSONDecoder().decode(AuthLoginResponse.self, from: await request("api/auth/login", method: "POST", body: body)) + } + func me(token: String) async throws -> AuthUser { + struct Response: Decodable { let user: AuthUser } + return try JSONDecoder().decode(Response.self, from: await request("api/auth/me", method: "GET", token: token)).user + } + func logout(token: String) async throws { + _ = try await request("api/auth/logout", method: "POST", token: token) + } +} diff --git a/Sources/Networking/KeychainTokenStore.swift b/Sources/Networking/KeychainTokenStore.swift new file mode 100644 index 0000000..a97ea30 --- /dev/null +++ b/Sources/Networking/KeychainTokenStore.swift @@ -0,0 +1,43 @@ +import Foundation +import Security + +protocol TokenStore { + func token(origin: String) throws -> String? + func save(_ token: String?, origin: String) throws +} +struct KeychainTokenStore: TokenStore { + private func query(_ origin: String) -> [String: Any] { + [kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: "dev.shane.voice.auth", + kSecAttrAccount as String: origin] + } + func token(origin: String) throws -> String? { + var q = query(origin) + q[kSecReturnData as String] = true + q[kSecMatchLimit as String] = kSecMatchLimitOne + var result: CFTypeRef? + let status = SecItemCopyMatching(q as CFDictionary, &result) + if status == errSecItemNotFound { return nil } + guard status == errSecSuccess, let data = result as? Data else { throw StoreError.failed } + return String(data: data, encoding: .utf8) + } + func save(_ token: String?, origin: String) throws { + let q = query(origin) + let deletion = SecItemDelete(q as CFDictionary) + guard deletion == errSecSuccess || deletion == errSecItemNotFound else { throw StoreError.failed } + guard let token else { return } + var item = q + item[kSecValueData as String] = Data(token.utf8) + item[kSecAttrAccessible as String] = kSecAttrAccessibleWhenUnlockedThisDeviceOnly + guard SecItemAdd(item as CFDictionary, nil) == errSecSuccess else { throw StoreError.failed } + } + enum StoreError: LocalizedError { + case failed + var errorDescription: String? { "Could not securely save the sign-in on this iPhone." } + } +} +final class MemoryTokenStore: TokenStore { + private var values: [String: String] = [:] + func token(origin: String) throws -> String? { values[origin] } + func save(_ token: String?, origin: String) throws { values[origin] = token } +} diff --git a/Sources/Networking/ServerConfig.swift b/Sources/Networking/ServerConfig.swift index dcdd771..0e48884 100644 --- a/Sources/Networking/ServerConfig.swift +++ b/Sources/Networking/ServerConfig.swift @@ -29,19 +29,19 @@ struct ServerConfig: Equatable { 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)) } - // 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")! + guard components.user == nil, components.password == nil, + components.query == nil, components.fragment == nil else { + return .failure(.invalidURL(raw)) } + var c = components + c.scheme = "https" + c.host = host.lowercased() + if c.port == 443 { c.port = nil } + c.path = "" + guard let base = c.url else { return .failure(.invalidURL(raw)) } + c.scheme = "wss" + c.path = "/livekit" + guard let livekit = c.url else { return .failure(.invalidURL(raw)) } return .success(ServerConfig(baseURL: base, livekitURL: livekit)) } diff --git a/Sources/Networking/TLSPinning.swift b/Sources/Networking/TLSPinning.swift index f2ab2ed..d45ded8 100644 --- a/Sources/Networking/TLSPinning.swift +++ b/Sources/Networking/TLSPinning.swift @@ -15,7 +15,10 @@ enum TLSPinning { static let host = "192.168.86.2" static func session() -> URLSession { - let config = URLSessionConfiguration.default + let config = URLSessionConfiguration.ephemeral + config.urlCache = nil + config.httpCookieStorage = nil + config.urlCredentialStorage = nil config.timeoutIntervalForRequest = 15 return URLSession(configuration: config, delegate: PinnedTrustDelegate(), delegateQueue: nil) } @@ -37,7 +40,12 @@ enum TLSPinning { } } -final class PinnedTrustDelegate: NSObject, URLSessionDelegate { +final class PinnedTrustDelegate: NSObject, URLSessionDelegate, URLSessionTaskDelegate { + func urlSession(_ session: URLSession, task: URLSessionTask, willPerformHTTPRedirection response: HTTPURLResponse, newRequest request: URLRequest, completionHandler: @escaping (URLRequest?) -> Void) { + // Authentication/password-bearing requests must never be redirected to + // another endpoint or origin. Correct the configured server instead. + completionHandler(nil) + } func urlSession(_ session: URLSession, didReceive challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) { guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust, let trust = challenge.protectionSpace.serverTrust else { diff --git a/Sources/Networking/TokenClient.swift b/Sources/Networking/TokenClient.swift index 90223a1..30e9530 100644 --- a/Sources/Networking/TokenClient.swift +++ b/Sources/Networking/TokenClient.swift @@ -42,22 +42,36 @@ enum TokenError: LocalizedError { final class TokenClient { let config: ServerConfig private let session: URLSession + var onUnauthorized: (() async -> Void)? + let bearerToken: String - init(config: ServerConfig) { + init(config: ServerConfig, bearerToken: String = "", session: URLSession? = nil) { self.config = config - self.session = TLSPinning.session() + self.session = session ?? TLSPinning.session() + self.bearerToken = bearerToken } + func cancel() { session.getAllTasks { $0.forEach { $0.cancel() } } } + func fetchToken() async throws -> TokenResponse { + #if DEBUG + if ProcessInfo.processInfo.arguments.contains("--ui-testing"), + !ProcessInfo.processInfo.arguments.contains("--live-testing") { + throw URLError(.cannotConnectToHost) // offline fixtures never contact any configured server + } + #endif + guard !bearerToken.isEmpty else { throw AuthError.status(401) } var request = URLRequest(url: config.baseURL.appendingPathComponent("token")) request.httpMethod = "POST" + request.setValue("Bearer \(bearerToken)", forHTTPHeaderField: "Authorization") 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) ?? "" + if http.statusCode == 401 || http.statusCode == 403 { await onUnauthorized?() } + let detail = "" throw TokenError.httpStatus(http.statusCode, detail) } do { diff --git a/Tests/AuthTests.swift b/Tests/AuthTests.swift new file mode 100644 index 0000000..f78e347 --- /dev/null +++ b/Tests/AuthTests.swift @@ -0,0 +1,150 @@ +import Foundation +import Testing +@testable import Voice + +private final class AuthURLProtocol: URLProtocol { + static var handler: ((URLRequest) throws -> (Int, Data))? + override class func canInit(with request: URLRequest) -> Bool { true } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + override func startLoading() { + do { + let (status, data) = try Self.handler!(request) + client?.urlProtocol(self, didReceive: HTTPURLResponse(url: request.url!, statusCode: status, httpVersion: nil, headerFields: nil)!, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: data) + client?.urlProtocolDidFinishLoading(self) + } catch { client?.urlProtocol(self, didFailWithError: error) } + } + override func stopLoading() {} +} + +@Suite(.serialized) +@MainActor +struct AuthTests { + private func config(_ value: String = "https://example.com") throws -> ServerConfig { + try ServerConfig.parse(value).get() + } + private func session() -> URLSession { + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [AuthURLProtocol.self] + return URLSession(configuration: config) + } + @Test func canonicalOriginsAndSecureIPv6() throws { + let parsed = try config(" HTTPS://EXAMPLE.COM:443/path ") + #expect(parsed.baseURL.absoluteString == "https://example.com") + #expect(parsed.livekitURL.absoluteString == "wss://example.com/livekit") + #expect(try config("https://[fd00::1]:9178").livekitURL.absoluteString == "wss://[fd00::1]:9178/livekit") + for invalid in ["https://u:p@example.com", "https://example.com?token=x", "https://example.com#fragment", "http://192.168.1.1"] { + if case .success = ServerConfig.parse(invalid) { Issue.record("Accepted unsafe origin") } + } + } + @Test func bearerBootstrapAndMissingAuth() async throws { + let c = try config() + AuthURLProtocol.handler = { request in + #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer test-session") + #expect(request.url?.path == "/token") + return (200, Data(#"{"token":"room-jwt","room":"room","user":{"id":"one","display_name":"One"}}"#.utf8)) + } + let response = try await TokenClient(config: c, bearerToken: "test-session", session: session()).fetchToken() + #expect(response.user.id == "one") + do { + _ = try await TokenClient(config: c, session: session()).fetchToken() + Issue.record("Unauthenticated bootstrap accepted") + } catch { #expect(error is AuthError) } + } + @Test func loginMeLogoutWireContract() async throws { + let c = try config() + let user = #"{"id":"one","username":"alice","display_name":"Alice","role":"user","status":"ready"}"# + AuthURLProtocol.handler = { request in + switch request.url!.path { + case "/api/auth/login": + #expect(request.httpMethod == "POST") + #expect(request.value(forHTTPHeaderField: "Authorization") == nil) + return (200, Data("{\"token\":\"test-session\",\"user\":\(user),\"expires_at\":4102444800}".utf8)) + case "/api/auth/me": + #expect(request.httpMethod == "GET") + #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer test-session") + return (200, Data("{\"user\":\(user)}".utf8)) + default: + #expect(request.url!.path == "/api/auth/logout") + #expect(request.httpMethod == "POST") + #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer test-session") + return (204, Data()) + } + } + let memory = MemoryTokenStore() + let client = AuthClient(config: c, session: session()) + let store = AuthStore(config: c, client: client, tokens: memory) + await store.login(username: "alice", password: "never-persist-this") + #expect(store.user?.id == "one") + #expect(try memory.token(origin: c.baseURL.absoluteString) == "test-session") + #expect(try memory.token(origin: "https://another.example") == nil) + let restored = AuthStore(config: c, client: client, tokens: memory) + await restored.restore() + #expect(restored.user?.username == "alice") + await store.logout { + #expect(store.token == nil) + #expect(store.user?.id == "one") // old UI survives only until transport disconnect + } + #expect(store.user == nil) + #expect(try memory.token(origin: c.baseURL.absoluteString) == nil) + } + @Test func transientRestoreFailureKeepsTokenButUnauthorizedDeletesIt() async throws { + let c = try config() + let memory = MemoryTokenStore() + try memory.save("test-session", origin: c.baseURL.absoluteString) + AuthURLProtocol.handler = { _ in throw URLError(.notConnectedToInternet) } + let offline = AuthStore(config: c, client: AuthClient(config: c, session: session()), tokens: memory) + await offline.restore() + #expect(offline.user == nil) + #expect(try memory.token(origin: c.baseURL.absoluteString) == "test-session") + AuthURLProtocol.handler = { _ in (401, Data()) } + let revoked = AuthStore(config: c, client: AuthClient(config: c, session: session()), tokens: memory) + await revoked.restore() + #expect(revoked.user == nil) + #expect(try memory.token(origin: c.baseURL.absoluteString) == nil) + } + @Test func logoutInvalidatesSuspendedRestore() async throws { + let c = try config() + let memory = MemoryTokenStore() + try memory.save("test-session", origin: c.baseURL.absoluteString) + AuthURLProtocol.handler = { _ in + return (200, Data(#"{"user":{"id":"one","username":"alice","role":"user","status":"ready"}}"#.utf8)) + } + let store = AuthStore(config: c, client: AuthClient(config: c, session: session()), tokens: memory) + let restore = Task { await store.restore() } + await Task.yield() + await store.logout {} + await restore.value + #expect(store.user == nil) + #expect(store.token == nil) + } + @Test func historyNeverImportsGlobalLegacyAndSeparatesAccounts() throws { + let origin = "https://history-test.example" + let legacy = AppPreferences.store.object(forKey: "voice_history") + defer { + if let legacy { AppPreferences.store.set(legacy, forKey: "voice_history") } + else { AppPreferences.store.removeObject(forKey: "voice_history") } + } + AppPreferences.store.set("[{\"role\":\"user\",\"text\":\"legacy secret\",\"timestamp\":0}]", forKey: "voice_history") + let first = TranscriptHistory(origin: origin, userID: "one") + #expect(!first.entries.contains { $0.text == "legacy secret" }) + first.clear() + first.sync(finals: [("user", "private", Date())]) + #expect(TranscriptHistory(origin: origin, userID: "one").entries.last?.text == "private") + let other = TranscriptHistory(origin: origin, userID: "two") + #expect(other.entries.isEmpty) + #expect(TranscriptHistory(origin: "https://other.example", userID: "one").entries.isEmpty) + first.clear() + } + @Test func progressCompletesWithoutTranscriptPollution() throws { + let settings = SettingsStore() + let c = try config() + let voice = VoiceSession(config: c, tokenClient: TokenClient(config: c), settings: settings) + let before = voice.transcriptHandler.events + voice.receiveAssistantProgress(Data(#"{"type":"assistant_progress","request_id":"a","status":"working","message":"Checking your calendar…"}"#.utf8)) + #expect(voice.assistantActivity == "Checking your calendar…") + voice.receiveAssistantProgress(Data(#"{"type":"assistant_progress","request_id":"a","status":"completed","message":"Done"}"#.utf8)) + #expect(voice.assistantActivity == nil) + #expect(voice.transcriptHandler.events == before) + } +} diff --git a/UITests/VoiceUITests.swift b/UITests/VoiceUITests.swift index 575e54a..0605f83 100644 --- a/UITests/VoiceUITests.swift +++ b/UITests/VoiceUITests.swift @@ -11,6 +11,24 @@ final class VoiceUITests: XCTestCase { app.launchArguments = ["--ui-testing", "--reset-ui-testing"] } + func testLogoutDoesNotLeakPriorAccountTranscriptOrTasks() { + app.launchArguments.append("--ui-fixtures") + app.launch() + XCTAssertTrue(app.staticTexts["Help me plan a quiet weekend."].waitForExistence(timeout: 10)) + app.buttons["Sign out"].tap() + XCTAssertTrue(app.secureTextFields["Password"].waitForExistence(timeout: 10)) + let username = app.textFields["Username"] + username.tap() + username.typeText("second-user") + app.secureTextFields["Password"].tap() + app.secureTextFields["Password"].typeText("offline-only") + app.buttons["Sign in"].tap() + XCTAssertTrue(app.buttons["Start chatting"].waitForExistence(timeout: 10)) + XCTAssertFalse(app.staticTexts["Help me plan a quiet weekend."].exists) + app.tabBars.buttons["Activity"].tap() + XCTAssertFalse(app.staticTexts["Search backend timed out after 30s"].exists) + } + func testWelcomeNavigationAndOfflineRecovery() { app.launch() XCTAssertTrue(app.buttons["Start chatting"].waitForExistence(timeout: 10)) @@ -45,8 +63,8 @@ final class VoiceUITests: XCTestCase { server.tap() server.typeText(String(repeating: XCUIKeyboardKey.delete.rawValue, count: invalid.count) + " https://127.0.0.1:1 ") app.buttons["Save server address"].tap() - XCTAssertEqual(server.value as? String, "https://127.0.0.1:1") XCTAssertTrue(app.staticTexts["Server address saved."].waitForExistence(timeout: 5)) + XCTAssertEqual(server.value as? String, "https://127.0.0.1:1") let unchanged = server.value as? String ?? "" server.tap() @@ -108,7 +126,7 @@ final class VoiceUITests: XCTestCase { XCTAssertTrue(app.buttons["Start chatting"].waitForExistence(timeout: 10)) XCTAssertTrue(app.buttons["Start chatting"].isHittable) app.tabBars.buttons["Activity"].tap() - XCTAssertTrue(app.staticTexts["No tasks yet"].waitForExistence(timeout: 5)) + XCTAssertTrue(app.staticTexts["No tasks yet"].waitForExistence(timeout: 15)) app.buttons["Settings"].tap() XCTAssertTrue(app.navigationBars["Settings"].exists) capture("Accessibility text size") diff --git a/Voice.xcodeproj/project.pbxproj b/Voice.xcodeproj/project.pbxproj index db4254d..fb6ab54 100644 --- a/Voice.xcodeproj/project.pbxproj +++ b/Voice.xcodeproj/project.pbxproj @@ -10,15 +10,19 @@ 1813A1CB0921D04FCAEC92D0 /* TranscriptDedupeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAEF89B2DEEE68F7168A8014 /* TranscriptDedupeTests.swift */; }; 1ECD63F10A8EAF33FD2A5B62 /* VoiceApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FED8976B60FBC13BC980FCB /* VoiceApp.swift */; }; 28659D009399CA9C4FE57B74 /* ControlChannel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61C71C48230C683D199D6F4B /* ControlChannel.swift */; }; + 2D1633BB6B8C3E7938A46A42 /* AuthTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 262B5A712708421C35ED6AC0 /* AuthTests.swift */; }; 302BEC55CA9A10E49C307977 /* ServerConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5683993A1DE677121FFA1F3 /* ServerConfig.swift */; }; 36DF44D9A7896F18A064847D /* ConversationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F3FA9F420065D2127B0AEC0 /* ConversationView.swift */; }; + 4B9A3D956FB6CB7F6A231955 /* AuthClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC751BFB00B04C73D19C714 /* AuthClient.swift */; }; 57E6476135CE30495E02D723 /* VoiceUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8309AC6885ED7EB0047AE211 /* VoiceUITests.swift */; }; 64291BBC8D0DAD47E07E94E0 /* ConversationState.swift in Sources */ = {isa = PBXBuildFile; fileRef = A0C23305A6C55A83F8617D9D /* ConversationState.swift */; }; + 6488E54A6D93278C04D870E2 /* AuthStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDDA0509C2DF2504AA50CBA9 /* AuthStore.swift */; }; 70E4B860147494D9657E8C01 /* VoiceLiveTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F6A697985B36B3A1A3F2AD5 /* VoiceLiveTests.swift */; }; 720C26EC163AE46713B02DD8 /* TLSPinning.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9B40076934C28BA4B92D808 /* TLSPinning.swift */; }; 7A8CC686530A5920338EDF5A /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 26922A8D6467BD51ED45B63B /* Assets.xcassets */; }; 89B47754E5C39418BFD839E6 /* TranscriptHistory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68A45228EF59186CFC219F2E /* TranscriptHistory.swift */; }; 8DE1363CC0119B5AF9E4E117 /* ConnectionTestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC4B9073EE8EAB51F8C55760 /* ConnectionTestView.swift */; }; + 93F179C1E302E4868B9AFC71 /* LoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96278769DD369C629F18E475 /* LoginView.swift */; }; 943BCE971A01F220FAE9E250 /* TaskPanelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E85BACA267ADD462306C9920 /* TaskPanelView.swift */; }; 9461C0886FA294FDD9742E51 /* TranscriptListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD31F76E1880AC76ED31DD4D /* TranscriptListView.swift */; }; 98BA1A0F92DB7B98A8BA7814 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 48F76881B4AE51DEE74511CC /* SettingsView.swift */; }; @@ -33,6 +37,7 @@ D958BE8EE0FF9568F8FABB11 /* Nearlight.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA6FA2A93116B237CEA712E6 /* Nearlight.swift */; }; D9A0059E342955A06A021D6C /* LiveKit in Frameworks */ = {isa = PBXBuildFile; productRef = A6BAD55715F383C0DB5F306F /* LiveKit */; }; EE0C5762EC97B123A1880FFB /* Voices.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E5DF207EDB787F63CB3A64D /* Voices.swift */; }; + F09DD789D07F49C46A659A4F /* KeychainTokenStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 536E3E89F4BB09D631744F3F /* KeychainTokenStore.swift */; }; FC696C6E74057D4C93E6DA67 /* ConversationStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDD0AA8B0E9B45A325D1E5E7 /* ConversationStateTests.swift */; }; /* End PBXBuildFile section */ @@ -56,12 +61,14 @@ /* Begin PBXFileReference section */ 0FC167F6658CFEC9546A7E9F /* AgentMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentMonitor.swift; sourceTree = ""; }; 20A82F8996DEA166C94A7956 /* ServerReplacementCoordinatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerReplacementCoordinatorTests.swift; sourceTree = ""; }; + 262B5A712708421C35ED6AC0 /* AuthTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthTests.swift; sourceTree = ""; }; 26922A8D6467BD51ED45B63B /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 2A4C3CE2CA6FF828452803D1 /* Voice.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Voice.app; sourceTree = BUILT_PRODUCTS_DIR; }; 2E5DF207EDB787F63CB3A64D /* Voices.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Voices.swift; sourceTree = ""; }; 2FED8976B60FBC13BC980FCB /* VoiceApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceApp.swift; sourceTree = ""; }; 3F3FA9F420065D2127B0AEC0 /* ConversationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationView.swift; sourceTree = ""; }; 48F76881B4AE51DEE74511CC /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; + 536E3E89F4BB09D631744F3F /* KeychainTokenStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainTokenStore.swift; sourceTree = ""; }; 5759EB570DB87BBC121D946D /* VoiceUITests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = VoiceUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 5F6A697985B36B3A1A3F2AD5 /* VoiceLiveTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceLiveTests.swift; sourceTree = ""; }; 61C71C48230C683D199D6F4B /* ControlChannel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ControlChannel.swift; sourceTree = ""; }; @@ -69,6 +76,7 @@ 6D0D153557BB1C756ED04C61 /* SettingsStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsStore.swift; sourceTree = ""; }; 8309AC6885ED7EB0047AE211 /* VoiceUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceUITests.swift; sourceTree = ""; }; 87DD77AA383E1ECCEEEFB399 /* LightwellView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LightwellView.swift; sourceTree = ""; }; + 96278769DD369C629F18E475 /* LoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginView.swift; sourceTree = ""; }; 9681E7410B684D2D38752A70 /* TokenClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TokenClient.swift; sourceTree = ""; }; A0C23305A6C55A83F8617D9D /* ConversationState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationState.swift; sourceTree = ""; }; AC4B9073EE8EAB51F8C55760 /* ConnectionTestView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectionTestView.swift; sourceTree = ""; }; @@ -77,8 +85,10 @@ C0B60FBBB9C47BB9ADACA7CA /* TaskStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskStore.swift; sourceTree = ""; }; C22975C69240AACD3418091F /* VoiceTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = VoiceTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; CA6FA2A93116B237CEA712E6 /* Nearlight.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Nearlight.swift; sourceTree = ""; }; + CDDA0509C2DF2504AA50CBA9 /* AuthStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthStore.swift; sourceTree = ""; }; D82D665165A7BC1A4CBCCC6F /* TranscriptHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranscriptHandler.swift; sourceTree = ""; }; D9B40076934C28BA4B92D808 /* TLSPinning.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TLSPinning.swift; sourceTree = ""; }; + DAC751BFB00B04C73D19C714 /* AuthClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthClient.swift; sourceTree = ""; }; E85BACA267ADD462306C9920 /* TaskPanelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskPanelView.swift; sourceTree = ""; }; EB58EEF9490F81FB2E1CE31C /* VoiceSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceSession.swift; sourceTree = ""; }; F5683993A1DE677121FFA1F3 /* ServerConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerConfig.swift; sourceTree = ""; }; @@ -132,6 +142,8 @@ 6B2AB34274D9B37E77178C6C /* Networking */ = { isa = PBXGroup; children = ( + DAC751BFB00B04C73D19C714 /* AuthClient.swift */, + 536E3E89F4BB09D631744F3F /* KeychainTokenStore.swift */, F5683993A1DE677121FFA1F3 /* ServerConfig.swift */, D9B40076934C28BA4B92D808 /* TLSPinning.swift */, 9681E7410B684D2D38752A70 /* TokenClient.swift */, @@ -154,6 +166,7 @@ children = ( 3F3FA9F420065D2127B0AEC0 /* ConversationView.swift */, 87DD77AA383E1ECCEEEFB399 /* LightwellView.swift */, + 96278769DD369C629F18E475 /* LoginView.swift */, BD31F76E1880AC76ED31DD4D /* TranscriptListView.swift */, ); path = Conversation; @@ -183,6 +196,7 @@ CE49A38E40AAF659A9281B85 /* App */ = { isa = PBXGroup; children = ( + CDDA0509C2DF2504AA50CBA9 /* AuthStore.swift */, CA6FA2A93116B237CEA712E6 /* Nearlight.swift */, 6D0D153557BB1C756ED04C61 /* SettingsStore.swift */, 2FED8976B60FBC13BC980FCB /* VoiceApp.swift */, @@ -194,6 +208,7 @@ DC8495F2B27E1770E6620A05 /* Tests */ = { isa = PBXGroup; children = ( + 262B5A712708421C35ED6AC0 /* AuthTests.swift */, BDD0AA8B0E9B45A325D1E5E7 /* ConversationStateTests.swift */, 20A82F8996DEA166C94A7956 /* ServerReplacementCoordinatorTests.swift */, FAEF89B2DEEE68F7168A8014 /* TranscriptDedupeTests.swift */, @@ -346,11 +361,15 @@ buildActionMask = 2147483647; files = ( C00D1878C2AB3D8B6F9562B6 /* AgentMonitor.swift in Sources */, + 4B9A3D956FB6CB7F6A231955 /* AuthClient.swift in Sources */, + 6488E54A6D93278C04D870E2 /* AuthStore.swift in Sources */, 8DE1363CC0119B5AF9E4E117 /* ConnectionTestView.swift in Sources */, 28659D009399CA9C4FE57B74 /* ControlChannel.swift in Sources */, 64291BBC8D0DAD47E07E94E0 /* ConversationState.swift in Sources */, 36DF44D9A7896F18A064847D /* ConversationView.swift in Sources */, + F09DD789D07F49C46A659A4F /* KeychainTokenStore.swift in Sources */, A66BC894BEEC870D879F991F /* LightwellView.swift in Sources */, + 93F179C1E302E4868B9AFC71 /* LoginView.swift in Sources */, D958BE8EE0FF9568F8FABB11 /* Nearlight.swift in Sources */, 302BEC55CA9A10E49C307977 /* ServerConfig.swift in Sources */, A94F6C509E3FE215115B2C58 /* SettingsStore.swift in Sources */, @@ -381,6 +400,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 2D1633BB6B8C3E7938A46A42 /* AuthTests.swift in Sources */, FC696C6E74057D4C93E6DA67 /* ConversationStateTests.swift in Sources */, BCAB602C722D74F6C96C5E36 /* ServerReplacementCoordinatorTests.swift in Sources */, 1813A1CB0921D04FCAEC92D0 /* TranscriptDedupeTests.swift in Sources */, diff --git a/Voice.xcodeproj/xcshareddata/xcschemes/Voice_iOS.xcscheme b/Voice.xcodeproj/xcshareddata/xcschemes/Voice_iOS.xcscheme new file mode 100644 index 0000000..cec566d --- /dev/null +++ b/Voice.xcodeproj/xcshareddata/xcschemes/Voice_iOS.xcscheme @@ -0,0 +1,141 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/ENGINEERING_LEARNINGS.md b/docs/ENGINEERING_LEARNINGS.md index bde822c..6dfbce7 100644 --- a/docs/ENGINEERING_LEARNINGS.md +++ b/docs/ENGINEERING_LEARNINGS.md @@ -4,6 +4,31 @@ Updated 2026-09-19 from repository source, recorded test evidence, and prior deb This is guidance, not a claim that hardware or server checks were rerun today. Read [PROJECT_STATUS.md](PROJECT_STATUS.md) for the current product scope. +## Authorized accounts and transport — 2026-10-01 + +- The latest direct request makes Hermes server-managed; do not restore model + selectors or `set_model` from old handoffs. Server account auth is distinct from + the previously canceled ChatGPT subscription/device login. +- Scope credentials to canonical HTTPS origin, and transcripts to origin + immutable + user id. Never import the global legacy transcript into an authenticated account. +- Disable bootstrap immediately on logout, invalidate/cancel pending authentication, + then disconnect before clearing account UI. New account identity resets drafts and + task stores; reject stale room transcription/control callbacks during teardown. +- Check generation and cancellation immediately after token fetch and before + `room.connect`. Serialize normal mic actions with interruption actions and recheck + Type mode after awaits; pending Voice requests cannot enable Type-mode capture. +- Private IP does not justify plaintext signaling. Use same-origin URLComponents WSS + (IPv6 safe). Exact HTTP certificate pinning does not establish the SDK's separate + WSS trust. Installed trusted CA and real hardware qualification may be required; + never introduce trust-all or plaintext fallback to bypass it. +- A transient `/me` outage is not revocation: retain its Keychain bearer but show + no unverified account UI. Only 401/403 clears saved authentication. Authentication + URLSession is ephemeral with cookies/cache/credential persistence disabled and + redirects rejected to protect password/bearer origin confinement. +- Never log data-channel payloads or bootstrap response bodies. Keep release SDK + logging at error. Offline auth fixtures use memory tokens, isolated preferences + and deterministic no-network bootstrap errors. + ## Preserve behavior across UI changes - Observe nested observable objects directly where their data is rendered. Watching diff --git a/docs/PROJECT_STATUS.md b/docs/PROJECT_STATUS.md index 891e3f9..fd35b34 100644 --- a/docs/PROJECT_STATUS.md +++ b/docs/PROJECT_STATUS.md @@ -6,6 +6,57 @@ Reusable implementation, testing, device and deployment lessons are in [ENGINEERING_LEARNINGS.md](ENGINEERING_LEARNINGS.md). September 12 test/install results below are historical evidence, not a September 19 health check. +## Authorized Hermes migration — 2026-10-01 + +The direct user request supersedes the historical Qwen/client-model direction below. +This repository now implements server-account login (`/api/auth/login`), bearer +verification (`/api/auth/me`), logout (`/api/auth/logout`), and authorized `/token` +bootstrap. Hermes is server-managed: model settings, pickers and control messages +were removed. No backend source, deployment, live endpoint or secrets were touched +in this iOS implementation. + +- Bearers live in device-only, unlocked Keychain items scoped to canonical HTTPS + origin; passwords are never persisted. Login accepts numeric Unix expiry and + ISO8601 expiry, and ready/provisioning accounts. +- Logout and origin replacement invalidate auth work, cancel bootstrap and + disconnect transport before removing the account UI. Each account/root gets + fresh drafts/tasks and history scoped to origin + immutable user id; the global + legacy `voice_history` is never imported. +- Signaling is same-origin WSS `/livekit` for all hosts, including IPv6/private + addresses. Userinfo/query/fragment origins are rejected. There is no plaintext + fallback and no trust-all delegate. HTTP retains the existing exact LAN pin; + **the LiveKit SDK's separate WSS trust may require an installed trusted CA**. + Self-signed WSS, actual login, multi-user server isolation, microphone capture, + audible speech and interruptions require subsequent user hardware qualification. +- `assistant-progress` working/completed/failed events display request activity + without polluting eventual normal transcripts. Payload logging is removed; SDK + log level is error rather than release debug. +- Offline UI uses fake auth and memory token storage plus isolated preferences; + bootstrap fixtures cannot contact a remote server. Live suite is excluded from + this validation. `project.yml` generates sources and an explicit test scheme. + +Verification: simulator build passed. Initial offline unit run: **50 passed, 0 +failed** (5 new focused authentication tests), `/tmp/hermes-ios-unit2.xcresult`. +A scheme-less first test attempt failed before running tests; adding the generated +scheme fixed it. Combined offline run: **51 unit + 5 UI passed, 0 failures** at +`/tmp/hermes-ios-offline-final.xcresult`. Expanded run added the logout/account-switch +privacy UI test, which passed; one existing URL normalization assertion raced the +asynchronous save render and failed. It now waits for the saved confirmation before +asserting the normalized value. A subsequent accessibility-size Activity empty-state +assertion timed out after 5 seconds; its wait now allows 15 seconds. Both failed-run +bundles are retained (`/tmp/hermes-ios-offline-qualified.xcresult` and +`/tmp/hermes-ios-offline-release.xcresult`). Final focused re-verification: +**51 unit + 3 UI passed, 0 failures**, including accessibility-size navigation, +settings persistence/normalization and logout/account-switch transcript/task privacy, +`/tmp/hermes-ios-focused-final.xcresult`. Remaining three offline UI cases passed in +both expanded runs. Parent security review identified transient `/me` outages clearing +Keychain prematurely; fixed to clear only 401/403 and added a regression test. +Final source: **52 unit passed, 0 failures**, `/tmp/hermes-ios-unit-final.xcresult`. +All six offline UI cases have passed across the runs described above; the final +three focused cases passed together. No claim of one fully green expanded six-UI +run is made. These are client-only simulator claims, not live/deployment/hardware +qualification. + ## Current direction Keep **Qwen 2.5**. The user explicitly canceled the earlier request to switch to diff --git a/project.yml b/project.yml index acd436a..e805d01 100644 --- a/project.yml +++ b/project.yml @@ -1,4 +1,15 @@ name: Voice +schemes: + Voice_iOS: + build: + targets: + Voice_iOS: all + VoiceTests_iOS: [test] + VoiceUITests: [test] + test: + targets: + - VoiceTests_iOS + - VoiceUITests options: bundleIdPrefix: dev.shane deploymentTarget: