feat: authorize iOS Hermes accounts and secure session isolation

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

No files matched your search

@@ -4,26 +4,32 @@ import SwiftUI
/// Owns one session across all tabs. Changing tabs never ends a conversation.
struct ConversationView: View {
@ObservedObject var settings: SettingsStore
@ObservedObject var auth: AuthStore
let user: AuthUser
let bearerToken: String
@State private var session: VoiceSession
@State private var activeServerURL: String
@State private var pendingServerURL: String?
@State private var isReplacingSession = false
@State private var selectedTab = 0
init(settings: SettingsStore) {
init(settings: SettingsStore, auth: AuthStore, user: AuthUser, bearerToken: String) {
self.auth = auth
self.user = user
self.bearerToken = bearerToken
self.settings = settings
let url = settings.trimmedServerURL
_activeServerURL = State(initialValue: url)
_session = State(initialValue: Self.makeSession(url, settings: settings))
_session = State(initialValue: Self.makeSession(url, settings: settings, bearerToken: bearerToken, userID: user.id))
}
private static func makeSession(_ url: String, settings: SettingsStore) -> VoiceSession {
private static func makeSession(_ url: String, settings: SettingsStore, bearerToken: String, userID: String) -> VoiceSession {
guard case .success(let config) = ServerConfig.parse(url) else {
preconditionFailure("Stored server URL failed validation; init sanitizes to a valid value.")
}
let session = VoiceSession(config: config, tokenClient: TokenClient(config: config), settings: settings)
let session = VoiceSession(config: config, tokenClient: TokenClient(config: config, bearerToken: bearerToken), settings: settings, userID: userID)
#if DEBUG
if ProcessInfo.processInfo.arguments.contains("--ui-fixtures") {
if userID == "offline-fixture", ProcessInfo.processInfo.arguments.contains("--ui-fixtures") {
session.transcriptHandler.receiveDataTranscript(role: .user, text: "Help me plan a quiet weekend.")
session.transcriptHandler.receiveDataTranscript(role: .agent, text: "Let’s leave some room to breathe. A walk, a good book, and time for your favorite music would be a lovely start.")
session.taskStore.injectSampleEvents()
@@ -45,7 +51,12 @@ struct ConversationView: View {
TabView(selection: $selectedTab) {
NavigationStack {
ConversationContent(settings: settings, session: session)
.toolbar { settingsLink }
.toolbar {
settingsLink
ToolbarItem(placement: .topBarLeading) {
Button("Sign out") { Task { await auth.logout { await session.disconnect() } } }
}
}
}
.tabItem { Label("Talk", systemImage: "bubble.left.and.bubble.right") }
.tag(0)
@@ -57,7 +68,16 @@ struct ConversationView: View {
.onChange(of: settings.textMode) { _, enabled in
Task { await session.applyTextMode(enabled) }
}
.onChange(of: settings.serverURL) { _, value in replaceServer(value) }
.onChange(of: settings.serverURL) { _, value in
if auth.fixture { replaceServer(value) }
}
.onAppear {
auth.disconnectAccount = { [weak session] in await session?.disconnect() }
session.onUnauthorized = { [weak auth, weak session] in
await auth?.logout { await session?.disconnect() }
}
}
.disabled(auth.isBusy || auth.token == nil)
}
@ToolbarContentBuilder private var settingsLink: some ToolbarContent {
@@ -84,7 +104,7 @@ struct ConversationView: View {
guard next != activeServerURL else { continue }
await session.disconnect()
activeServerURL = next
session = Self.makeSession(next, settings: settings)
session = Self.makeSession(next, settings: settings, bearerToken: bearerToken, userID: user.id)
}
isReplacingSession = false
}
@@ -167,6 +187,10 @@ private struct ConversationContent: View {
if failureNotice != nil { failureBanner }
} else {
statusStrip
if let activity = session.assistantActivity {
Label(activity, systemImage: "clock").font(.callout)
.padding().accessibilityIdentifier("assistant.progress")
}
if session.isReconnecting { reconnectBanner }
if failureNotice != nil { failureBanner }
TranscriptListView(events: transcripts.events, onClear: transcripts.clearAll)
@@ -610,4 +634,4 @@ extension VoiceSession {
}
}
#Preview { ConversationView(settings: SettingsStore()) }
#Preview { AuthenticationRoot(settings: SettingsStore()) }
@@ -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 {
@ObservedObject var settings: SettingsStore
var bearerToken: String = ""
@State private var isBusy = false
@State private var result: Result<TokenResponse, Error>?
@State private var urlError: String?
@@ -67,7 +68,7 @@ struct ConnectionTestView: View {
case .success(let config):
isBusy = true
Task {
let client = TokenClient(config: config)
let client = TokenClient(config: config, bearerToken: bearerToken)
do {
let response = try await client.fetchToken()
result = .success(response)
+2 -63
View File
@@ -13,7 +13,6 @@ struct SettingsView: View {
@State private var urlError: String?
@State private var showVoicePicker = false
@State private var savedURLConfirmation: String?
@State private var showModelPicker = false
@FocusState private var urlFocused: Bool
init(settings: SettingsStore, session: VoiceSession) {
self.settings = settings
@@ -46,19 +45,6 @@ struct SettingsView: View {
} footer: {
Text("Voice and typing preferences are saved on this iPhone. You can switch modes from the Talk tab.")
}
Section {
if session.isReady, !session.models.isEmpty {
Button {
showModelPicker = true
} label: {
LabeledContent("Model", value: session.selectedModel ?? settings.modelID ?? "Server default")
}
} else {
LabeledContent("Model", value: settings.modelID ?? "Server default")
Text("Connect from Talk to see the models available on your server.")
.font(.footnote).foregroundStyle(Nearlight.quiet)
}
} header: { Text("Intelligence") }
Section {
Text("Server URL")
.font(.footnote.weight(.semibold))
@@ -106,7 +92,7 @@ struct SettingsView: View {
.foregroundStyle(Nearlight.quiet)
}
NavigationLink("Connection test") {
ConnectionTestView(settings: settings)
ConnectionTestView(settings: settings, bearerToken: session.bootstrapBearerToken)
}
} header: {
Text("Connection")
@@ -120,9 +106,7 @@ struct SettingsView: View {
.sheet(isPresented: $showVoicePicker) {
VoicePickerView(settings: settings, session: session)
}
.sheet(isPresented: $showModelPicker) {
ModelPickerView(settings: settings, session: session)
}
}
/// Validates the draft and persists it only on success. On failure the
@@ -190,51 +174,6 @@ struct VoicePickerView: View {
}
}
struct ModelPickerView: View {
@ObservedObject var settings: SettingsStore
@ObservedObject var session: VoiceSession
@Environment(\.dismiss) private var dismiss
private var activeModelID: String? {
if !session.models.isEmpty,
let selected = session.selectedModel ?? settings.modelID,
session.models.contains(where: { $0.id == selected }) {
return selected
}
return session.models.first?.id
}
var body: some View {
NavigationStack {
List {
ForEach(session.models, id: \.id) { model in
Button {
settings.modelID = model.id
Task { await session.selectModel(model.id) }
dismiss()
} label: {
HStack {
Text(model.name ?? model.id)
if activeModelID == model.id {
Spacer()
Image(systemName: "checkmark")
.foregroundStyle(Nearlight.rosewood)
}
}
}
.accessibilityAddTraits(activeModelID == model.id ? .isSelected : [])
}
}
.navigationTitle("Model")
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
}
}
}
}
}
#Preview {
let previewSettings = SettingsStore()
guard case .success(let previewConfig) = ServerConfig.parse(SettingsStore.defaultServerURL) else {