feat: authorize iOS Hermes accounts and secure session isolation

This commit is contained in:
snowspeeder committed 2026-10-01 12:57:57 -04:00
1 parent 9bdaea2408
commit bebaf59466
23 files changed
+911 -185

No files matched your search

+103
View File
@@ -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<AuthUser, Error>?
private var loginOperation: Task<AuthLoginResponse, Error>?
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
}
}
-6
View File
@@ -8,9 +8,6 @@ final class SettingsStore: ObservableObject {
@Published var voiceID: String { @Published var voiceID: String {
didSet { AppPreferences.store.set(voiceID, forKey: Keys.voiceID) } didSet { AppPreferences.store.set(voiceID, forKey: Keys.voiceID) }
} }
@Published var modelID: String? {
didSet { AppPreferences.store.set(modelID, forKey: Keys.modelID) }
}
@Published var textMode: Bool { @Published var textMode: Bool {
didSet { AppPreferences.store.set(textMode, forKey: Keys.textMode) } didSet { AppPreferences.store.set(textMode, forKey: Keys.textMode) }
} }
@@ -18,7 +15,6 @@ final class SettingsStore: ObservableObject {
private enum Keys { private enum Keys {
static let serverURL = "serverURL" static let serverURL = "serverURL"
static let voiceID = "voiceID" static let voiceID = "voiceID"
static let modelID = "modelID"
static let textMode = "textMode" static let textMode = "textMode"
} }
@@ -41,7 +37,6 @@ final class SettingsStore: ObservableObject {
} }
let storedVoice = d.string(forKey: Keys.voiceID) let storedVoice = d.string(forKey: Keys.voiceID)
voiceID = Voices.all.contains { $0.id == storedVoice } ? (storedVoice ?? "") : Voices.defaultID voiceID = Voices.all.contains { $0.id == storedVoice } ? (storedVoice ?? "") : Voices.defaultID
modelID = d.string(forKey: Keys.modelID)
textMode = d.bool(forKey: Keys.textMode) textMode = d.bool(forKey: Keys.textMode)
} }
@@ -64,7 +59,6 @@ enum AppPreferences {
if ProcessInfo.processInfo.arguments.contains("--live-testing") { if ProcessInfo.processInfo.arguments.contains("--live-testing") {
defaults.set(SettingsStore.defaultServerURL, forKey: "serverURL") defaults.set(SettingsStore.defaultServerURL, forKey: "serverURL")
defaults.set(true, forKey: "textMode") defaults.set(true, forKey: "textMode")
defaults.set("qwen2.5-vl-3b", forKey: "modelID")
} }
return defaults return defaults
} }
+2 -2
View File
@@ -6,12 +6,12 @@ struct VoiceApp: App {
@StateObject private var settings = SettingsStore() @StateObject private var settings = SettingsStore()
init() { init() {
LiveKitSDK.setLogLevel(.debug) LiveKitSDK.setLogLevel(.error)
} }
var body: some Scene { var body: some Scene {
WindowGroup { WindowGroup {
ConversationView(settings: settings) AuthenticationRoot(settings: settings)
.tint(Nearlight.rosewood) .tint(Nearlight.rosewood)
} }
} }
@@ -4,26 +4,32 @@ import SwiftUI
/// Owns one session across all tabs. Changing tabs never ends a conversation. /// Owns one session across all tabs. Changing tabs never ends a conversation.
struct ConversationView: View { struct ConversationView: View {
@ObservedObject var settings: SettingsStore @ObservedObject var settings: SettingsStore
@ObservedObject var auth: AuthStore
let user: AuthUser
let bearerToken: String
@State private var session: VoiceSession @State private var session: VoiceSession
@State private var activeServerURL: String @State private var activeServerURL: String
@State private var pendingServerURL: String? @State private var pendingServerURL: String?
@State private var isReplacingSession = false @State private var isReplacingSession = false
@State private var selectedTab = 0 @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 self.settings = settings
let url = settings.trimmedServerURL let url = settings.trimmedServerURL
_activeServerURL = State(initialValue: url) _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 { guard case .success(let config) = ServerConfig.parse(url) else {
preconditionFailure("Stored server URL failed validation; init sanitizes to a valid value.") 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 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: .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.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() session.taskStore.injectSampleEvents()
@@ -45,7 +51,12 @@ struct ConversationView: View {
TabView(selection: $selectedTab) { TabView(selection: $selectedTab) {
NavigationStack { NavigationStack {
ConversationContent(settings: settings, session: session) 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") } .tabItem { Label("Talk", systemImage: "bubble.left.and.bubble.right") }
.tag(0) .tag(0)
@@ -57,7 +68,16 @@ struct ConversationView: View {
.onChange(of: settings.textMode) { _, enabled in .onChange(of: settings.textMode) { _, enabled in
Task { await session.applyTextMode(enabled) } 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 { @ToolbarContentBuilder private var settingsLink: some ToolbarContent {
@@ -84,7 +104,7 @@ struct ConversationView: View {
guard next != activeServerURL else { continue } guard next != activeServerURL else { continue }
await session.disconnect() await session.disconnect()
activeServerURL = next activeServerURL = next
session = Self.makeSession(next, settings: settings) session = Self.makeSession(next, settings: settings, bearerToken: bearerToken, userID: user.id)
} }
isReplacingSession = false isReplacingSession = false
} }
@@ -167,6 +187,10 @@ private struct ConversationContent: View {
if failureNotice != nil { failureBanner } if failureNotice != nil { failureBanner }
} else { } else {
statusStrip statusStrip
if let activity = session.assistantActivity {
Label(activity, systemImage: "clock").font(.callout)
.padding().accessibilityIdentifier("assistant.progress")
}
if session.isReconnecting { reconnectBanner } if session.isReconnecting { reconnectBanner }
if failureNotice != nil { failureBanner } if failureNotice != nil { failureBanner }
TranscriptListView(events: transcripts.events, onClear: transcripts.clearAll) TranscriptListView(events: transcripts.events, onClear: transcripts.clearAll)
@@ -610,4 +634,4 @@ extension VoiceSession {
} }
} }
#Preview { ConversationView(settings: SettingsStore()) } #Preview { AuthenticationRoot(settings: SettingsStore()) }
@@ -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<Void, Never>?
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)
}
}
}
@@ -2,6 +2,7 @@ import SwiftUI
struct ConnectionTestView: View { struct ConnectionTestView: View {
@ObservedObject var settings: SettingsStore @ObservedObject var settings: SettingsStore
var bearerToken: String = ""
@State private var isBusy = false @State private var isBusy = false
@State private var result: Result<TokenResponse, Error>? @State private var result: Result<TokenResponse, Error>?
@State private var urlError: String? @State private var urlError: String?
@@ -67,7 +68,7 @@ struct ConnectionTestView: View {
case .success(let config): case .success(let config):
isBusy = true isBusy = true
Task { Task {
let client = TokenClient(config: config) let client = TokenClient(config: config, bearerToken: bearerToken)
do { do {
let response = try await client.fetchToken() let response = try await client.fetchToken()
result = .success(response) result = .success(response)
+2 -63
View File
@@ -13,7 +13,6 @@ struct SettingsView: View {
@State private var urlError: String? @State private var urlError: String?
@State private var showVoicePicker = false @State private var showVoicePicker = false
@State private var savedURLConfirmation: String? @State private var savedURLConfirmation: String?
@State private var showModelPicker = false
@FocusState private var urlFocused: Bool @FocusState private var urlFocused: Bool
init(settings: SettingsStore, session: VoiceSession) { init(settings: SettingsStore, session: VoiceSession) {
self.settings = settings self.settings = settings
@@ -46,19 +45,6 @@ struct SettingsView: View {
} footer: { } footer: {
Text("Voice and typing preferences are saved on this iPhone. You can switch modes from the Talk tab.") 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 { Section {
Text("Server URL") Text("Server URL")
.font(.footnote.weight(.semibold)) .font(.footnote.weight(.semibold))
@@ -106,7 +92,7 @@ struct SettingsView: View {
.foregroundStyle(Nearlight.quiet) .foregroundStyle(Nearlight.quiet)
} }
NavigationLink("Connection test") { NavigationLink("Connection test") {
ConnectionTestView(settings: settings) ConnectionTestView(settings: settings, bearerToken: session.bootstrapBearerToken)
} }
} header: { } header: {
Text("Connection") Text("Connection")
@@ -120,9 +106,7 @@ struct SettingsView: View {
.sheet(isPresented: $showVoicePicker) { .sheet(isPresented: $showVoicePicker) {
VoicePickerView(settings: settings, session: session) 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 /// 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 { #Preview {
let previewSettings = SettingsStore() let previewSettings = SettingsStore()
guard case .success(let previewConfig) = ServerConfig.parse(SettingsStore.defaultServerURL) else { guard case .success(let previewConfig) = ServerConfig.parse(SettingsStore.defaultServerURL) else {
-31
View File
@@ -6,15 +6,8 @@ import LiveKit
@MainActor @MainActor
final class ControlChannel: NSObject, RoomDelegate { final class ControlChannel: NSObject, RoomDelegate {
struct ModelInfo: Equatable {
let id: String
let name: String?
}
enum InboundEvent { enum InboundEvent {
case agentReady case agentReady
case models([ModelInfo], selected: String?)
case modelChanged(String)
case taskEvent(TaskEventPayload) case taskEvent(TaskEventPayload)
} }
@@ -43,22 +36,6 @@ final class ControlChannel: NSObject, RoomDelegate {
session?.agentReadyReceived() session?.agentReadyReceived()
} }
return true 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: default:
return false return false
} }
@@ -97,14 +74,6 @@ final class ControlChannel: NSObject, RoomDelegate {
try await publish(["type": "set_voice", "voice": voiceID], topic: "voice-control", room: room) 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 { func setMode(_ mode: String, room: Room) async throws {
try await publish(["type": "set_mode", "mode": mode], topic: "conversation-control", room: room) try await publish(["type": "set_mode", "mode": mode], topic: "conversation-control", room: room)
} }
+3 -1
View File
@@ -30,6 +30,7 @@ final class TranscriptHandler: ObservableObject {
/// history; live events append/update here. Trimmed to 50 finals. /// history; live events append/update here. Trimmed to 50 finals.
@Published private(set) var events: [TranscriptEvent] = [] @Published private(set) var events: [TranscriptEvent] = []
var acceptsRoom: ((Room) -> Bool)?
let history: TranscriptHistory let history: TranscriptHistory
// MARK: Cross-source dedupe state // 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) TranscriptEvent(id: $0.id, role: resolvedRole, text: $0.text, isFinal: $0.isFinal, timestamp: $0.lastReceivedTime)
} }
Task { @MainActor [weak self] in Task { @MainActor [weak self] in
guard let self, self.acceptsRoom?(room) ?? true else { return }
for event in events { 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)
} }
} }
} }
+7 -4
View File
@@ -16,11 +16,14 @@ struct HistoryEntry: Codable, Equatable {
final class TranscriptHistory: ObservableObject { final class TranscriptHistory: ObservableObject {
@Published private(set) var entries: [HistoryEntry] = [] @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 static let maxEntries = 50
init() { init(origin: String? = nil, userID: String? = nil) {
if let raw = AppPreferences.store.string(forKey: Self.key), // 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 data = raw.data(using: .utf8),
let decoded = try? JSONDecoder().decode([HistoryEntry].self, from: data) { let decoded = try? JSONDecoder().decode([HistoryEntry].self, from: data) {
entries = Array(decoded.suffix(Self.maxEntries)) entries = Array(decoded.suffix(Self.maxEntries))
@@ -45,6 +48,6 @@ final class TranscriptHistory: ObservableObject {
private func persist() { private func persist() {
guard let data = try? JSONEncoder().encode(entries) else { return } 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)
} }
} }
+85 -49
View File
@@ -36,8 +36,8 @@ final class VoiceSession: ObservableObject {
@Published private(set) var isMuted = false @Published private(set) var isMuted = false
@Published private(set) var micError: String? @Published private(set) var micError: String?
@Published private(set) var isReconnecting = false @Published private(set) var isReconnecting = false
@Published private(set) var models: [ControlChannel.ModelInfo] = [] @Published private(set) var assistantActivity: String?
@Published private(set) var selectedModel: String? private var workingRequests: [String: String] = [:]
/// Identity of the current room. Every async callback that could outlive a /// Identity of the current room. Every async callback that could outlive a
/// disconnect/reconnect compares its captured generation against this before /// 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 sessionGeneration = 0
private(set) var room = Room() private(set) var room = Room()
let controlChannel = ControlChannel() let controlChannel = ControlChannel()
let transcriptHandler = TranscriptHandler() let transcriptHandler: TranscriptHandler
let stateModel = ConversationStateModel() let stateModel = ConversationStateModel()
let taskStore = TaskStore() let taskStore = TaskStore()
private let config: ServerConfig private let config: ServerConfig
@@ -85,10 +85,15 @@ final class VoiceSession: ObservableObject {
/// terminal failure) and ignored. Cleared when the new room connects. /// terminal failure) and ignored. Cleared when the new room connects.
private var isTearingDown = false 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.config = config
self.tokenClient = tokenClient self.tokenClient = tokenClient
self.settings = settings 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: self)
room.add(delegate: controlChannel) room.add(delegate: controlChannel)
room.add(delegate: transcriptHandler) room.add(delegate: transcriptHandler)
@@ -111,21 +116,8 @@ final class VoiceSession: ObservableObject {
// Restrict inbound control-event application to active lifecycle states // Restrict inbound control-event application to active lifecycle states
// so late events from a failed/tearing-down room cannot repopulate stale // 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). // 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 { 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): case .taskEvent(let payload):
taskStore.apply(payload) taskStore.apply(payload)
default: 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 } var isReady: Bool { state == .ready }
/// Clear all per-room/session ephemeral published fields so the failed or /// Clear all per-room/session ephemeral published fields so the failed or
@@ -150,8 +149,8 @@ final class VoiceSession: ObservableObject {
isMuted = false isMuted = false
userWantsMicMuted = false userWantsMicMuted = false
micError = nil micError = nil
models = [] workingRequests.removeAll()
selectedModel = nil assistantActivity = nil
isReconnecting = false isReconnecting = false
reconnectAttempted = false reconnectAttempted = false
micMutedByInterruption = false micMutedByInterruption = false
@@ -206,7 +205,7 @@ final class VoiceSession: ObservableObject {
let result: Result<Void, Error> let result: Result<Void, Error>
do { do {
let token = try await tokenClient.fetchToken() 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()) try await room.connect(url: config.livekitURL.absoluteString, token: token.token, connectOptions: makeConnectOptions())
result = .success(()) result = .success(())
} catch { } catch {
@@ -327,7 +326,7 @@ final class VoiceSession: ObservableObject {
} }
func agentReadyReceived() { 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)") logger.log("agent_ready ignored in state \(String(describing: self.state), privacy: .public)")
return return
} }
@@ -351,7 +350,7 @@ final class VoiceSession: ObservableObject {
private func syncSettingsOnJoin(generation: Int) async { private func syncSettingsOnJoin(generation: Int) async {
guard generation == sessionGeneration, isReady else { return } guard generation == sessionGeneration, isReady else { return }
// Each sync operation is independent and best-effort: a failed set_voice // 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 let voiceID = settings.voiceID
do { do {
try await controlChannel.setVoice(voiceID, room: room) 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)") logger.error("sync-on-join set_voice failed: \(String(describing: error), privacy: .public)")
} }
guard generation == sessionGeneration else { return } 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. // Sync the agent's conversation mode with the persisted setting.
let mode = settings.textMode ? "text" : "voice" let mode = settings.textMode ? "text" : "voice"
do { 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() { private func scanForAgent() {
for participant in room.remoteParticipants.values { for participant in room.remoteParticipants.values {
if isAgentParticipant(participant) { if isAgentParticipant(participant) {
@@ -427,6 +402,18 @@ final class VoiceSession: ObservableObject {
} }
func enableMicrophone() async { 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 // Text mode invariant: the microphone must never be enabled while text
// mode is active. This enforces the privacy contract at the API level, // mode is active. This enforces the privacy contract at the API level,
// not just in the view layer (Finding 4). // not just in the view layer (Finding 4).
@@ -436,9 +423,15 @@ final class VoiceSession: ObservableObject {
} }
let generation = sessionGeneration let generation = sessionGeneration
for attempt in 0..<3 { for attempt in 0..<3 {
guard generation == sessionGeneration, isReady, !settings.textMode, !Task.isCancelled else { return }
do { do {
try await room.localParticipant.setMicrophone(enabled: true) try await room.localParticipant.setMicrophone(enabled: true)
guard generation == sessionGeneration else { return } guard generation == sessionGeneration else { return }
if settings.textMode || Task.isCancelled {
try? await room.localParticipant.setMicrophone(enabled: false)
isMuted = true
return
}
isMuted = false isMuted = false
userWantsMicMuted = false userWantsMicMuted = false
micError = nil micError = nil
@@ -461,6 +454,18 @@ final class VoiceSession: ObservableObject {
} }
func setMuted(_ muted: Bool) async { 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 } guard isReady else { return }
// Text mode invariant: unmuting (enabling the mic) is not permitted // Text mode invariant: unmuting (enabling the mic) is not permitted
// while text mode is active. Muting is always allowed. // while text mode is active. Muting is always allowed.
@@ -472,6 +477,11 @@ final class VoiceSession: ObservableObject {
do { do {
try await room.localParticipant.setMicrophone(enabled: !muted) try await room.localParticipant.setMicrophone(enabled: !muted)
guard generation == sessionGeneration else { return } guard generation == sessionGeneration else { return }
if !muted && settings.textMode {
try? await room.localParticipant.setMicrophone(enabled: false)
isMuted = true
return
}
isMuted = muted isMuted = muted
// Track explicit user mute intent separately from hardware state. // Track explicit user mute intent separately from hardware state.
// Interruption begin operations use this after awaiting the // Interruption begin operations use this after awaiting the
@@ -502,11 +512,14 @@ final class VoiceSession: ObservableObject {
/// current session. /// current session.
func applyTextMode(_ enabled: Bool) async { func applyTextMode(_ enabled: Bool) async {
guard isReady else { return } guard isReady else { return }
guard enabled == settings.textMode else { return }
if enabled { if enabled {
await setMuted(true) await setMuted(true)
guard settings.textMode else { return }
await setMode("text") await setMode("text")
} else { } else {
await setMode("voice") await setMode("voice")
guard !settings.textMode else { return }
await enableMicrophone() await enableMicrophone()
} }
} }
@@ -518,6 +531,7 @@ final class VoiceSession: ObservableObject {
} }
func disconnect() async { func disconnect() async {
tokenClient.cancel()
// Set the teardown guard first so any .disconnected callback from our // Set the teardown guard first so any .disconnected callback from our
// own room.disconnect() is ignored (not treated as a terminal failure). // own room.disconnect() is ignored (not treated as a terminal failure).
isTearingDown = true isTearingDown = true
@@ -651,6 +665,11 @@ final class VoiceSession: ObservableObject {
do { do {
try await self.room.localParticipant.setMicrophone(enabled: true) try await self.room.localParticipant.setMicrophone(enabled: true)
guard !Task.isCancelled, self.sessionGeneration == generation else { return } 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 self.isMuted = false
logger.log("audio interruption ended, resuming mic (seq=\(currentSeq))") logger.log("audio interruption ended, resuming mic (seq=\(currentSeq))")
} catch { } 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) { func tickAudioLevels(local: Float, remote: Float) {
stateModel.tick(localAudioLevel: local, remoteAudioLevel: remote) 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) { 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) ?? "<binary>", privacy: .public)")
Task { @MainActor [weak self] in 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). // Secondary transcript source (finals only).
if topic == "transcript", if topic == "transcript",
let json = try? JSONDecoder().decode([String: JSONValue].self, from: data), let json = try? JSONDecoder().decode([String: JSONValue].self, from: data),
+77
View File
@@ -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)
}
}
@@ -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 }
}
+12 -12
View File
@@ -29,19 +29,19 @@ struct ServerConfig: Equatable {
return .failure(.invalidURL(raw)) return .failure(.invalidURL(raw))
} }
if scheme != "https" { return .failure(.notHTTPS(raw)) } if scheme != "https" { return .failure(.notHTTPS(raw)) }
var c = components guard components.user == nil, components.password == nil,
c.path = "" components.query == nil, components.fragment == nil else {
c.fragment = nil return .failure(.invalidURL(raw))
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")!
} }
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)) return .success(ServerConfig(baseURL: base, livekitURL: livekit))
} }
+10 -2
View File
@@ -15,7 +15,10 @@ enum TLSPinning {
static let host = "192.168.86.2" static let host = "192.168.86.2"
static func session() -> URLSession { static func session() -> URLSession {
let config = URLSessionConfiguration.default let config = URLSessionConfiguration.ephemeral
config.urlCache = nil
config.httpCookieStorage = nil
config.urlCredentialStorage = nil
config.timeoutIntervalForRequest = 15 config.timeoutIntervalForRequest = 15
return URLSession(configuration: config, delegate: PinnedTrustDelegate(), delegateQueue: nil) 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) { func urlSession(_ session: URLSession, didReceive challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {
guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust, guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust,
let trust = challenge.protectionSpace.serverTrust else { let trust = challenge.protectionSpace.serverTrust else {
+17 -3
View File
@@ -42,22 +42,36 @@ enum TokenError: LocalizedError {
final class TokenClient { final class TokenClient {
let config: ServerConfig let config: ServerConfig
private let session: URLSession 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.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 { 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")) var request = URLRequest(url: config.baseURL.appendingPathComponent("token"))
request.httpMethod = "POST" request.httpMethod = "POST"
request.setValue("Bearer \(bearerToken)", forHTTPHeaderField: "Authorization")
request.timeoutInterval = 10 request.timeoutInterval = 10
let (data, response) = try await session.data(for: request) let (data, response) = try await session.data(for: request)
guard let http = response as? HTTPURLResponse else { guard let http = response as? HTTPURLResponse else {
throw TokenError.decodeFailed throw TokenError.decodeFailed
} }
guard (200..<300).contains(http.statusCode) else { 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) throw TokenError.httpStatus(http.statusCode, detail)
} }
do { do {
+150
View File
@@ -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:[email protected]", "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)
}
}
+20 -2
View File
@@ -11,6 +11,24 @@ final class VoiceUITests: XCTestCase {
app.launchArguments = ["--ui-testing", "--reset-ui-testing"] 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() { func testWelcomeNavigationAndOfflineRecovery() {
app.launch() app.launch()
XCTAssertTrue(app.buttons["Start chatting"].waitForExistence(timeout: 10)) XCTAssertTrue(app.buttons["Start chatting"].waitForExistence(timeout: 10))
@@ -45,8 +63,8 @@ final class VoiceUITests: XCTestCase {
server.tap() server.tap()
server.typeText(String(repeating: XCUIKeyboardKey.delete.rawValue, count: invalid.count) + " https://127.0.0.1:1 ") server.typeText(String(repeating: XCUIKeyboardKey.delete.rawValue, count: invalid.count) + " https://127.0.0.1:1 ")
app.buttons["Save server address"].tap() 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)) 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 ?? "" let unchanged = server.value as? String ?? ""
server.tap() server.tap()
@@ -108,7 +126,7 @@ final class VoiceUITests: XCTestCase {
XCTAssertTrue(app.buttons["Start chatting"].waitForExistence(timeout: 10)) XCTAssertTrue(app.buttons["Start chatting"].waitForExistence(timeout: 10))
XCTAssertTrue(app.buttons["Start chatting"].isHittable) XCTAssertTrue(app.buttons["Start chatting"].isHittable)
app.tabBars.buttons["Activity"].tap() 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() app.buttons["Settings"].tap()
XCTAssertTrue(app.navigationBars["Settings"].exists) XCTAssertTrue(app.navigationBars["Settings"].exists)
capture("Accessibility text size") capture("Accessibility text size")
+20
View File
@@ -10,15 +10,19 @@
1813A1CB0921D04FCAEC92D0 /* TranscriptDedupeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAEF89B2DEEE68F7168A8014 /* TranscriptDedupeTests.swift */; }; 1813A1CB0921D04FCAEC92D0 /* TranscriptDedupeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAEF89B2DEEE68F7168A8014 /* TranscriptDedupeTests.swift */; };
1ECD63F10A8EAF33FD2A5B62 /* VoiceApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FED8976B60FBC13BC980FCB /* VoiceApp.swift */; }; 1ECD63F10A8EAF33FD2A5B62 /* VoiceApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FED8976B60FBC13BC980FCB /* VoiceApp.swift */; };
28659D009399CA9C4FE57B74 /* ControlChannel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61C71C48230C683D199D6F4B /* ControlChannel.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 */; }; 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 */; };
4B9A3D956FB6CB7F6A231955 /* AuthClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC751BFB00B04C73D19C714 /* AuthClient.swift */; };
57E6476135CE30495E02D723 /* VoiceUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8309AC6885ED7EB0047AE211 /* VoiceUITests.swift */; }; 57E6476135CE30495E02D723 /* VoiceUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8309AC6885ED7EB0047AE211 /* VoiceUITests.swift */; };
64291BBC8D0DAD47E07E94E0 /* ConversationState.swift in Sources */ = {isa = PBXBuildFile; fileRef = A0C23305A6C55A83F8617D9D /* ConversationState.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 */; }; 70E4B860147494D9657E8C01 /* VoiceLiveTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F6A697985B36B3A1A3F2AD5 /* VoiceLiveTests.swift */; };
720C26EC163AE46713B02DD8 /* TLSPinning.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9B40076934C28BA4B92D808 /* TLSPinning.swift */; }; 720C26EC163AE46713B02DD8 /* TLSPinning.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9B40076934C28BA4B92D808 /* TLSPinning.swift */; };
7A8CC686530A5920338EDF5A /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 26922A8D6467BD51ED45B63B /* Assets.xcassets */; }; 7A8CC686530A5920338EDF5A /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 26922A8D6467BD51ED45B63B /* Assets.xcassets */; };
89B47754E5C39418BFD839E6 /* TranscriptHistory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68A45228EF59186CFC219F2E /* TranscriptHistory.swift */; }; 89B47754E5C39418BFD839E6 /* TranscriptHistory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68A45228EF59186CFC219F2E /* TranscriptHistory.swift */; };
8DE1363CC0119B5AF9E4E117 /* ConnectionTestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC4B9073EE8EAB51F8C55760 /* ConnectionTestView.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 */; }; 943BCE971A01F220FAE9E250 /* TaskPanelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E85BACA267ADD462306C9920 /* TaskPanelView.swift */; };
9461C0886FA294FDD9742E51 /* TranscriptListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD31F76E1880AC76ED31DD4D /* TranscriptListView.swift */; }; 9461C0886FA294FDD9742E51 /* TranscriptListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD31F76E1880AC76ED31DD4D /* TranscriptListView.swift */; };
98BA1A0F92DB7B98A8BA7814 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 48F76881B4AE51DEE74511CC /* SettingsView.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 */; }; D958BE8EE0FF9568F8FABB11 /* Nearlight.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA6FA2A93116B237CEA712E6 /* Nearlight.swift */; };
D9A0059E342955A06A021D6C /* LiveKit in Frameworks */ = {isa = PBXBuildFile; productRef = A6BAD55715F383C0DB5F306F /* LiveKit */; }; D9A0059E342955A06A021D6C /* LiveKit in Frameworks */ = {isa = PBXBuildFile; productRef = A6BAD55715F383C0DB5F306F /* LiveKit */; };
EE0C5762EC97B123A1880FFB /* Voices.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E5DF207EDB787F63CB3A64D /* Voices.swift */; }; 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 */; }; FC696C6E74057D4C93E6DA67 /* ConversationStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDD0AA8B0E9B45A325D1E5E7 /* ConversationStateTests.swift */; };
/* End PBXBuildFile section */ /* End PBXBuildFile section */
@@ -56,12 +61,14 @@
/* Begin PBXFileReference section */ /* Begin PBXFileReference section */
0FC167F6658CFEC9546A7E9F /* AgentMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentMonitor.swift; sourceTree = "<group>"; }; 0FC167F6658CFEC9546A7E9F /* AgentMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentMonitor.swift; sourceTree = "<group>"; };
20A82F8996DEA166C94A7956 /* ServerReplacementCoordinatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerReplacementCoordinatorTests.swift; sourceTree = "<group>"; }; 20A82F8996DEA166C94A7956 /* ServerReplacementCoordinatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerReplacementCoordinatorTests.swift; sourceTree = "<group>"; };
262B5A712708421C35ED6AC0 /* AuthTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthTests.swift; sourceTree = "<group>"; };
26922A8D6467BD51ED45B63B /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; }; 26922A8D6467BD51ED45B63B /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
2A4C3CE2CA6FF828452803D1 /* Voice.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Voice.app; sourceTree = BUILT_PRODUCTS_DIR; }; 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 = "<group>"; }; 2E5DF207EDB787F63CB3A64D /* Voices.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Voices.swift; sourceTree = "<group>"; };
2FED8976B60FBC13BC980FCB /* VoiceApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceApp.swift; sourceTree = "<group>"; }; 2FED8976B60FBC13BC980FCB /* VoiceApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceApp.swift; sourceTree = "<group>"; };
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>"; };
536E3E89F4BB09D631744F3F /* KeychainTokenStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainTokenStore.swift; sourceTree = "<group>"; };
5759EB570DB87BBC121D946D /* VoiceUITests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = VoiceUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 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 = "<group>"; }; 5F6A697985B36B3A1A3F2AD5 /* VoiceLiveTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceLiveTests.swift; sourceTree = "<group>"; };
61C71C48230C683D199D6F4B /* ControlChannel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ControlChannel.swift; sourceTree = "<group>"; }; 61C71C48230C683D199D6F4B /* ControlChannel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ControlChannel.swift; sourceTree = "<group>"; };
@@ -69,6 +76,7 @@
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>"; };
8309AC6885ED7EB0047AE211 /* VoiceUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceUITests.swift; sourceTree = "<group>"; }; 8309AC6885ED7EB0047AE211 /* VoiceUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceUITests.swift; sourceTree = "<group>"; };
87DD77AA383E1ECCEEEFB399 /* LightwellView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LightwellView.swift; sourceTree = "<group>"; }; 87DD77AA383E1ECCEEEFB399 /* LightwellView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LightwellView.swift; sourceTree = "<group>"; };
96278769DD369C629F18E475 /* LoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginView.swift; sourceTree = "<group>"; };
9681E7410B684D2D38752A70 /* TokenClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TokenClient.swift; sourceTree = "<group>"; }; 9681E7410B684D2D38752A70 /* TokenClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TokenClient.swift; sourceTree = "<group>"; };
A0C23305A6C55A83F8617D9D /* ConversationState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationState.swift; sourceTree = "<group>"; }; A0C23305A6C55A83F8617D9D /* ConversationState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationState.swift; sourceTree = "<group>"; };
AC4B9073EE8EAB51F8C55760 /* ConnectionTestView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectionTestView.swift; sourceTree = "<group>"; }; AC4B9073EE8EAB51F8C55760 /* ConnectionTestView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectionTestView.swift; sourceTree = "<group>"; };
@@ -77,8 +85,10 @@
C0B60FBBB9C47BB9ADACA7CA /* TaskStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskStore.swift; sourceTree = "<group>"; }; C0B60FBBB9C47BB9ADACA7CA /* TaskStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskStore.swift; sourceTree = "<group>"; };
C22975C69240AACD3418091F /* VoiceTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = VoiceTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 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 = "<group>"; }; CA6FA2A93116B237CEA712E6 /* Nearlight.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Nearlight.swift; sourceTree = "<group>"; };
CDDA0509C2DF2504AA50CBA9 /* AuthStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthStore.swift; sourceTree = "<group>"; };
D82D665165A7BC1A4CBCCC6F /* TranscriptHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranscriptHandler.swift; sourceTree = "<group>"; }; D82D665165A7BC1A4CBCCC6F /* TranscriptHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranscriptHandler.swift; sourceTree = "<group>"; };
D9B40076934C28BA4B92D808 /* TLSPinning.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TLSPinning.swift; sourceTree = "<group>"; }; D9B40076934C28BA4B92D808 /* TLSPinning.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TLSPinning.swift; sourceTree = "<group>"; };
DAC751BFB00B04C73D19C714 /* AuthClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthClient.swift; sourceTree = "<group>"; };
E85BACA267ADD462306C9920 /* TaskPanelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskPanelView.swift; sourceTree = "<group>"; }; E85BACA267ADD462306C9920 /* TaskPanelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskPanelView.swift; sourceTree = "<group>"; };
EB58EEF9490F81FB2E1CE31C /* VoiceSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceSession.swift; sourceTree = "<group>"; }; EB58EEF9490F81FB2E1CE31C /* VoiceSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceSession.swift; sourceTree = "<group>"; };
F5683993A1DE677121FFA1F3 /* ServerConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerConfig.swift; sourceTree = "<group>"; }; F5683993A1DE677121FFA1F3 /* ServerConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerConfig.swift; sourceTree = "<group>"; };
@@ -132,6 +142,8 @@
6B2AB34274D9B37E77178C6C /* Networking */ = { 6B2AB34274D9B37E77178C6C /* Networking */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
DAC751BFB00B04C73D19C714 /* AuthClient.swift */,
536E3E89F4BB09D631744F3F /* KeychainTokenStore.swift */,
F5683993A1DE677121FFA1F3 /* ServerConfig.swift */, F5683993A1DE677121FFA1F3 /* ServerConfig.swift */,
D9B40076934C28BA4B92D808 /* TLSPinning.swift */, D9B40076934C28BA4B92D808 /* TLSPinning.swift */,
9681E7410B684D2D38752A70 /* TokenClient.swift */, 9681E7410B684D2D38752A70 /* TokenClient.swift */,
@@ -154,6 +166,7 @@
children = ( children = (
3F3FA9F420065D2127B0AEC0 /* ConversationView.swift */, 3F3FA9F420065D2127B0AEC0 /* ConversationView.swift */,
87DD77AA383E1ECCEEEFB399 /* LightwellView.swift */, 87DD77AA383E1ECCEEEFB399 /* LightwellView.swift */,
96278769DD369C629F18E475 /* LoginView.swift */,
BD31F76E1880AC76ED31DD4D /* TranscriptListView.swift */, BD31F76E1880AC76ED31DD4D /* TranscriptListView.swift */,
); );
path = Conversation; path = Conversation;
@@ -183,6 +196,7 @@
CE49A38E40AAF659A9281B85 /* App */ = { CE49A38E40AAF659A9281B85 /* App */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
CDDA0509C2DF2504AA50CBA9 /* AuthStore.swift */,
CA6FA2A93116B237CEA712E6 /* Nearlight.swift */, CA6FA2A93116B237CEA712E6 /* Nearlight.swift */,
6D0D153557BB1C756ED04C61 /* SettingsStore.swift */, 6D0D153557BB1C756ED04C61 /* SettingsStore.swift */,
2FED8976B60FBC13BC980FCB /* VoiceApp.swift */, 2FED8976B60FBC13BC980FCB /* VoiceApp.swift */,
@@ -194,6 +208,7 @@
DC8495F2B27E1770E6620A05 /* Tests */ = { DC8495F2B27E1770E6620A05 /* Tests */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
262B5A712708421C35ED6AC0 /* AuthTests.swift */,
BDD0AA8B0E9B45A325D1E5E7 /* ConversationStateTests.swift */, BDD0AA8B0E9B45A325D1E5E7 /* ConversationStateTests.swift */,
20A82F8996DEA166C94A7956 /* ServerReplacementCoordinatorTests.swift */, 20A82F8996DEA166C94A7956 /* ServerReplacementCoordinatorTests.swift */,
FAEF89B2DEEE68F7168A8014 /* TranscriptDedupeTests.swift */, FAEF89B2DEEE68F7168A8014 /* TranscriptDedupeTests.swift */,
@@ -346,11 +361,15 @@
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
C00D1878C2AB3D8B6F9562B6 /* AgentMonitor.swift in Sources */, C00D1878C2AB3D8B6F9562B6 /* AgentMonitor.swift in Sources */,
4B9A3D956FB6CB7F6A231955 /* AuthClient.swift in Sources */,
6488E54A6D93278C04D870E2 /* AuthStore.swift in Sources */,
8DE1363CC0119B5AF9E4E117 /* ConnectionTestView.swift in Sources */, 8DE1363CC0119B5AF9E4E117 /* ConnectionTestView.swift in Sources */,
28659D009399CA9C4FE57B74 /* ControlChannel.swift in Sources */, 28659D009399CA9C4FE57B74 /* ControlChannel.swift in Sources */,
64291BBC8D0DAD47E07E94E0 /* ConversationState.swift in Sources */, 64291BBC8D0DAD47E07E94E0 /* ConversationState.swift in Sources */,
36DF44D9A7896F18A064847D /* ConversationView.swift in Sources */, 36DF44D9A7896F18A064847D /* ConversationView.swift in Sources */,
F09DD789D07F49C46A659A4F /* KeychainTokenStore.swift in Sources */,
A66BC894BEEC870D879F991F /* LightwellView.swift in Sources */, A66BC894BEEC870D879F991F /* LightwellView.swift in Sources */,
93F179C1E302E4868B9AFC71 /* LoginView.swift in Sources */,
D958BE8EE0FF9568F8FABB11 /* Nearlight.swift in Sources */, D958BE8EE0FF9568F8FABB11 /* Nearlight.swift in Sources */,
302BEC55CA9A10E49C307977 /* ServerConfig.swift in Sources */, 302BEC55CA9A10E49C307977 /* ServerConfig.swift in Sources */,
A94F6C509E3FE215115B2C58 /* SettingsStore.swift in Sources */, A94F6C509E3FE215115B2C58 /* SettingsStore.swift in Sources */,
@@ -381,6 +400,7 @@
isa = PBXSourcesBuildPhase; isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
2D1633BB6B8C3E7938A46A42 /* AuthTests.swift in Sources */,
FC696C6E74057D4C93E6DA67 /* ConversationStateTests.swift in Sources */, FC696C6E74057D4C93E6DA67 /* ConversationStateTests.swift in Sources */,
BCAB602C722D74F6C96C5E36 /* ServerReplacementCoordinatorTests.swift in Sources */, BCAB602C722D74F6C96C5E36 /* ServerReplacementCoordinatorTests.swift in Sources */,
1813A1CB0921D04FCAEC92D0 /* TranscriptDedupeTests.swift in Sources */, 1813A1CB0921D04FCAEC92D0 /* TranscriptDedupeTests.swift in Sources */,
@@ -0,0 +1,141 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1430"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
runPostActionsOnFailure = "NO">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "NO"
buildForProfiling = "NO"
buildForArchiving = "NO"
buildForAnalyzing = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "1385EEF29189F331F2634382"
BuildableName = "VoiceTests.xctest"
BlueprintName = "VoiceTests_iOS"
ReferencedContainer = "container:Voice.xcodeproj">
</BuildableReference>
</BuildActionEntry>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "NO"
buildForProfiling = "NO"
buildForArchiving = "NO"
buildForAnalyzing = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "1C8CC2859E99A4AC83FC6374"
BuildableName = "VoiceUITests.xctest"
BlueprintName = "VoiceUITests"
ReferencedContainer = "container:Voice.xcodeproj">
</BuildableReference>
</BuildActionEntry>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "57E3A292BDE525A22EB7ADE4"
BuildableName = "Voice.app"
BlueprintName = "Voice_iOS"
ReferencedContainer = "container:Voice.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
onlyGenerateCoverageForSpecifiedTargets = "NO">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "57E3A292BDE525A22EB7ADE4"
BuildableName = "Voice.app"
BlueprintName = "Voice_iOS"
ReferencedContainer = "container:Voice.xcodeproj">
</BuildableReference>
</MacroExpansion>
<Testables>
<TestableReference
skipped = "NO"
parallelizable = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "1385EEF29189F331F2634382"
BuildableName = "VoiceTests.xctest"
BlueprintName = "VoiceTests_iOS"
ReferencedContainer = "container:Voice.xcodeproj">
</BuildableReference>
</TestableReference>
<TestableReference
skipped = "NO"
parallelizable = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "1C8CC2859E99A4AC83FC6374"
BuildableName = "VoiceUITests.xctest"
BlueprintName = "VoiceUITests"
ReferencedContainer = "container:Voice.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
<CommandLineArguments>
</CommandLineArguments>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "57E3A292BDE525A22EB7ADE4"
BuildableName = "Voice.app"
BlueprintName = "Voice_iOS"
ReferencedContainer = "container:Voice.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "57E3A292BDE525A22EB7ADE4"
BuildableName = "Voice.app"
BlueprintName = "Voice_iOS"
ReferencedContainer = "container:Voice.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
+25
View File
@@ -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. 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. 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 ## Preserve behavior across UI changes
- Observe nested observable objects directly where their data is rendered. Watching - Observe nested observable objects directly where their data is rendered. Watching
+51
View File
@@ -6,6 +6,57 @@ Reusable implementation, testing, device and deployment lessons are in
[ENGINEERING_LEARNINGS.md](ENGINEERING_LEARNINGS.md). September 12 test/install [ENGINEERING_LEARNINGS.md](ENGINEERING_LEARNINGS.md). September 12 test/install
results below are historical evidence, not a September 19 health check. 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 ## Current direction
Keep **Qwen 2.5**. The user explicitly canceled the earlier request to switch to Keep **Qwen 2.5**. The user explicitly canceled the earlier request to switch to
+11
View File
@@ -1,4 +1,15 @@
name: Voice name: Voice
schemes:
Voice_iOS:
build:
targets:
Voice_iOS: all
VoiceTests_iOS: [test]
VoiceUITests: [test]
test:
targets:
- VoiceTests_iOS
- VoiceUITests
options: options:
bundleIdPrefix: dev.shane bundleIdPrefix: dev.shane
deploymentTarget: deploymentTarget: