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
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user