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

+85 -49
View File
@@ -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<Void, Error>
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) ?? "<binary>", 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),