feat: authorize iOS Hermes accounts and secure session isolation
This commit is contained in:
1 parent
9bdaea2408
commit
bebaf59466
23 files changed
+911
-185
No files matched your search
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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),
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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:
|
||||||
|
|||||||
Reference in new issue
Block a user