feat: consolidate iOS with API and web preserving history
Rebuild & Deploy / rebuild (push) Failing after 23s

This commit is contained in:
snowspeeder committed 2026-10-04 07:23:49 -04:00
commit d735ee72fa
102 files changed
+13077 -258

No files matched your search

+103
View File
@@ -0,0 +1,103 @@
import Foundation
@MainActor
final class AuthStore: ObservableObject {
@Published private(set) var user: AuthUser?
@Published private(set) var token: String?
@Published private(set) var isBusy = false
@Published private(set) var error: String?
var disconnectAccount: (() async -> Void)?
let config: ServerConfig
private let client: AuthClient
private let tokens: TokenStore
private var generation = 0
private var operation: Task<AuthUser, Error>?
private var loginOperation: Task<AuthLoginResponse, Error>?
let fixture: Bool
init(config: ServerConfig, client: AuthClient? = nil, tokens: TokenStore = KeychainTokenStore(), fixture: Bool = false) {
self.config = config
self.client = client ?? AuthClient(config: config)
self.tokens = tokens
self.fixture = fixture
if fixture {
user = AuthUser(id: "offline-fixture", username: "fixture", displayName: "Offline fixture", role: "user", status: "ready")
token = "offline-fixture-not-a-server-token"
}
}
func restore() async {
guard !fixture, !isBusy, user == nil else { return }
generation += 1
let current = generation
isBusy = true
do {
guard let saved = try tokens.token(origin: config.baseURL.absoluteString) else { isBusy = false; return }
let task = Task { try await client.me(token: saved) }
operation = task
let verified = try await task.value
guard current == generation, !Task.isCancelled else { return }
guard ["ready", "provisioning"].contains(verified.status) else { throw AuthError.status(403) }
user = verified
token = saved
} catch {
guard current == generation else { return }
if let authError = error as? AuthError,
case .status(let status) = authError, status == 401 || status == 403 {
try? tokens.save(nil, origin: config.baseURL.absoluteString)
self.error = "Please sign in to continue."
} else {
// An outage is not revocation. Preserve the origin-scoped bearer
// for a subsequent launch/retry, but expose no unverified user UI.
self.error = "Could not verify your saved sign-in. Check the connection or sign in again."
}
}
guard current == generation else { return }
isBusy = false
operation = nil
}
func login(username: String, password: String) async {
guard !isBusy else { return }
if fixture {
user = AuthUser(id: "offline-" + username, username: username, displayName: username, role: "user", status: "ready")
token = "offline-fixture-not-a-server-token"
return
}
generation += 1
let current = generation
isBusy = true
error = nil
do {
let task = Task { try await client.login(username: username, password: password) }
loginOperation = task
let response = try await task.value
guard current == generation, !Task.isCancelled else { return }
guard !response.token.isEmpty, ["ready", "provisioning"].contains(response.user.status), response.expiresAt > Date().timeIntervalSince1970 else { throw AuthError.status(403) }
try tokens.save(response.token, origin: config.baseURL.absoluteString)
token = response.token
user = response.user
} catch {
guard current == generation else { return }
self.error = ConnectionFailure.message(error, server: config.baseURL)
}
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
}
}
+121
View File
@@ -0,0 +1,121 @@
import SwiftUI
import UIKit
/// Nearlight design tokens (design spec: local://ui-design-spec.md).
///
/// Views reference semantic roles; the primitive hex values live only here.
/// All custom surfaces are opaque; native system materials are untouched.
enum Nearlight {
// MARK: Palette (light / dark variants)
/// Opal screen background: luminous pink-white #FFFDFD / deep plum #201820.
static let ground = Color(light: UIColor(red: 255 / 255, green: 253 / 255, blue: 253 / 255, alpha: 1),
dark: UIColor(red: 32 / 255, green: 24 / 255, blue: 32 / 255, alpha: 1))
/// Controls, grouped rows, dock: #FFF7FA / #302330.
static let surface = Color(light: UIColor(red: 255 / 255, green: 247 / 255, blue: 250 / 255, alpha: 1),
dark: UIColor(red: 48 / 255, green: 35 / 255, blue: 48 / 255, alpha: 1))
/// The square persona's pale pink stage: #FFF2F7 / #3B2836.
static let personaSurface = Color(light: UIColor(red: 255 / 255, green: 242 / 255, blue: 247 / 255, alpha: 1),
dark: UIColor(red: 59 / 255, green: 40 / 255, blue: 54 / 255, alpha: 1))
/// Primary text: readable dark plum #302B40 / #FFF2F7.
static let ink = Color(light: UIColor(red: 48 / 255, green: 43 / 255, blue: 64 / 255, alpha: 1),
dark: UIColor(red: 255 / 255, green: 242 / 255, blue: 247 / 255, alpha: 1))
/// Supporting text, inactive icons: plum #706074 / #CBB6C7.
static let quiet = Color(light: UIColor(red: 112 / 255, green: 96 / 255, blue: 116 / 255, alpha: 1),
dark: UIColor(red: 203 / 255, green: 182 / 255, blue: 199 / 255, alpha: 1))
/// Opal action rose #A64270 / #F0A8C8; retains the existing semantic API.
static let rosewood = Color(light: UIColor(red: 166 / 255, green: 66 / 255, blue: 112 / 255, alpha: 1),
dark: UIColor(red: 240 / 255, green: 168 / 255, blue: 200 / 255, alpha: 1))
/// Errors and destructive actions.
static let alert = Color(light: UIColor(red: 0.675, green: 0.208, blue: 0.282, alpha: 1),
dark: UIColor(red: 1.0, green: 0.604, blue: 0.675, alpha: 1))
// MARK: Derived roles
/// Decorative separator: Ink at 14% over its parent.
static var separator: Color { ink.opacity(0.14) }
/// Selected wash: Rosewood at 10% over Surface.
static var selectedWash: Color { rosewood.opacity(0.10) }
/// Error wash: Alert at 8% over Surface.
static var errorWash: Color { alert.opacity(0.08) }
/// Primary-action label: white on strong rose, deep plum on the dark-mode pink.
static let onRosewood = Color(light: .white,
dark: UIColor(red: 32 / 255, green: 24 / 255, blue: 32 / 255, alpha: 1))
// MARK: Spacing scale
static let s4: CGFloat = 4
static let s8: CGFloat = 8
static let s12: CGFloat = 12
static let s16: CGFloat = 16
static let s20: CGFloat = 20
static let s24: CGFloat = 24
static let s32: CGFloat = 32
static let s48: CGFloat = 48
// MARK: Radius scale
static let radiusSegment: CGFloat = 8
static let radiusControl: CGFloat = 12
static let radiusDock: CGFloat = 16
// MARK: Motion tokens
/// quick = 0.12s easeOut.
static let quick: Animation = .easeOut(duration: 0.12)
/// standard = 0.22s timingCurve(0.22, 1, 0.36, 1).
static let standard: Animation = .timingCurve(0.22, 1, 0.36, 1, duration: 0.22)
/// settle = 0.32s timingCurve(0.22, 1, 0.36, 1).
static let settle: Animation = .timingCurve(0.22, 1, 0.36, 1, duration: 0.32)
/// cycle = 2.4s easeInOut (Lightwell pulses).
static let cycle: Animation = .easeInOut(duration: 2.4)
}
extension Color {
/// Builds an adaptive color from explicit light/dark UIColors without
/// requiring an asset catalog.
init(light: UIColor, dark: UIColor) {
self.init(uiColor: UIColor { traits in
traits.userInterfaceStyle == .dark ? dark : light
})
}
}
/// Shared typography roles from the spec. SF Pro default design except the
/// rounded welcome heading and wordmark.
enum NearlightFont {
/// Welcome heading: 32 medium, rounded, largeTitle metrics.
static func welcome(_ sizeCategory: DynamicTypeSize) -> Font {
scaled(.largeTitle, base: 32, weight: .medium, design: .rounded, sizeCategory: sizeCategory)
}
/// "Hope" wordmark: 20 semibold, rounded, title3 metrics.
static func wordmark(_ sizeCategory: DynamicTypeSize) -> Font {
scaled(.title3, base: 20, weight: .semibold, design: .rounded, sizeCategory: sizeCategory)
}
/// Section/detail heading: 22 semibold, title2 metrics.
static func heading(_ sizeCategory: DynamicTypeSize) -> Font {
scaled(.title2, base: 22, weight: .semibold, sizeCategory: sizeCategory)
}
/// Control/row label: 17 semibold, body metrics.
static func control(_ sizeCategory: DynamicTypeSize) -> Font {
scaled(.body, base: 17, weight: .semibold, sizeCategory: sizeCategory)
}
/// Status/support: 15 regular, subheadline metrics.
static func support(_ sizeCategory: DynamicTypeSize) -> Font {
scaled(.subheadline, base: 15, weight: .regular, sizeCategory: sizeCategory)
}
/// Speaker/timestamp: 13 medium, footnote metrics.
static func metadata(_ sizeCategory: DynamicTypeSize) -> Font {
scaled(.footnote, base: 13, weight: .medium, sizeCategory: sizeCategory)
}
/// Scales a base size with the given text style via UIFontMetrics and
/// returns a SwiftUI font with the requested weight/design.
private static func scaled(_ textStyle: UIFont.TextStyle, base: CGFloat, weight: Font.Weight,
design: Font.Design = .default, sizeCategory: DynamicTypeSize) -> Font {
// UIFontMetrics scales the base size against the CURRENT Dynamic Type
// setting at render time (no explicit traits: inherits environment).
let scaledSize = UIFontMetrics(forTextStyle: textStyle).scaledValue(for: base)
return .system(size: scaledSize, weight: weight, design: design)
}
}
+69
View File
@@ -0,0 +1,69 @@
import Foundation
import Combine
final class SettingsStore: ObservableObject {
@Published var serverURL: String {
didSet { AppPreferences.store.set(serverURL, forKey: Keys.serverURL) }
}
@Published var voiceID: String {
didSet { AppPreferences.store.set(voiceID, forKey: Keys.voiceID) }
}
@Published var textMode: Bool {
didSet { AppPreferences.store.set(textMode, forKey: Keys.textMode) }
}
private enum Keys {
static let serverURL = "serverURL"
static let voiceID = "voiceID"
static let textMode = "textMode"
}
static let defaultServerURL = "https://192.168.86.2:9178"
init() {
let d = AppPreferences.store
// Sanitize legacy values: earlier versions wrote the field to storage on
// every keystroke, so an invalid half-typed URL could be persisted and
// shipped to the network layer. Fall back to the documented default and
// repair the stored value once.
let storedURL = d.string(forKey: Keys.serverURL) ?? Self.defaultServerURL
switch ServerConfig.parse(storedURL) {
case .success:
serverURL = storedURL
case .failure:
// Legacy invalid value: repair to the documented default.
serverURL = Self.defaultServerURL
d.set(Self.defaultServerURL, forKey: Keys.serverURL)
}
let storedVoice = d.string(forKey: Keys.voiceID)
voiceID = Voices.restoredID(storedVoice)
textMode = d.bool(forKey: Keys.textMode)
d.set(voiceID, forKey: Keys.voiceID)
}
var trimmedServerURL: String {
serverURL.trimmingCharacters(in: .whitespacesAndNewlines)
}
}
enum AppPreferences {
static let store: UserDefaults = {
#if DEBUG
if ProcessInfo.processInfo.arguments.contains("--ui-testing") {
let suite = "dev.shane.voice.ui-testing"
let defaults = UserDefaults(suiteName: suite)!
if ProcessInfo.processInfo.arguments.contains("--reset-ui-testing") {
defaults.removePersistentDomain(forName: suite)
// Deterministic offline errors; UI tests must not contact the NAS.
defaults.set("https://127.0.0.1:1", forKey: "serverURL")
}
if ProcessInfo.processInfo.arguments.contains("--live-testing") {
defaults.set(SettingsStore.defaultServerURL, forKey: "serverURL")
defaults.set(true, forKey: "textMode")
}
return defaults
}
#endif
return .standard
}()
}
+18
View File
@@ -0,0 +1,18 @@
import SwiftUI
import LiveKit
@main
struct VoiceApp: App {
@StateObject private var settings = SettingsStore()
init() {
LiveKitSDK.setLogLevel(.error)
}
var body: some Scene {
WindowGroup {
AuthenticationRoot(settings: settings)
.tint(Nearlight.rosewood)
}
}
}
+64
View File
@@ -0,0 +1,64 @@
import Foundation
struct VoiceOption: Codable, Equatable {
let id: String
let name: String
let tag: String?
var locale: String? = nil
var gender: String? = nil
var details: String {
[locale, gender, tag].compactMap { $0 }.joined(separator: " · ")
}
var displayName: String {
tag.map { "\(name) (\($0))" } ?? name
}
}
struct VoiceCatalogResponse: Decodable {
let defaultVoice: String
let voices: [VoiceOption]
let catalogAvailable: Bool
let message: String?
enum CodingKeys: String, CodingKey {
case voices, message
case defaultVoice = "default_voice"
case catalogAvailable = "catalog_available"
}
}
enum Voices {
static let all: [VoiceOption] = [
VoiceOption(id: "en-US-Ava:DragonHDOmniLatestNeural", name: "Ava", tag: "Dragon HD Omni"),
VoiceOption(id: "en-US-Jenny:DragonHDOmniLatestNeural", name: "Jenny", tag: "Dragon HD Omni"),
VoiceOption(id: "en-US-Guy:DragonHDOmniLatestNeural", name: "Guy", tag: "Dragon HD Omni"),
VoiceOption(id: "en-US-Andrew:DragonHDOmniLatestNeural", name: "Andrew", tag: "Dragon HD Omni"),
VoiceOption(id: "en-US-Aria:DragonHDOmniLatestNeural", name: "Aria", tag: "Dragon HD Omni"),
VoiceOption(id: "en-US-Emma:DragonHDOmniLatestNeural", name: "Emma", tag: "Dragon HD Omni"),
VoiceOption(id: "en-US-Eric:DragonHDOmniLatestNeural", name: "Eric", tag: "Dragon HD Omni"),
VoiceOption(id: "en-US-Brian:DragonHDOmniLatestNeural", name: "Brian", tag: "Dragon HD Omni"),
VoiceOption(id: "en-US-Ashley:DragonHDOmniLatestNeural", name: "Ashley", tag: "Dragon HD Omni"),
VoiceOption(id: "en-US-Richard:DragonHDOmniLatestNeural", name: "Richard", tag: "Dragon HD Omni"),
VoiceOption(id: "en-US-Tina:DragonHDOmniLatestNeural", name: "Tina", tag: "Dragon HD Omni"),
VoiceOption(id: "en-US-Steffan:DragonHDOmniLatestNeural", name: "Steffan", tag: "Dragon HD Omni"),
]
static var defaultID: String { all[0].id }
private static let legacyPersonaIDs: [String: String] = Dictionary(uniqueKeysWithValues: all.compactMap { option in
guard let prefix = option.id.split(separator: ":").first else { return nil }
return ("\(prefix)Neural", option.id)
})
static func restoredID(_ saved: String?) -> String {
guard let saved, saved.range(of: "^[A-Za-z0-9:_-]{1,120}$", options: .regularExpression) != nil else { return defaultID }
if let migrated = legacyPersonaIDs[saved] { return migrated }
return saved.replacingOccurrences(of: ":DragonHDLatestNeural", with: ":DragonHDOmniLatestNeural")
}
static func display(for id: String) -> String? {
all.first { $0.id == id }?.displayName
}
}
+14
View File
@@ -0,0 +1,14 @@
import SwiftUI
import UniformTypeIdentifiers
/// Share through the system transfer service, not a persistent app cache.
/// Named representations carry server bytes unchanged and never expose an origin/token.
struct PrivateChatExport: Transferable {
let data: Data
let filename: String
let type: UTType
static var transferRepresentation: some TransferRepresentation {
DataRepresentation(exportedContentType: .data) { value in value.data }
.suggestedFileName { value in value.filename }
}
}
+298
View File
@@ -0,0 +1,298 @@
import SwiftUI
import UniformTypeIdentifiers
import PhotosUI
import PDFKit
struct ChatAttachment: Codable, Identifiable { var id: String; var filename: String; var mime: String; var size: Int; var extractionStatus: String; var createdAt: Double }
struct ChatArtifact: Codable, Identifiable { var id: String; var title: String; var kind: String; var revision: Int; var content: String?; var createdAt: Double; var updatedAt: Double }
struct ScheduledChatTask: Codable, Identifiable { var id: String; var name: String?; var schedule: String?; var prompt: String?; var enabled: Bool?; var `repeat`: Int? }
struct ChatMemory: Codable, Identifiable { var section: String; var content: String; var revision: String; var id: String { section } }
struct FeatureCapabilities: Decodable { let features: [String: BoolValue] }
/// Capabilities may carry limits/strings alongside bool flags; only explicit true enables UI.
struct BoolValue: Decodable { let enabled: Bool; init(from decoder: Decoder) throws { enabled = (try? decoder.singleValueContainer().decode(Bool.self)) == true } }
@MainActor
final class ChatFeatureStore: ObservableObject {
@Published var enabled: Set<String> = []
@Published var attachments: [ChatAttachment] = []
@Published var artifacts: [ChatArtifact] = []
@Published var tasks: [ScheduledChatTask] = []
@Published var memory: [ChatMemory] = []
@Published var error: String?
@Published var busy = false
@Published var previewText: String?
@Published var previewImage: UIImage?
@Published var previewPDF: Data?
@Published var previewAttachment: ChatAttachment?
@Published var previewLoading = false
@Published var previewError: String?
var onUnauthorized: (() async -> Void)?
private let api: ChatTransport
private var epoch = 0
private var closed = false
init(api: ChatTransport) { self.api = api }
private func call<T: Decodable>(_ path: String, method: String = "GET", body: [String: Any]? = nil, query: [URLQueryItem] = []) async throws -> T {
let current = epoch
let data = try await api.request(path, method: method, body: body, query: query)
guard !closed, current == epoch else { throw CancellationError() }
try Task.checkCancellation()
return try ChatCoding.decoder.decode(T.self, from: data)
}
func load() async {
do {
let caps: FeatureCapabilities = try await call("api/capabilities")
enabled = Set(caps.features.filter { $0.value.enabled }.map(\.key))
if enabled.contains("attachments") { struct Page: Decodable { let attachments: [ChatAttachment] }; let page: Page = try await call("api/attachments"); attachments = page.attachments }
if enabled.contains("artifacts") { struct Page: Decodable { let artifacts: [ChatArtifact] }; let page: Page = try await call("api/artifacts"); artifacts = page.artifacts }
if enabled.contains("scheduled_tasks") { struct Page: Decodable { let tasks: [ScheduledChatTask] }; let page: Page = try await call("api/tasks"); tasks = page.tasks }
if enabled.contains("memory_controls") { struct Page: Decodable { let sections: [ChatMemory] }; let page: Page = try await call("api/memory"); memory = page.sections }
error = nil
} catch { report(error) }
}
private func report(_ value: Error) {
guard !(value is CancellationError), !closed else { return }
if case ChatFailure.status(let code) = value, code == 401 || code == 403 { Task { await onUnauthorized?() }; return }
error = value.localizedDescription
}
func upload(_ data: Data, filename: String, mime: String) async -> ChatAttachment? {
guard !busy, enabled.contains("attachments") else { return nil }
guard data.count <= 8 * 1024 * 1024 else { error = "Choose a file smaller than 8 MiB."; return nil }
busy = true; defer { busy = false }
do {
let current = epoch
struct Envelope: Decodable { let attachment: ChatAttachment }
let bytes = try await api.upload(data: data, filename: filename, mime: mime)
guard current == epoch, !closed else { return nil }
let value = try ChatCoding.decoder.decode(Envelope.self, from: bytes).attachment
attachments.insert(value, at: 0); error = nil; return value
} catch { report(error); return nil }
}
func preview(_ attachment: ChatAttachment) async {
previewAttachment = attachment
previewLoading = true
previewError = nil
previewPDF = nil; previewText = nil; previewImage = nil
defer { if previewAttachment?.id == attachment.id { previewLoading = false } }
do {
let current = epoch
let data = try await api.request("api/attachments/\(attachment.id)/content", method: "GET", body: nil, query: [])
guard current == epoch, !closed, previewAttachment?.id == attachment.id else { return }
if attachment.mime.hasPrefix("image/") {
guard let image = UIImage(data: data) else { throw ChatFailure.invalidResponse }
previewImage = image
} else if attachment.mime == "application/pdf" {
guard PDFDocument(data: data) != nil else { throw ChatFailure.invalidResponse }
previewPDF = data
} else if let text = String(data: data, encoding: .utf8) {
previewText = text
} else {
throw ChatFailure.invalidResponse
}
} catch is CancellationError {
return
} catch {
guard previewAttachment?.id == attachment.id else { return }
previewError = "Couldn’t open \(attachment.filename). Try again."
}
}
func clearPreview() {
previewAttachment = nil; previewLoading = false; previewError = nil
previewText = nil; previewImage = nil; previewPDF = nil
}
func deleteAttachment(_ item: ChatAttachment) async {
await remove("api/attachments/\(item.id)"); if error == nil { attachments.removeAll { $0.id == item.id } }
}
private func remove(_ path: String) async {
do { struct Result: Decodable { let ok: Bool }; let _: Result = try await call(path, method: "DELETE"); error = nil } catch { report(error) }
}
func saveArtifact(_ item: ChatArtifact?, title: String, kind: String, content: String) async {
do {
var body: [String: Any] = ["title": title, "kind": kind, "content": content]
if let item { body["revision"] = item.revision }
struct Result: Decodable { let artifact: ChatArtifact }
let result: Result = try await call(item.map { "api/artifacts/\($0.id)" } ?? "api/artifacts", method: item == nil ? "POST" : "PATCH", body: body)
if let i = artifacts.firstIndex(where: { $0.id == result.artifact.id }) { artifacts[i] = result.artifact } else { artifacts.insert(result.artifact, at: 0) }; error = nil
} catch { report(error) }
}
func detail(_ item: ChatArtifact) async -> ChatArtifact? {
do { struct Result: Decodable { let artifact: ChatArtifact }; let result: Result = try await call("api/artifacts/\(item.id)"); return result.artifact } catch { report(error); return nil }
}
func historical(_ item: ChatArtifact, revision: Int) async -> ChatArtifact? {
do { struct Result: Decodable { let artifact: ChatArtifact }; let result: Result = try await call("api/artifacts/\(item.id)", query: [URLQueryItem(name: "revision", value: String(revision))]); return result.artifact } catch { report(error); return nil }
}
func deleteArtifact(_ item: ChatArtifact) async { await remove("api/artifacts/\(item.id)"); if error == nil { artifacts.removeAll { $0.id == item.id } } }
func saveTask(_ item: ScheduledChatTask?, name: String, schedule: String, prompt: String) async {
do {
struct Result: Decodable { let task: ScheduledChatTask }
let result: Result = try await call(item.map { "api/tasks/\($0.id)" } ?? "api/tasks", method: item == nil ? "POST" : "PATCH", body: ["name": name, "schedule": schedule, "prompt": prompt])
if let i = tasks.firstIndex(where: { $0.id == result.task.id }) { tasks[i] = result.task } else { tasks.insert(result.task, at: 0) }; error = nil
} catch { report(error) }
}
func taskAction(_ item: ScheduledChatTask, action: String) async {
do { struct Result: Decodable { let task: ScheduledChatTask }; let result: Result = try await call("api/tasks/\(item.id)/\(action)", method: "POST"); if let i = tasks.firstIndex(where: { $0.id == item.id }) { tasks[i] = result.task }; error = nil } catch { report(error) }
}
func deleteTask(_ item: ScheduledChatTask) async { await remove("api/tasks/\(item.id)"); if error == nil { tasks.removeAll { $0.id == item.id } } }
func saveMemory(_ item: ChatMemory, content: String) async {
do { let result: ChatMemory = try await call("api/memory/\(item.section)", method: "PUT", body: ["revision": item.revision, "content": content]); if let i = memory.firstIndex(where: { $0.id == item.id }) { memory[i] = result }; error = nil } catch { report(error) }
}
func close() { closed = true; epoch += 1; enabled = []; attachments = []; artifacts = []; tasks = []; memory = []; clearPreview(); error = nil }
}
struct ChatLibraryView: View {
@ObservedObject var store: ChatFeatureStore
@Binding var selectedAttachmentIds: Set<String>
@Environment(\.dismiss) private var dismiss
@State private var importing = false
@State private var photo: PhotosPickerItem?
@State private var showingPreview = false
@State private var loadingCapabilities = true
@State private var artifact: ChatArtifact?
@State private var artifactEditor = false
@State private var title = ""
@State private var kind = "markdown"
@State private var content = ""
@State private var task: ScheduledChatTask?
@State private var taskEditor = false
@State private var name = ""
@State private var schedule = ""
@State private var prompt = ""
@State private var memory: ChatMemory?
@State private var memoryContent = ""
@State private var deleting: (() async -> Void)?
var body: some View {
NavigationStack {
List {
if let error = store.error { Section { Text(error).foregroundStyle(.red); Button("Retry") { Task { await store.load() } } } }
if loadingCapabilities {
ContentUnavailableView("Checking server capabilities", systemImage: "server.rack", description: Text("Only features your account supports are shown."))
} else if store.enabled.isEmpty {
ContentUnavailableView("Library features aren’t available", systemImage: "lock", description: Text("Your server has not enabled private files, documents, schedules, or memory controls for this account."))
}
if store.enabled.contains("attachments") {
Section("Files") {
Button("Import from Files", systemImage: "doc.badge.plus") { importing = true }.disabled(store.busy)
if store.enabled.contains("image_input") { PhotosPicker(selection: $photo, matching: .images) { Label("Import photo", systemImage: "photo") }.disabled(store.busy) }
ForEach(store.attachments) { item in
VStack(alignment: .leading) {
HStack {
Button {
if selectedAttachmentIds.contains(item.id) { selectedAttachmentIds.remove(item.id) }
else if selectedAttachmentIds.count < 8 { selectedAttachmentIds.insert(item.id) }
else { store.error = "Select up to 8 files for one message." }
} label: { Label(item.filename, systemImage: selectedAttachmentIds.contains(item.id) ? "checkmark.circle.fill" : "circle").frame(minHeight: 44) }
.disabled(item.extractionStatus == "failed" || item.extractionStatus == "encrypted" || (item.mime.hasPrefix("image/") && !store.enabled.contains("image_input")))
Button("Preview") { showingPreview = true; Task { await store.preview(item) } }.frame(minHeight: 44)
}
Text("\(ByteCountFormatter.string(fromByteCount: Int64(item.size), countStyle: .file)) · \(item.extractionStatus)").font(.caption).foregroundStyle(.secondary)
}.swipeActions { Button("Delete", role: .destructive) { deleting = { await store.deleteAttachment(item); selectedAttachmentIds.remove(item.id) } } }
}
Text("Select up to 8 files for the next message. Uploading alone does not send them.").font(.footnote).foregroundStyle(.secondary)
}
}
if store.enabled.contains("artifacts") {
Section("Artifacts") {
Button("New artifact", systemImage: "doc.text") { artifact = nil; title = ""; content = ""; kind = "markdown"; artifactEditor = true }
ForEach(store.artifacts) { item in Button { Task { if let detail = await store.detail(item) { artifact = detail; title = detail.title; kind = detail.kind; content = detail.content ?? ""; artifactEditor = true } } } label: { VStack(alignment: .leading) { Text(item.title); Text("Revision \(item.revision) · \(item.kind)").font(.caption).foregroundStyle(.secondary) } }.swipeActions { Button("Delete", role: .destructive) { deleting = { await store.deleteArtifact(item) } } } }
}
}
if store.enabled.contains("scheduled_tasks") {
Section("Scheduled tasks") {
Text("Tasks run on your Hope server. Push notifications and task output history are not available in this app.").font(.footnote).foregroundStyle(.secondary)
Button("New scheduled task", systemImage: "calendar.badge.plus") { task = nil; name = ""; schedule = ""; prompt = ""; taskEditor = true }
ForEach(store.tasks) { item in
VStack(alignment: .leading) {
Button(item.name ?? item.id) { task = item; name = item.name ?? ""; schedule = item.schedule ?? ""; prompt = item.prompt ?? ""; taskEditor = true }.frame(minHeight: 44)
Text(item.schedule ?? "Schedule not provided").font(.caption).foregroundStyle(.secondary)
HStack { Button(item.enabled == false ? "Resume" : "Pause") { Task { await store.taskAction(item, action: item.enabled == false ? "resume" : "pause") } }; Button("Run now") { Task { await store.taskAction(item, action: "run") } } }
}.swipeActions { Button("Delete", role: .destructive) { deleting = { await store.deleteTask(item) } } }
}
}
}
if store.enabled.contains("memory_controls") {
Section("Account memory") {
ForEach(store.memory) { item in Button(item.section.capitalized) { memory = item; memoryContent = item.content } }
Text("Changes may take effect on the next Hermes run or session. Clearing memory does not delete chats or all account data.").font(.footnote).foregroundStyle(.secondary)
}
}
}
.navigationTitle("Library and tools")
.toolbar { ToolbarItem(placement: .confirmationAction) { Button("Done") { dismiss() } } }
.task { await store.load(); loadingCapabilities = false }
.fileImporter(isPresented: $importing, allowedContentTypes: [.plainText, .pdf, .png, .jpeg, UTType(filenameExtension: "md") ?? .plainText, UTType(filenameExtension: "webp") ?? .image]) { result in
if case .success(let url) = result { Task {
let scoped = url.startAccessingSecurityScopedResource(); defer { if scoped { url.stopAccessingSecurityScopedResource() } }
do {
let size = try url.resourceValues(forKeys: [.fileSizeKey]).fileSize ?? 0
guard size <= 8 * 1024 * 1024 else { store.error = "Choose a file smaller than 8 MiB."; return }
let mime: String
switch url.pathExtension.lowercased() { case "md": mime = "text/markdown"; case "pdf": mime = "application/pdf"; case "png": mime = "image/png"; case "jpg", "jpeg": mime = "image/jpeg"; case "webp": mime = "image/webp"; default: mime = "text/plain" }
_ = await store.upload(try Data(contentsOf: url), filename: url.lastPathComponent, mime: mime)
} catch { store.error = "Could not read this file. Select it again to retry." }
} }
}
.onChange(of: photo) { _, value in Task { if let data = try? await value?.loadTransferable(type: Data.self), data.count <= 8 * 1024 * 1024, let image = UIImage(data: data), let jpeg = image.jpegData(compressionQuality: 0.85) { _ = await store.upload(jpeg, filename: "photo.jpg", mime: "image/jpeg") } else if value != nil { store.error = "This photo could not be imported or exceeds 8 MiB. Choose a smaller JPEG or PNG." }; photo = nil } }
.sheet(isPresented: $showingPreview, onDismiss: { store.clearPreview() }) {
NavigationStack {
ScrollView {
if store.previewLoading { ProgressView("Opening private file…").frame(maxWidth: .infinity, minHeight: 200) }
else if let error = store.previewError {
ContentUnavailableView("Couldn’t open file", systemImage: "exclamationmark.triangle", description: Text(error))
if let item = store.previewAttachment { Button("Try again") { Task { await store.preview(item) } } }
} else if let pdf = store.previewPDF { PrivatePDFPreview(data: pdf).frame(minHeight: 500) }
else if let image = store.previewImage { Image(uiImage: image).resizable().scaledToFit().padding() }
else if let text = store.previewText { Text(text).textSelection(.enabled).padding() }
}
.navigationTitle(store.previewAttachment?.filename ?? "Private preview")
.toolbar { ToolbarItem(placement: .confirmationAction) { Button("Done") { showingPreview = false } } }
}
}
.sheet(isPresented: $artifactEditor) { artifactForm }
.sheet(isPresented: $taskEditor) { taskForm }
.sheet(item: $memory) { item in
NavigationStack {
Form {
Section("Memory") { TextEditor(text: $memoryContent).frame(minHeight: 260).accessibilityLabel("Memory content") }
Text("An empty value clears this section. Existing sessions may cache earlier memory.").font(.footnote)
if let error = store.error { Text(error).foregroundStyle(.red) }
}
.navigationTitle(item.section.capitalized)
.toolbar {
ToolbarItem(placement: .cancellationAction) { Button("Cancel") { memory = nil } }
ToolbarItem(placement: .confirmationAction) { Button("Save") { Task { await store.saveMemory(item, content: memoryContent); if store.error == nil { memory = nil } } } }
}
}
}
.confirmationDialog("Delete this item?", isPresented: Binding(get: { deleting != nil }, set: { if !$0 { deleting = nil } }), titleVisibility: .visible) { Button("Delete", role: .destructive) { if let deleting { Task { await deleting() } }; deleting = nil } }
}
}
private var artifactForm: some View {
NavigationStack {
Form {
TextField("Title", text: $title)
Picker("Kind", selection: $kind) { ForEach(["markdown", "text", "code"], id: \.self) { Text($0.capitalized).tag($0) } }
TextEditor(text: $content).frame(minHeight: 220)
if let artifact {
let file = PrivateChatExport(data: Data(content.utf8), filename: "Hope-artifact-\(artifact.id).\(kind == "markdown" ? "md" : "txt")", type: .plainText)
ShareLink("Export file", item: file, preview: SharePreview(file.filename))
if artifact.revision > 1 { Menu("View earlier revision") { ForEach(1..<artifact.revision, id: \.self) { revision in Button("Revision \(revision)") { Task { if let value = await store.historical(artifact, revision: revision) { content = value.content ?? "" } } } } } }
Text("Historical content uses the current title and kind. Saving creates a new revision.").font(.footnote)
}
if let error = store.error { Text(error).foregroundStyle(.red) }
}.navigationTitle(artifact == nil ? "New artifact" : "Edit artifact")
.toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { artifactEditor = false } }; ToolbarItem(placement: .confirmationAction) { Button("Save") { Task { await store.saveArtifact(artifact, title: title, kind: kind, content: content); if store.error == nil { artifactEditor = false } } }.disabled(title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) } }
}
}
private var taskForm: some View {
NavigationStack {
Form { TextField("Name", text: $name); TextField("Hermes schedule", text: $schedule); Text("Use a schedule accepted by your Hermes server. Hope does not invent a separate scheduling format.").font(.footnote); TextEditor(text: $prompt).frame(minHeight: 180); if let error = store.error { Text(error).foregroundStyle(.red) } }
.navigationTitle(task == nil ? "New task" : "Edit task")
.toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { taskEditor = false } }; ToolbarItem(placement: .confirmationAction) { Button("Save") { Task { await store.saveTask(task, name: name, schedule: schedule, prompt: prompt); if store.error == nil { taskEditor = false } } }.disabled(name.isEmpty || schedule.isEmpty || prompt.isEmpty) } }
}
}
}
struct PrivatePDFPreview: UIViewRepresentable {
let data: Data
func makeUIView(context: Context) -> PDFView { let view = PDFView(); view.autoScales = true; view.document = PDFDocument(data: data); return view }
func updateUIView(_ view: PDFView, context: Context) { if view.document?.dataRepresentation() != data { view.document = PDFDocument(data: data) } }
}
+269
View File
@@ -0,0 +1,269 @@
import Foundation
struct HopeConversation: Codable, Identifiable, Equatable {
var id: String
var title: String
var workspaceId: String?
var pinned: Bool
var pinRank: Int
var archived: Bool
var temporary: Bool
var revision: Int
var createdAt: Double
var updatedAt: Double
}
struct HopeMessage: Codable, Identifiable, Equatable {
var id: String
var conversationId: String
var role: String
var content: String
var status: String
var runId: String?
var createdAt: Double
// Attachments are deliberately unavailable until their contract is qualified.
}
struct HopeRun: Codable, Identifiable, Equatable {
var id: String
var conversationId: String
var status: String
var messageId: String
var createdAt: Double
var updatedAt: Double
var error: String?
var active: Bool { ["queued", "running", "awaiting_approval"].contains(status) }
}
struct HopeWorkspace: Codable, Identifiable, Equatable {
var id: String
var name: String
var instructions: String
var revision: Int
var createdAt: Double
var updatedAt: Double
}
struct HopePreferences: Codable, Equatable {
var palette: String = "opal"
var appearance: String = "system"
var revision: Int = 0
}
struct ConversationPage: Decodable { let conversations: [HopeConversation]; let nextCursor: String? }
struct ConversationDetail: Decodable { let conversation: HopeConversation; let messages: [HopeMessage]; let runs: [HopeRun]; let nextCursor: String? }
struct ConversationEnvelope: Decodable { let conversation: HopeConversation }
struct WorkspaceEnvelope: Decodable { let workspace: HopeWorkspace }
struct WorkspacePage: Decodable { let workspaces: [HopeWorkspace] }
struct PreferencesEnvelope: Decodable { let preferences: HopePreferences }
struct SendEnvelope: Decodable { let message: HopeMessage; let run: HopeRun }
struct RunEnvelope: Decodable { let run: HopeRun }
struct RunDetail: Decodable { let run: HopeRun; let messages: [HopeMessage] }
struct ChatEvent: Equatable { let id: Int; let name: String; let data: Data }
/// Incremental SSE framing. A cursor advances only after a complete event is applied.
struct SSEParser {
private var id = 0
private var name = "message"
private var lines: [String] = []
mutating func consume(_ line: String) -> ChatEvent? {
if line.isEmpty {
defer { id = 0; name = "message"; lines = [] }
guard id > 0, !lines.isEmpty else { return nil }
return ChatEvent(id: id, name: name, data: Data(lines.joined(separator: "\n").utf8))
}
if line.hasPrefix(":") { return nil }
let pair = line.split(separator: ":", maxSplits: 1, omittingEmptySubsequences: false)
let value = pair.count > 1 ? String(pair[1]).replacingOccurrences(of: "^ ", with: "", options: .regularExpression) : ""
switch pair.first { case "id": id = Int(value) ?? 0; case "event": name = value; case "data": lines.append(value); default: break }
return nil
}
}
enum ChatCoding {
static var decoder: JSONDecoder { let d = JSONDecoder(); d.keyDecodingStrategy = .convertFromSnakeCase; return d }
static func body(_ value: [String: Any]) throws -> Data { try JSONSerialization.data(withJSONObject: value) }
}
enum ChatFailure: LocalizedError {
case status(Int), invalidResponse, unavailable
var errorDescription: String? {
switch self {
case .status(409): return "This chat changed elsewhere. Refresh and try again."
case .status(401), .status(403): return "Your session is no longer authorized. Please sign in again."
case .status(404): return "This chat is no longer available."
case .status(503), .unavailable: return "Chat is not available on this server yet. Talk remains available."
default: return "Hope couldn’t complete that request. Your draft is still here. Try again."
}
}
}
protocol ChatTransport {
func request(_ path: String, method: String, body: [String: Any]?, query: [URLQueryItem]) async throws -> Data
func events(run: String, after: Int, receive: @escaping @MainActor (ChatEvent) throws -> Void) async throws
func upload(data: Data, filename: String, mime: String) async throws -> Data
func cancel()
}
extension ChatTransport {
func upload(data: Data, filename: String, mime: String) async throws -> Data { throw ChatFailure.unavailable }
}
final class ChatAPI: ChatTransport {
let config: ServerConfig
private let token: String
private let session: URLSession
init(config: ServerConfig, token: String, session: URLSession? = nil) {
self.config = config; self.token = token; self.session = session ?? TLSPinning.session()
}
private func makeRequest(_ path: String, method: String, body: [String: Any]?, query: [URLQueryItem]) throws -> URLRequest {
guard !token.isEmpty else { throw ChatFailure.status(401) }
var components = URLComponents(url: config.baseURL.appendingPathComponent(path), resolvingAgainstBaseURL: false)!
components.queryItems = query.isEmpty ? nil : query
var request = URLRequest(url: components.url!)
request.httpMethod = method; request.timeoutInterval = 60
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
if let body { request.httpBody = try ChatCoding.body(body); request.setValue("application/json", forHTTPHeaderField: "Content-Type") }
return request
}
func request(_ path: String, method: String = "GET", body: [String: Any]? = nil, query: [URLQueryItem] = []) async throws -> Data {
let (data, response) = try await session.data(for: makeRequest(path, method: method, body: body, query: query))
try validate(response)
return data
}
private func validate(_ response: URLResponse) throws {
guard let response = response as? HTTPURLResponse else { throw ChatFailure.invalidResponse }
guard (200..<300).contains(response.statusCode) else { throw ChatFailure.status(response.statusCode) }
}
func events(run: String, after: Int, receive: @escaping @MainActor (ChatEvent) throws -> Void) async throws {
var request = try makeRequest("api/runs/\(run)/events", method: "GET", body: nil, query: [URLQueryItem(name: "after", value: String(after))])
request.setValue("text/event-stream", forHTTPHeaderField: "Accept")
let (bytes, response) = try await session.bytes(for: request)
try validate(response)
var parser = SSEParser()
for try await line in bytes.lines {
try Task.checkCancellation()
if let event = parser.consume(line) { try await receive(event); if event.name == "done" { return } }
}
}
func upload(data: Data, filename: String, mime: String) async throws -> Data {
var request = try makeRequest("api/attachments", method: "POST", body: nil, query: [])
request.httpBody = data
request.setValue(mime, forHTTPHeaderField: "Content-Type")
request.setValue(filename, forHTTPHeaderField: "X-Filename")
let (result, response) = try await session.data(for: request)
try validate(response)
return result
}
func cancel() { session.invalidateAndCancel() }
}
/// Offline tests never hit any server. Uses the same contract envelopes as HTTP.
final class OfflineChatAPI: ChatTransport {
private var conversations: [HopeConversation] = []
private var messages: [String: [HopeMessage]] = [:]
private var workspaces: [HopeWorkspace] = []
private var preferences = HopePreferences()
private var operations: [String: Data] = [:]
private var featureObjects: [String: [[String: Any]]] = ["attachments": [], "artifacts": [], "tasks": []]
private var fileData: [String: Data] = [:]
private var memories: [[String: Any]] = [["section": "memory", "content": "Offline memory", "revision": "1"], ["section": "user", "content": "", "revision": "1"]]
private func encode<T: Encodable>(_ value: T) throws -> Data { let e = JSONEncoder(); e.keyEncodingStrategy = .convertToSnakeCase; return try e.encode(value) }
func request(_ path: String, method: String, body: [String: Any]?, query: [URLQueryItem]) async throws -> Data {
try Task.checkCancellation()
let now = Date().timeIntervalSince1970
let parts = path.split(separator: "/").map(String.init)
let body = body ?? [:]
if path == "api/capabilities" { return Data("{\"features\":{\"attachments\":true,\"artifacts\":true,\"scheduled_tasks\":true,\"memory_controls\":true,\"image_input\":false}}".utf8) }
if path == "api/research" { return Data("{\"available\":false,\"reason\":\"Offline fixture\"}".utf8) }
if path == "api/sync" { return Data("{\"changes\":[],\"cursor\":0}".utf8) }
if parts.count == 4, parts[1] == "conversations", parts[3] == "export" {
guard let conversation = conversations.first(where: { $0.id == parts[2] }) else { throw ChatFailure.status(404) }
return Data(("# " + conversation.title + "\n\n" + (messages[conversation.id] ?? []).map { "## " + $0.role + "\n\n" + $0.content }.joined(separator: "\n\n")).utf8)
}
if path == "api/memory" { return try JSONSerialization.data(withJSONObject: ["sections": memories]) }
if parts.count == 3, parts[1] == "memory", let i = memories.firstIndex(where: { $0["section"] as? String == parts[2] }) {
guard body["revision"] as? String == memories[i]["revision"] as? String else { throw ChatFailure.status(409) }
memories[i]["content"] = body["content"] as? String ?? ""; memories[i]["revision"] = UUID().uuidString
return try JSONSerialization.data(withJSONObject: memories[i])
}
if parts.count >= 2, ["attachments", "artifacts", "tasks"].contains(parts[1]) {
let kind = parts[1]; let singular = kind == "tasks" ? "task" : String(kind.dropLast())
if parts.count == 2 {
if method == "GET" { return try JSONSerialization.data(withJSONObject: [kind: featureObjects[kind] ?? [], "next_cursor": NSNull()]) }
var item = body; item["id"] = UUID().uuidString; item["created_at"] = now; item["updated_at"] = now
if kind == "artifacts" { item["revision"] = 1 }
if kind == "tasks" { item["enabled"] = true }
featureObjects[kind, default: []].insert(item, at: 0)
return try JSONSerialization.data(withJSONObject: [singular: item])
}
guard let i = featureObjects[kind]?.firstIndex(where: { $0["id"] as? String == parts[2] }) else { throw ChatFailure.status(404) }
if parts.count == 4, parts[3] == "content" { return fileData[parts[2]] ?? Data() }
if method == "DELETE" { featureObjects[kind]?.remove(at: i); fileData[parts[2]] = nil; return Data("{\"ok\":true}".utf8) }
var item = featureObjects[kind]![i]
if method == "PATCH" {
if kind == "artifacts" { guard body["revision"] as? Int == item["revision"] as? Int else { throw ChatFailure.status(409) }; item["revision"] = (item["revision"] as? Int ?? 1) + 1 }
for (key, value) in body where key != "revision" { item[key] = value }
}
if parts.count == 4 { if parts[3] == "pause" { item["enabled"] = false }; if parts[3] == "resume" { item["enabled"] = true } }
featureObjects[kind]![i] = item
return try JSONSerialization.data(withJSONObject: [singular: item])
}
if path == "api/preferences" {
if method == "PUT" { preferences.palette = body["palette"] as? String ?? "opal"; preferences.appearance = body["appearance"] as? String ?? "system"; preferences.revision += 1 }
return try JSONSerialization.data(withJSONObject: ["preferences": JSONSerialization.jsonObject(with: encode(preferences))])
}
if path == "api/workspaces" {
if method == "POST" { workspaces.append(HopeWorkspace(id: UUID().uuidString, name: body["name"] as? String ?? "Workspace", instructions: body["instructions"] as? String ?? "", revision: 1, createdAt: now, updatedAt: now)); return try wrap("workspace", workspaces.last!) }
return try JSONSerialization.data(withJSONObject: ["workspaces": JSONSerialization.jsonObject(with: encode(workspaces))])
}
if parts.count == 3, parts[1] == "workspaces", let index = workspaces.firstIndex(where: { $0.id == parts[2] }) {
if method == "DELETE" { workspaces.remove(at: index); for i in conversations.indices where conversations[i].workspaceId == parts[2] { conversations[i].workspaceId = nil }; return Data("{\"ok\":true}".utf8) }
workspaces[index].name = body["name"] as? String ?? workspaces[index].name
workspaces[index].instructions = body["instructions"] as? String ?? workspaces[index].instructions
workspaces[index].revision += 1
return try wrap("workspace", workspaces[index])
}
if path == "api/conversations" {
if method == "POST" { let c = HopeConversation(id: UUID().uuidString, title: body["title"] as? String ?? "New chat", workspaceId: body["workspace_id"] as? String, pinned: false, pinRank: 0, archived: false, temporary: false, revision: 1, createdAt: now, updatedAt: now); conversations.insert(c, at: 0); return try wrap("conversation", c) }
let q = query.first(where: { $0.name == "q" })?.value ?? ""
let archived = query.first(where: { $0.name == "archived" })?.value == "true"
let filtered = conversations.filter { $0.archived == archived && (q.isEmpty || $0.title.localizedCaseInsensitiveContains(q) || (messages[$0.id] ?? []).contains { $0.content.localizedCaseInsensitiveContains(q) }) }
return try JSONSerialization.data(withJSONObject: ["conversations": JSONSerialization.jsonObject(with: encode(filtered)), "next_cursor": NSNull()])
}
if parts.count >= 3, parts[1] == "conversations", let index = conversations.firstIndex(where: { $0.id == parts[2] }) {
let id = parts[2]
if parts.count == 4, parts[3] == "messages" {
let key = body["idempotency_key"] as? String ?? UUID().uuidString
if let prior = operations[key] { return prior }
let runId = UUID().uuidString
let user = HopeMessage(id: UUID().uuidString, conversationId: id, role: "user", content: body["content"] as? String ?? "", status: "completed", runId: runId, createdAt: now)
let answer = HopeMessage(id: UUID().uuidString, conversationId: id, role: "assistant", content: "## Offline reply\n\nThis is a **local fixture**, not a server response.\n\n```swift\nlet hope = true\n```", status: "completed", runId: runId, createdAt: now)
messages[id, default: []] += [user, answer]
let run = HopeRun(id: runId, conversationId: id, status: "completed", messageId: answer.id, createdAt: now, updatedAt: now, error: nil)
let data = try JSONSerialization.data(withJSONObject: ["message": JSONSerialization.jsonObject(with: encode(user)), "run": JSONSerialization.jsonObject(with: encode(run))]); operations[key] = data; return data
}
if parts.count == 4, parts[3] == "branch" {
var c = conversations[index]; c.id = UUID().uuidString; c.title += " · Branch"; c.revision = 1
let source = messages[id] ?? []; let cutoff = source.firstIndex { $0.id == body["message_id"] as? String } ?? source.count
messages[c.id] = Array(source.prefix(cutoff)).map { var m = $0; m.conversationId = c.id; return m }
conversations.insert(c, at: 0); return try wrap("conversation", c)
}
if method == "DELETE" { conversations.remove(at: index); messages[id] = nil; return Data("{\"ok\":true}".utf8) }
if method == "PATCH" {
guard body["revision"] as? Int == conversations[index].revision else { throw ChatFailure.status(409) }
conversations[index].title = body["title"] as? String ?? conversations[index].title
conversations[index].pinned = body["pinned"] as? Bool ?? conversations[index].pinned
conversations[index].archived = body["archived"] as? Bool ?? conversations[index].archived
if body.keys.contains("workspace_id") { conversations[index].workspaceId = body["workspace_id"] as? String }
conversations[index].revision += 1; return try wrap("conversation", conversations[index])
}
return try JSONSerialization.data(withJSONObject: ["conversation": JSONSerialization.jsonObject(with: encode(conversations[index])), "messages": JSONSerialization.jsonObject(with: encode(messages[id] ?? [])), "runs": [], "next_cursor": NSNull()])
}
throw ChatFailure.status(404)
}
private func wrap<T: Encodable>(_ key: String, _ value: T) throws -> Data { try JSONSerialization.data(withJSONObject: [key: JSONSerialization.jsonObject(with: encode(value))]) }
func events(run: String, after: Int, receive: @escaping @MainActor (ChatEvent) throws -> Void) async throws {}
func upload(data: Data, filename: String, mime: String) async throws -> Data {
let id = UUID().uuidString
let item: [String: Any] = ["id": id, "filename": filename, "mime": mime, "size": data.count, "extraction_status": mime.hasPrefix("image/") ? "image" : "ready", "created_at": Date().timeIntervalSince1970]
fileData[id] = data; featureObjects["attachments", default: []].insert(item, at: 0)
return try JSONSerialization.data(withJSONObject: ["attachment": item])
}
func cancel() {}
}
+331
View File
@@ -0,0 +1,331 @@
import Foundation
@MainActor
final class ChatStore: ObservableObject {
@Published var conversations: [HopeConversation] = []
@Published var workspaces: [HopeWorkspace] = []
@Published var preferences = HopePreferences()
@Published var selected: HopeConversation?
@Published var messages: [HopeMessage] = []
@Published var runs: [HopeRun] = []
@Published var draft = ""
@Published var error: String?
@Published var progress: String?
@Published var busy = false
@Published var searching = false
@Published var nextCursor: String?
@Published var olderMessagesCursor: String?
private var currentQuery = ""
private var currentArchived = false
private var searchGeneration = 0
@Published var approval: (id: String, prompt: String)?
@Published var researchAvailable = false
@Published var research = false
@Published var exportText: String?
@Published var exportFile: PrivateChatExport?
private var syncCursor = 0
private var syncTask: Task<Void, Never>?
@Published var model = ""
@Published var thinkingLevel = ""
@Published var models: [ChatModelChoice] = []
@Published var modelNote: String?
let features: ChatFeatureStore
@Published var selectedAttachmentIds: Set<String> = []
private let api: ChatTransport
private var generation = 0
private var stream: Task<Void, Never>?
private var cursors: [String: Int] = [:]
private var drafts: [String: String] = [:]
private var pendingKey: (content: String, key: String)?
private var closed = false
var onUnauthorized: (() async -> Void)?
var activeRun: HopeRun? { runs.first(where: \.active) }
init(api: ChatTransport) { self.api = api; self.features = ChatFeatureStore(api: api) }
deinit { stream?.cancel(); syncTask?.cancel() }
private func call<T: Decodable>(_ path: String, method: String = "GET", body: [String: Any]? = nil, query: [URLQueryItem] = []) async throws -> T {
let epoch = generation
let data = try await api.request(path, method: method, body: body, query: query)
try Task.checkCancellation()
guard !closed, generation == epoch else { throw CancellationError() }
return try ChatCoding.decoder.decode(T.self, from: data)
}
private func failure(_ value: Error) async {
guard !closed, !(value is CancellationError) else { return }
if case ChatFailure.status(let code) = value, code == 401 || code == 403 { await onUnauthorized?(); return }
error = value.localizedDescription
}
func load(query: String = "", archived: Bool = false, more: Bool = false) async {
guard !closed else { return }
currentQuery = query; currentArchived = archived
searchGeneration += 1
let search = searchGeneration
searching = true
defer { if search == searchGeneration { searching = false } }
do {
var q = [URLQueryItem(name: "q", value: query), URLQueryItem(name: "archived", value: String(archived)), URLQueryItem(name: "limit", value: "50")]
if more, let nextCursor { q.append(URLQueryItem(name: "cursor", value: nextCursor)) }
let page: ConversationPage = try await call("api/conversations", query: q)
guard search == searchGeneration else { return }
if more { let existing = Set(conversations.map(\.id)); conversations += page.conversations.filter { !existing.contains($0.id) } }
else { conversations = page.conversations }
nextCursor = page.nextCursor
let spaces: WorkspacePage = try await call("api/workspaces"); workspaces = spaces.workspaces
let prefs: PreferencesEnvelope = try await call("api/preferences"); preferences = prefs.preferences
error = nil
} catch { await failure(error) }
}
func open(_ conversation: HopeConversation) async {
if let selected { drafts[selected.id] = draft }
generation += 1; stream?.cancel(); stream = nil
selected = conversation; messages = []; runs = []; progress = nil; approval = nil; exportText = nil; exportFile = nil
draft = drafts[conversation.id] ?? ""; pendingKey = nil; selectedAttachmentIds = []; olderMessagesCursor = nil; error = nil
do {
let detail: ConversationDetail = try await call("api/conversations/\(conversation.id)")
selected = detail.conversation; messages = detail.messages; runs = detail.runs; olderMessagesCursor = detail.nextCursor
if let activeRun { replay(activeRun.id) }
} catch { await failure(error) }
}
func loadOlder() async {
guard let selected, let cursor = olderMessagesCursor else { return }
do {
let detail: ConversationDetail = try await call("api/conversations/\(selected.id)", query: [URLQueryItem(name: "cursor", value: cursor)])
let existing = Set(messages.map(\.id))
messages = detail.messages.filter { !existing.contains($0.id) } + messages
olderMessagesCursor = detail.nextCursor
} catch { await failure(error) }
}
func create(workspace: String? = nil) async {
guard !busy else { return }; busy = true; defer { busy = false }
do {
var body: [String: Any] = [:]; if let workspace { body["workspace_id"] = workspace }
let response: ConversationEnvelope = try await call("api/conversations", method: "POST", body: body)
conversations.insert(response.conversation, at: 0); await open(response.conversation)
} catch { await failure(error) }
}
func send() async {
guard let selected, !busy, activeRun == nil else { return }
let content = draft.trimmingCharacters(in: .whitespacesAndNewlines)
guard !content.isEmpty else { return }
busy = true; error = nil; defer { busy = false }
let payloadIdentity = content + "\n" + selectedAttachmentIds.sorted().joined(separator: ",") + String(research && researchAvailable)
if pendingKey?.content != payloadIdentity { pendingKey = (payloadIdentity, UUID().uuidString) }
do {
let response: SendEnvelope = try await call("api/conversations/\(selected.id)/messages", method: "POST", body: ["content": content, "idempotency_key": pendingKey!.key, "attachment_ids": Array(selectedAttachmentIds).sorted(), "mode": research && researchAvailable ? "research" : "normal"])
upsert(response.message); upsert(response.run); draft = ""; drafts[selected.id] = ""; pendingKey = nil; selectedAttachmentIds = []
if response.run.active { replay(response.run.id) }
else { let detail: ConversationDetail = try await call("api/conversations/\(selected.id)"); messages = detail.messages }
} catch { await failure(error) }
}
private func upsert(_ message: HopeMessage) {
guard message.conversationId == selected?.id else { return }
if let index = messages.firstIndex(where: { $0.id == message.id }) { messages[index] = message } else { messages.append(message) }
}
private func upsert(_ run: HopeRun) {
guard run.conversationId == selected?.id else { return }
if let index = runs.firstIndex(where: { $0.id == run.id }) { runs[index] = run } else { runs.append(run) }
}
/// Full-message replacement, never concatenate replayed content.
func apply(_ event: ChatEvent, run: String) throws {
guard event.id > cursors[run, default: 0] else { return }
switch event.name {
case "message":
struct Envelope: Decodable { let message: HopeMessage }
upsert(try ChatCoding.decoder.decode(Envelope.self, from: event.data).message)
case "run", "done": upsert(try ChatCoding.decoder.decode(RunEnvelope.self, from: event.data).run)
case "progress":
struct Envelope: Decodable { let message: String }
progress = try ChatCoding.decoder.decode(Envelope.self, from: event.data).message
case "approval":
struct Envelope: Decodable { let approvalId: String; let prompt: String }
let value = try ChatCoding.decoder.decode(Envelope.self, from: event.data)
approval = (value.approvalId, value.prompt)
default: break
}
cursors[run] = event.id
if activeRun == nil { progress = nil; approval = nil }
}
func resume() {
guard !closed, let activeRun else { return }; replay(activeRun.id)
}
private func replay(_ run: String) {
stream?.cancel()
let epoch = generation
stream = Task { [weak self] in
guard let self else { return }
do {
try await api.events(run: run, after: cursors[run, default: 0]) { [weak self] event in
guard let self, self.generation == epoch, !self.closed else { throw CancellationError() }
try self.apply(event, run: run)
}
guard generation == epoch, !closed else { return }
if activeRun != nil { error = "Connection interrupted. Resume to replay the latest response." }
} catch { guard generation == epoch else { return }; await failure(error) }
}
}
func stop() async {
guard let activeRun else { return }
do { let response: RunEnvelope = try await call("api/runs/\(activeRun.id)/cancel", method: "POST"); upsert(response.run); stream?.cancel(); progress = nil; approval = nil }
catch { await failure(error) }
}
func decide(_ decision: String) async {
guard let activeRun, let approval else { return }
do { let result: RunEnvelope = try await call("api/runs/\(activeRun.id)/approval", method: "POST", body: ["approval_id": approval.id, "decision": decision]); upsert(result.run); self.approval = nil; replay(result.run.id) }
catch { await failure(error) }
}
func patch(_ conversation: HopeConversation, changes: [String: Any]) async {
var body = changes; body["revision"] = conversation.revision
do {
let response: ConversationEnvelope = try await call("api/conversations/\(conversation.id)", method: "PATCH", body: body)
if let index = conversations.firstIndex(where: { $0.id == conversation.id }) { conversations[index] = response.conversation }
if selected?.id == conversation.id { selected = response.conversation }
} catch { await failure(error) }
}
func delete(_ conversation: HopeConversation) async {
do {
struct Result: Decodable { let ok: Bool }
let _: Result = try await call("api/conversations/\(conversation.id)", method: "DELETE")
conversations.removeAll { $0.id == conversation.id }; drafts[conversation.id] = nil
if selected?.id == conversation.id { generation += 1; stream?.cancel(); selected = nil; messages = []; runs = []; draft = "" }
} catch { await failure(error) }
}
func branch(_ message: HopeMessage, edited: String? = nil, regenerate: Bool = false) async {
guard let selected, !busy, activeRun == nil else { return }
busy = true
do {
let target: HopeMessage
if regenerate {
guard let index = messages.firstIndex(where: { $0.id == message.id }), let prior = messages.prefix(index).last(where: { $0.role == "user" }) else { busy = false; return }
target = prior
} else { target = message }
var body: [String: Any] = ["message_id": target.id]
if let edited { body["content"] = edited }
else if regenerate { body["content"] = target.content }
let response: ConversationEnvelope = try await call("api/conversations/\(selected.id)/branch", method: "POST", body: body)
conversations.insert(response.conversation, at: 0); await open(response.conversation)
busy = false
if let edited { draft = edited; await send() }
else if regenerate { draft = target.content; await send() }
} catch { busy = false; await failure(error) }
}
@discardableResult
func saveWorkspace(_ workspace: HopeWorkspace?, name: String, instructions: String) async -> Bool {
guard !name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
error = "Enter a workspace name."
return false
}
do {
var body: [String: Any] = ["name": name, "instructions": instructions]
if let workspace { body["revision"] = workspace.revision }
let _: WorkspaceEnvelope = try await call(workspace.map { "api/workspaces/\($0.id)" } ?? "api/workspaces", method: workspace == nil ? "POST" : "PATCH", body: body)
let result: WorkspacePage = try await call("api/workspaces")
workspaces = result.workspaces
error = nil
return true
} catch {
await failure(error)
return false
}
}
func deleteWorkspace(_ workspace: HopeWorkspace) async {
do {
struct Result: Decodable { let ok: Bool }
let _: Result = try await call("api/workspaces/\(workspace.id)", method: "DELETE")
workspaces.removeAll { $0.id == workspace.id }
for i in conversations.indices where conversations[i].workspaceId == workspace.id { conversations[i].workspaceId = nil }
} catch { await failure(error) }
}
func savePreferences(palette: String, appearance: String) async {
do { let result: PreferencesEnvelope = try await call("api/preferences", method: "PUT", body: ["revision": preferences.revision, "palette": palette, "appearance": appearance]); preferences = result.preferences }
catch { await failure(error) }
}
func loadModels() async {
do {
let result: ChatSettingsResponse = try await call("api/settings/model")
model = result.settings.model; thinkingLevel = result.settings.thinkingLevel; models = result.models; modelNote = result.thinkingNote ?? result.message
} catch { modelNote = "Server model settings are not available." }
}
func saveModel() async {
do { let result: ChatSettingsSaved = try await call("api/settings/model", method: "PUT", body: ["model": model, "thinking_level": thinkingLevel]); model = result.settings.model; thinkingLevel = result.settings.thinkingLevel; modelNote = result.message }
catch { await failure(error) }
}
func loadResearch() async {
do { struct Result: Decodable { let available: Bool }; let result: Result = try await call("api/research"); researchAvailable = result.available }
catch { researchAvailable = false; research = false }
}
func exportConversation(format: String) async {
guard let selected else { return }
do {
let epoch = generation
let bytes = try await api.request("api/conversations/\(selected.id)/export", method: "GET", body: nil, query: [URLQueryItem(name: "format", value: format)])
guard epoch == generation, !closed else { return }
exportText = String(data: bytes, encoding: .utf8)
exportFile = PrivateChatExport(data: bytes, filename: "Hope-chat-\(selected.id).\(format == "json" ? "json" : "md")", type: format == "json" ? .json : .plainText)
} catch { await failure(error) }
}
func foreground(_ active: Bool) {
syncTask?.cancel(); syncTask = nil
guard active, !closed else { stream?.cancel(); stream = nil; return }
resume()
syncTask = Task { [weak self] in
await self?.loadResearch()
while !Task.isCancelled {
guard self?.closed == false else { return }
await self?.synchronize()
// Release the store before suspension; its retained task must not
// keep an otherwise-discarded account alive indefinitely.
do { try await Task.sleep(for: .seconds(5)) } catch { return }
}
}
}
private func synchronize() async {
do {
struct Change: Decodable { let id: String; let kind: String; let deleted: Bool; let data: HopeSyncData? }
struct Result: Decodable { let changes: [Change]; let cursor: Int }
let result: Result = try await call("api/sync", query: [URLQueryItem(name: "after", value: String(syncCursor))])
syncCursor = result.cursor
guard !result.changes.isEmpty else { return }
for change in result.changes {
if change.kind == "conversation" {
if change.deleted {
conversations.removeAll { $0.id == change.id }
if selected?.id == change.id { stream?.cancel(); self.selected = nil; messages = []; runs = []; draft = "" }
} else if let value = change.data?.conversation {
if value.archived != currentArchived { conversations.removeAll { $0.id == value.id } }
else if let i = conversations.firstIndex(where: { $0.id == change.id }) { conversations[i] = value }
else if currentQuery.isEmpty { conversations.insert(value, at: 0) }
}
}
if change.kind == "preferences", let value = change.data?.preferences { preferences = value }
if change.kind == "workspace" {
if change.deleted { workspaces.removeAll { $0.id == change.id } }
else if let value = change.data?.workspace { if let i = workspaces.firstIndex(where: { $0.id == value.id }) { workspaces[i] = value } else { workspaces.append(value) } }
}
}
if let selected, result.changes.contains(where: { $0.id == selected.id && !$0.deleted }) {
let detail: ConversationDetail = try await call("api/conversations/\(selected.id)")
self.selected = detail.conversation
for message in detail.messages { upsert(message) }
for run in detail.runs { upsert(run) }
}
} catch { if case ChatFailure.status(let status) = error, status == 401 || status == 403 { await failure(error) } }
}
/// Invalidate every outstanding callback before clearing account-visible content.
func close() { syncTask?.cancel(); syncTask = nil; exportText = nil; exportFile = nil; research = false; features.close(); selectedAttachmentIds = []; closed = true; generation += 1; stream?.cancel(); stream = nil; api.cancel(); conversations = []; workspaces = []; selected = nil; messages = []; runs = []; draft = ""; drafts = [:]; cursors = [:]; pendingKey = nil; approval = nil; progress = nil; error = nil }
}
struct ChatModelChoice: Decodable, Identifiable { let id: String; let thinkingLevels: [String]? }
struct ChatSettingsValue: Decodable { let model: String; let thinkingLevel: String }
struct ChatSettingsResponse: Decodable { let settings: ChatSettingsValue; let models: [ChatModelChoice]; let message: String?; let thinkingNote: String? }
struct ChatSettingsSaved: Decodable { let settings: ChatSettingsValue; let message: String? }
struct HopeSyncData: Decodable {
let conversation: HopeConversation?
let workspace: HopeWorkspace?
let preferences: HopePreferences?
init(from decoder: Decoder) throws {
let value = try decoder.singleValueContainer()
conversation = try? value.decode(HopeConversation.self)
workspace = try? value.decode(HopeWorkspace.self)
preferences = try? value.decode(HopePreferences.self)
}
}
+334
View File
@@ -0,0 +1,334 @@
import SwiftUI
import UIKit
struct NativeChatView: View {
@ObservedObject var store: ChatStore
let talk: (String) async -> Void
let logout: () async -> Void
@State private var query = ""
@State private var archived = false
@State private var workspace: String?
@State private var path: [String] = []
@State private var rename: HopeConversation?
@State private var delete: HopeConversation?
@State private var title = ""
@State private var showPreferences = false
@State private var showWorkspaces = false
@State private var showLibrary = false
@Environment(\.scenePhase) private var scenePhase
var body: some View {
NavigationStack(path: $path) {
List {
if let error = store.error { Section { Label(error, systemImage: "exclamationmark.circle").foregroundStyle(.red); Button("Retry") { Task { await refresh() } } } }
if store.searching && store.conversations.isEmpty { ProgressView("Loading chats") }
if visible.isEmpty && !store.searching {
ContentUnavailableView(emptyTitle, systemImage: "bubble.left.and.bubble.right", description: Text(emptyDescription))
}
if workspace != nil || archived { Section { Label(scopeLabel, systemImage: archived ? "archivebox" : "folder") } }
if !pinned.isEmpty { Section("Pinned") { rows(pinned) } }
if !recent.isEmpty { Section(archived ? "Archived" : "Recent chats") { rows(recent) } }
if store.nextCursor != nil { Button("Load more") { Task { await store.load(query: query, archived: archived, more: true) } } }
}
.scrollContentBackground(.hidden)
.background(HopeTheme.color(.ground, palette: HopePalette(rawValue: store.preferences.palette) ?? .opal))
.navigationTitle("Chats")
.searchable(text: $query, prompt: "Search chats and messages")
.task(id: query + String(archived)) {
do { try await Task.sleep(for: .milliseconds(250)); try Task.checkCancellation(); await refresh() } catch {}
}
.refreshable { await refresh() }
.onAppear { store.foreground(scenePhase == .active) }
.onChange(of: scenePhase) { _, phase in store.foreground(phase == .active) }
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Menu {
Toggle("Show archived", isOn: $archived)
Picker("Workspace", selection: $workspace) {
Text("All chats").tag(nil as String?)
ForEach(store.workspaces) { Text($0.name).tag(Optional($0.id)) }
}
Button("Library and tools") { showLibrary = true }
Button("Manage workspaces") { showWorkspaces = true }
Button("Appearance and model") { showPreferences = true }
Button("Sign out", role: .destructive) { Task { await logout() } }
} label: { Image(systemName: "line.3.horizontal.decrease.circle").frame(minWidth: 44, minHeight: 44) }
.accessibilityLabel("Chat options")
}
ToolbarItem(placement: .topBarTrailing) {
Button { Task { await store.create(workspace: workspace); if let id = store.selected?.id { path = [id] } } } label: { Image(systemName: "square.and.pencil").frame(minWidth: 44, minHeight: 44) }
.accessibilityLabel("New chat").disabled(store.busy)
}
}
.navigationDestination(for: String.self) { _ in ChatDetailView(store: store, talk: talk) }
.sheet(isPresented: $showLibrary) { ChatLibraryView(store: store.features, selectedAttachmentIds: $store.selectedAttachmentIds) }
.sheet(isPresented: $showPreferences) { ChatPreferencesView(store: store) }
.sheet(isPresented: $showWorkspaces) { WorkspaceManagerView(store: store) }
.alert("Rename chat", isPresented: Binding(get: { rename != nil }, set: { if !$0 { rename = nil } })) {
TextField("Title", text: $title)
Button("Cancel", role: .cancel) { rename = nil }
Button("Save") { if let rename { Task { await store.patch(rename, changes: ["title": title]); if store.error == nil { self.rename = nil } } } }
.disabled(title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
.confirmationDialog("Delete this chat?", isPresented: Binding(get: { delete != nil }, set: { if !$0 { delete = nil } }), titleVisibility: .visible) {
Button("Delete chat", role: .destructive) { if let delete { Task { await store.delete(delete) } }; delete = nil }
Button("Cancel", role: .cancel) { delete = nil }
} message: { Text("This removes the conversation, not your account memory.") }
}
}
private var visible: [HopeConversation] { store.conversations.filter { workspace == nil || $0.workspaceId == workspace } }
private var scopeLabel: String { archived ? "Archived chats" : (workspace.flatMap { id in store.workspaces.first(where: { $0.id == id })?.name } ?? "All chats") }
private var emptyTitle: String {
if !query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return "No matching chats" }
if archived { return "No archived chats" }
if workspace != nil { return "No chats in this workspace" }
return "A place to think"
}
private var emptyDescription: String {
if !query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return "Try another search or clear it to see your conversations." }
if workspace != nil { return "Move a chat here or start a new conversation in this workspace." }
return archived ? "Archived chats appear here." : "Start a chat. Your conversations stay with your account, independently of Talk."
}
private var pinned: [HopeConversation] { visible.filter(\.pinned).sorted { $0.pinRank < $1.pinRank } }
private var recent: [HopeConversation] { visible.filter { !$0.pinned } }
private func refresh() async { await store.load(query: query, archived: archived) }
@ViewBuilder private func rows(_ items: [HopeConversation]) -> some View {
ForEach(items) { conversation in
Button {
Task { await store.open(conversation); path = [conversation.id] }
} label: {
VStack(alignment: .leading, spacing: 4) {
Text(conversation.title).foregroundStyle(.primary).font(.body.weight(.medium)).lineLimit(2)
HStack(spacing: 6) {
if let id = conversation.workspaceId, let space = store.workspaces.first(where: { $0.id == id }) { Text(space.name) }
Text(Date(timeIntervalSince1970: conversation.updatedAt), style: .date)
}.font(.caption).foregroundStyle(.secondary)
}.frame(minHeight: 44, alignment: .leading)
}
.contextMenu {
Button(conversation.pinned ? "Unpin" : "Pin", systemImage: conversation.pinned ? "pin.slash" : "pin") { Task { await store.patch(conversation, changes: ["pinned": !conversation.pinned]) } }
Button("Rename", systemImage: "pencil") { title = conversation.title; rename = conversation }
Menu("Move to workspace") {
Button("Outside workspaces") { Task { await store.patch(conversation, changes: ["workspace_id": NSNull()]) } }
ForEach(store.workspaces) { space in Button(space.name) { Task { await store.patch(conversation, changes: ["workspace_id": space.id]) } } }
}
Button(conversation.archived ? "Unarchive" : "Archive", systemImage: "archivebox") { Task { await store.patch(conversation, changes: ["archived": !conversation.archived]) } }
Button("Delete", systemImage: "trash", role: .destructive) { delete = conversation }
}
}
}
}
struct ChatDetailView: View {
@ObservedObject var store: ChatStore
let talk: (String) async -> Void
@State private var editing: HopeMessage?
@State private var editedText = ""
@State private var follow = false
@State private var library = false
@Environment(\.scenePhase) private var scenePhase
var body: some View {
ScrollViewReader { proxy in
ScrollView {
LazyVStack(alignment: .leading, spacing: 24) {
if store.messages.isEmpty { ContentUnavailableView("What’s on your mind?", systemImage: "bubble.left", description: Text("Write below to begin. Text chat does not connect your microphone.")) }
if store.olderMessagesCursor != nil { Button("Load earlier messages") { follow = false; Task { await store.loadOlder() } } }
ForEach(store.messages) { message in
VStack(alignment: .leading, spacing: 8) {
HStack { Text(message.role == "user" ? "You" : "Hope").font(.subheadline.weight(.semibold)); Spacer(); if message.status != "completed" { Text(message.status.capitalized).font(.caption).foregroundStyle(.secondary) } }
RichChatMarkdown(content: message.content)
Menu {
Button("Copy", systemImage: "doc.on.doc") { UIPasteboard.general.string = message.content }
Button("Branch from here", systemImage: "arrow.triangle.branch") { Task { await store.branch(message) } }
if message.role == "user" { Button("Edit in new branch", systemImage: "pencil") { editing = message; editedText = message.content } }
else { Button("Regenerate in new branch", systemImage: "arrow.clockwise") { Task { await store.branch(message, regenerate: true) } } }
} label: { Label("Message actions", systemImage: "ellipsis").font(.caption).frame(minHeight: 44) }
.disabled(store.busy || store.activeRun != nil)
}
.padding(message.role == "user" ? 12 : 0)
.background(message.role == "user" ? HopeTheme.color(.surface, palette: HopePalette(rawValue: store.preferences.palette) ?? .opal) : .clear, in: RoundedRectangle(cornerRadius: 12))
.frame(maxWidth: .infinity, alignment: message.role == "user" ? .trailing : .leading)
.id(message.id)
}
if let progress = store.progress { Label(progress, systemImage: "ellipsis").foregroundStyle(.secondary) }
Color.clear.frame(height: 1).id("chat-bottom")
}.padding(.horizontal, 20).padding(.vertical, 16).frame(maxWidth: 760)
}
.scrollDismissesKeyboard(.interactively)
.simultaneousGesture(DragGesture().onChanged { _ in follow = false })
.onChange(of: store.messages) { _, _ in if follow { proxy.scrollTo("chat-bottom", anchor: .bottom) } }
.overlay(alignment: .bottomTrailing) {
if !follow && !store.messages.isEmpty { Button("Latest", systemImage: "arrow.down") { follow = true; proxy.scrollTo("chat-bottom", anchor: .bottom) }.buttonStyle(.bordered).padding() }
}
}
.background(HopeTheme.color(.ground, palette: HopePalette(rawValue: store.preferences.palette) ?? .opal))
.foregroundStyle(HopeTheme.color(.ink, palette: HopePalette(rawValue: store.preferences.palette) ?? .opal))
.navigationTitle(store.selected?.title ?? "Chat")
.navigationBarTitleDisplayMode(.inline)
.toolbar { ToolbarItem(placement: .topBarTrailing) {
Menu {
Button("Talk", systemImage: "waveform") { if let id = store.selected?.id { Task { await talk(id) } } }.disabled(store.activeRun != nil)
Button("Export Markdown") { Task { await store.exportConversation(format: "markdown") } }
Button("Export JSON") { Task { await store.exportConversation(format: "json") } }
if let file = store.exportFile { ShareLink("Share export file", item: file, preview: SharePreview(file.filename)) }
} label: { Image(systemName: "ellipsis.circle").frame(minWidth: 44, minHeight: 44) }.accessibilityLabel("Chat actions")
} }
.safeAreaInset(edge: .bottom) {
VStack(alignment: .leading, spacing: 8) {
if let error = store.error { Text(error).font(.footnote).foregroundStyle(.red); if store.activeRun != nil { Button("Resume response") { store.resume() } } }
if let approval = store.approval {
Text(approval.prompt).font(.body)
HStack { Button("Deny", role: .destructive) { Task { await store.decide("deny") } }; Button("Approve") { Task { await store.decide("approve") } } }
}
if store.researchAvailable { Toggle("Research with cited sources", isOn: $store.research).font(.footnote) }
if !store.selectedAttachmentIds.isEmpty {
AttachmentSelectionSummary(store: store)
}
HStack(alignment: .bottom) {
Button { library = true } label: { Image(systemName: "paperclip").frame(minWidth: 44, minHeight: 44) }.accessibilityLabel("Attach files")
TextField("Message Hope", text: $store.draft, axis: .vertical).lineLimit(1...7).textFieldStyle(.roundedBorder).accessibilityIdentifier("chat-draft")
if store.activeRun != nil { Button("Stop", systemImage: "stop.fill") { Task { await store.stop() } }.frame(minWidth: 44, minHeight: 44) }
else { Button { follow = true; Task { await store.send() } } label: { Image(systemName: "arrow.up.circle.fill").font(.title).frame(minWidth: 44, minHeight: 44) }.accessibilityLabel("Send message").disabled(store.busy || store.draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) }
}
}.padding(16).background(.regularMaterial)
}
.sheet(isPresented: $library) { ChatLibraryView(store: store.features, selectedAttachmentIds: $store.selectedAttachmentIds) }
.sheet(item: $editing) { message in
NavigationStack {
Form { Section("Edit message") { TextEditor(text: $editedText).frame(minHeight: 200) }; Text("A new branch preserves the original conversation.").font(.footnote).foregroundStyle(.secondary) }
.navigationTitle("Edit in branch")
.toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { editing = nil } }; ToolbarItem(placement: .confirmationAction) { Button("Send") { editing = nil; Task { await store.branch(message, edited: editedText) } }.disabled(editedText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) } }
}
}
.onChange(of: scenePhase) { _, phase in if phase == .active { store.resume() } }
}
}
private struct AttachmentSelectionSummary: View {
@ObservedObject var store: ChatStore
var selectedAttachments: [ChatAttachment] { store.features.attachments.filter { store.selectedAttachmentIds.contains($0.id) } }
var body: some View {
VStack(alignment: .leading, spacing: 4) {
Text("Attached to next message (\(store.selectedAttachmentIds.count)/8)").font(.caption.weight(.semibold))
ForEach(selectedAttachments) { item in
HStack {
Label(item.filename, systemImage: "doc").font(.caption).lineLimit(1)
Spacer()
Button("Remove") { store.selectedAttachmentIds.remove(item.id) }.font(.caption).frame(minHeight: 36)
}
}
}
}
}
/// Native selectable prose and fenced code; no WebView or remote rendering.
struct RichChatMarkdown: View {
let content: String
var body: some View {
VStack(alignment: .leading, spacing: 12) {
ForEach(Array(blocks.enumerated()), id: \.offset) { _, block in
if block.code {
VStack(alignment: .leading, spacing: 4) {
HStack { Text(block.language.isEmpty ? "Code" : block.language).font(.caption).foregroundStyle(.secondary); Spacer(); Button("Copy code") { UIPasteboard.general.string = block.text }.font(.caption).frame(minHeight: 44) }
ScrollView(.horizontal) { Text(block.text).font(.system(.body, design: .monospaced)).textSelection(.enabled).fixedSize(horizontal: true, vertical: false) }
}.padding(12).background(Color(uiColor: .secondarySystemBackground), in: RoundedRectangle(cornerRadius: 12))
} else {
ForEach(Array(block.text.components(separatedBy: "\n").enumerated()), id: \.offset) { _, line in
if line.trimmingCharacters(in: .whitespaces).hasPrefix("|"), line.contains("|") {
let cells = line.split(separator: "|", omittingEmptySubsequences: true).map { String($0).trimmingCharacters(in: .whitespaces) }
if !cells.allSatisfy({ $0.allSatisfy { "-: ".contains($0) } }) {
HStack(alignment: .top, spacing: 12) { ForEach(Array(cells.enumerated()), id: \.offset) { _, cell in markdown(cell).frame(maxWidth: .infinity, alignment: .leading) } }
.padding(.vertical, 6).accessibilityElement(children: .combine)
Divider()
}
}
else if line.hasPrefix("# ") { markdown(String(line.dropFirst(2))).font(.title2.weight(.semibold)) }
else if line.hasPrefix("## ") { markdown(String(line.dropFirst(3))).font(.title3.weight(.semibold)) }
else if line.hasPrefix("### ") { markdown(String(line.dropFirst(4))).font(.headline) }
else if line.hasPrefix("- ") || line.hasPrefix("* ") { HStack(alignment: .top) { Text("•"); markdown(String(line.dropFirst(2))) } }
else if line.hasPrefix("> ") { markdown(String(line.dropFirst(2))).foregroundStyle(.secondary) }
else { markdown(line) }
}
}
}
}.textSelection(.enabled).frame(maxWidth: .infinity, alignment: .leading)
}
private func markdown(_ text: String) -> Text {
Text((try? AttributedString(markdown: text, options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace))) ?? AttributedString(text))
}
private var blocks: [(text: String, code: Bool, language: String)] {
let pieces = content.components(separatedBy: "```")
return pieces.enumerated().map { i, piece in
if i.isMultiple(of: 2) { return (piece, false, "") }
let lines = piece.components(separatedBy: "\n")
return (lines.dropFirst().joined(separator: "\n").trimmingCharacters(in: .newlines), true, lines.first ?? "")
}
}
}
struct ChatPreferencesView: View {
@ObservedObject var store: ChatStore
@Environment(\.dismiss) private var dismiss
@State private var palette = "opal"
@State private var appearance = "system"
var body: some View {
NavigationStack {
Form {
Section("Appearance") {
Picker("Palette", selection: $palette) { ForEach(["opal", "graphite", "ocean", "sage", "lavender"], id: \.self) { Text($0.capitalized).tag($0) } }
Picker("Appearance", selection: $appearance) { ForEach(["system", "light", "dark"], id: \.self) { Text($0.capitalized).tag($0) } }
Button("Save appearance") { Task { await store.savePreferences(palette: palette, appearance: appearance) } }
}
Section("Server model") {
if !store.models.isEmpty {
Picker("Model", selection: $store.model) { ForEach(store.models) { Text($0.id).tag($0.id) } }
if let levels = store.models.first(where: { $0.id == store.model })?.thinkingLevels, !levels.isEmpty { Picker("Thinking", selection: $store.thinkingLevel) { ForEach(levels, id: \.self) { Text($0.capitalized).tag($0) } } }
Button("Save model settings") { Task { await store.saveModel() } }
}
if let note = store.modelNote { Text(note).font(.footnote).foregroundStyle(.secondary) }
Text("Models are provided by your authenticated Hope server. No direct provider connection.").font(.footnote).foregroundStyle(.secondary)
}
if let error = store.error { Text(error).foregroundStyle(.red) }
}
.navigationTitle("Chat preferences")
.toolbar { ToolbarItem(placement: .confirmationAction) { Button("Done") { dismiss() } } }
.task { palette = store.preferences.palette; appearance = store.preferences.appearance; await store.loadModels() }
}
}
}
struct WorkspaceManagerView: View {
@ObservedObject var store: ChatStore
@Environment(\.dismiss) private var dismiss
@State private var editing: HopeWorkspace?
@State private var name = ""
@State private var instructions = ""
@State private var deleting: HopeWorkspace?
var body: some View {
NavigationStack {
Form {
Section("Workspaces") {
ForEach(store.workspaces) { space in
Button(space.name) { editing = space; name = space.name; instructions = space.instructions }
.swipeActions { Button("Delete", role: .destructive) { deleting = space } }
}
}
Section(editing == nil ? "New workspace" : "Edit workspace") {
TextField("Workspace name", text: $name)
TextField("Instructions", text: $instructions, axis: .vertical).lineLimit(3...8)
Text("Instructions guide chats. Workspaces do not isolate tool access.").font(.footnote).foregroundStyle(.secondary)
Button("Save workspace") { Task {
let saved = await store.saveWorkspace(editing, name: name, instructions: instructions)
if saved { editing = nil; name = ""; instructions = "" }
} }.disabled(name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
if let error = store.error { Text(error).foregroundStyle(.red) }
if editing != nil { Button("Cancel edit") { editing = nil; name = ""; instructions = "" } }
}
}
.navigationTitle("Workspaces")
.toolbar { ToolbarItem(placement: .confirmationAction) { Button("Done") { dismiss() } } }
.confirmationDialog("Delete workspace?", isPresented: Binding(get: { deleting != nil }, set: { if !$0 { deleting = nil } }), titleVisibility: .visible) {
Button("Delete workspace", role: .destructive) { if let deleting { Task { await store.deleteWorkspace(deleting) } }; deleting = nil }
} message: { Text("Its chats are retained outside the workspace.") }
}
}
}
+160
View File
@@ -0,0 +1,160 @@
// Generated from design/hope-palette.v1.json v1.0.0; SHA256 bb76134be477916d776cda4259f45096fc2dcfcf873918b8703a0b716391a350.
// DO NOT EDIT. python3 scripts/generate_hope_themes.py
import SwiftUI
import UIKit
struct HopeRGB: Sendable {
let red: Double
let green: Double
let blue: Double
init(_ red: Double, _ green: Double, _ blue: Double) {
self.red = red; self.green = green; self.blue = blue
}
var uiColor: UIColor { UIColor(red: CGFloat(red), green: CGFloat(green), blue: CGFloat(blue), alpha: 1) }
}
enum HopeAppearance: String, CaseIterable, Sendable {
case system, light, dark
}
enum HopePalette: String, CaseIterable, Sendable {
case opal
case graphite
case ocean
case sage
case lavender
static let defaultPalette: HopePalette = .opal
var label: String {
switch self {
case .opal: return "Opal Pink"
case .graphite: return "Graphite"
case .ocean: return "Ocean"
case .sage: return "Sage"
case .lavender: return "Lavender"
}
}
}
enum HopeThemeRole: String, CaseIterable, Sendable {
case ground
case surface
case personaSurface
case ink
case quiet
case accent
case onAccent
case alert
case onAlert
case focus
case controlBorder
case separator
case selectedWash
case errorWash
}
enum HopeTheme {
static let version = "1.0.0"
static func rgb(_ role: HopeThemeRole, palette: HopePalette = .defaultPalette, dark: Bool) -> HopeRGB {
switch palette {
case .opal:
switch role {
case .ground: return dark ? HopeRGB(0.12549019607843137, 0.09411764705882353, 0.12549019607843137) : HopeRGB(1, 0.9921568627450981, 0.9921568627450981)
case .surface: return dark ? HopeRGB(0.18823529411764706, 0.13725490196078433, 0.18823529411764706) : HopeRGB(1, 0.9686274509803922, 0.9803921568627451)
case .personaSurface: return dark ? HopeRGB(0.23137254901960785, 0.1568627450980392, 0.21176470588235294) : HopeRGB(1, 0.9490196078431372, 0.9686274509803922)
case .ink: return dark ? HopeRGB(1, 0.9490196078431372, 0.9686274509803922) : HopeRGB(0.18823529411764706, 0.16862745098039217, 0.25098039215686274)
case .quiet: return dark ? HopeRGB(0.796078431372549, 0.7137254901960784, 0.7803921568627451) : HopeRGB(0.4392156862745098, 0.3764705882352941, 0.4549019607843137)
case .accent: return dark ? HopeRGB(0.9411764705882353, 0.6588235294117647, 0.7843137254901961) : HopeRGB(0.6509803921568628, 0.25882352941176473, 0.4392156862745098)
case .onAccent: return dark ? HopeRGB(0.12549019607843137, 0.09411764705882353, 0.12549019607843137) : HopeRGB(1, 1, 1)
case .focus: return dark ? HopeRGB(0.9411764705882353, 0.6588235294117647, 0.7843137254901961) : HopeRGB(0.6509803921568628, 0.25882352941176473, 0.4392156862745098)
case .controlBorder: return dark ? HopeRGB(0.796078431372549, 0.7137254901960784, 0.7803921568627451) : HopeRGB(0.4392156862745098, 0.3764705882352941, 0.4549019607843137)
case .alert: return dark ? HopeRGB(1, 0.604, 0.675) : HopeRGB(0.675, 0.208, 0.282)
case .onAlert: return dark ? HopeRGB(0.12549019607843137, 0.09411764705882353, 0.12549019607843137) : HopeRGB(1, 1, 1)
case .separator: return dark ? HopeRGB(0.3018823529411765, 0.2509019607843137, 0.2974901960784314) : HopeRGB(0.8863529411764706, 0.8566274509803922, 0.8782745098039215)
case .selectedWash: return dark ? HopeRGB(0.2635294117647059, 0.1894117647058824, 0.24784313725490195) : HopeRGB(0.9650980392156863, 0.8976470588235295, 0.9262745098039216)
case .errorWash: return dark ? HopeRGB(0.2531764705882353, 0.17459450980392158, 0.2271764705882353) : HopeRGB(0.9740000000000001, 0.9077772549019608, 0.9245207843137255)
}
case .graphite:
switch role {
case .ground: return dark ? HopeRGB(0.09803921568627451, 0.10588235294117647, 0.12549019607843137) : HopeRGB(0.9921568627450981, 0.9921568627450981, 0.996078431372549)
case .surface: return dark ? HopeRGB(0.1450980392156863, 0.1568627450980392, 0.1843137254901961) : HopeRGB(0.9529411764705882, 0.9568627450980393, 0.9647058823529412)
case .personaSurface: return dark ? HopeRGB(0.18823529411764706, 0.20392156862745098, 0.23921568627450981) : HopeRGB(0.9294117647058824, 0.9333333333333333, 0.9490196078431372)
case .ink: return dark ? HopeRGB(0.9529411764705882, 0.9568627450980393, 0.9686274509803922) : HopeRGB(0.15294117647058825, 0.16470588235294117, 0.2)
case .quiet: return dark ? HopeRGB(0.7372549019607844, 0.7568627450980392, 0.803921568627451) : HopeRGB(0.3686274509803922, 0.3843137254901961, 0.43137254901960786)
case .accent: return dark ? HopeRGB(0.7490196078431373, 0.788235294117647, 0.8627450980392157) : HopeRGB(0.3137254901960784, 0.34901960784313724, 0.4117647058823529)
case .onAccent: return dark ? HopeRGB(0.09803921568627451, 0.10588235294117647, 0.12549019607843137) : HopeRGB(1, 1, 1)
case .focus: return dark ? HopeRGB(0.7490196078431373, 0.788235294117647, 0.8627450980392157) : HopeRGB(0.3137254901960784, 0.34901960784313724, 0.4117647058823529)
case .controlBorder: return dark ? HopeRGB(0.7372549019607844, 0.7568627450980392, 0.803921568627451) : HopeRGB(0.3686274509803922, 0.3843137254901961, 0.43137254901960786)
case .alert: return dark ? HopeRGB(1, 0.604, 0.675) : HopeRGB(0.675, 0.208, 0.282)
case .onAlert: return dark ? HopeRGB(0.09803921568627451, 0.10588235294117647, 0.12549019607843137) : HopeRGB(1, 1, 1)
case .separator: return dark ? HopeRGB(0.2581960784313726, 0.2688627450980392, 0.29411764705882354) : HopeRGB(0.8409411764705882, 0.8459607843137256, 0.8576470588235294)
case .selectedWash: return dark ? HopeRGB(0.2054901960784314, 0.22, 0.2521568627450981) : HopeRGB(0.8890196078431373, 0.8960784313725491, 0.9094117647058824)
case .errorWash: return dark ? HopeRGB(0.2134901960784314, 0.19263372549019608, 0.22356862745098044) : HopeRGB(0.9307058823529412, 0.8969537254901961, 0.9100894117647059)
}
case .ocean:
switch role {
case .ground: return dark ? HopeRGB(0.08235294117647059, 0.12941176470588237, 0.16862745098039217) : HopeRGB(0.9882352941176471, 0.996078431372549, 1)
case .surface: return dark ? HopeRGB(0.12549019607843137, 0.19215686274509805, 0.24705882352941178) : HopeRGB(0.9411764705882353, 0.9686274509803922, 0.984313725490196)
case .personaSurface: return dark ? HopeRGB(0.1568627450980392, 0.23921568627450981, 0.30196078431372547) : HopeRGB(0.9058823529411765, 0.9490196078431372, 0.9764705882352941)
case .ink: return dark ? HopeRGB(0.9294117647058824, 0.9686274509803922, 1) : HopeRGB(0.13725490196078433, 0.20392156862745098, 0.25882352941176473)
case .quiet: return dark ? HopeRGB(0.7019607843137254, 0.796078431372549, 0.8705882352941177) : HopeRGB(0.3254901960784314, 0.40784313725490196, 0.47058823529411764)
case .accent: return dark ? HopeRGB(0.592156862745098, 0.807843137254902, 0.9333333333333333) : HopeRGB(0.15294117647058825, 0.396078431372549, 0.5490196078431373)
case .onAccent: return dark ? HopeRGB(0.08235294117647059, 0.12941176470588237, 0.16862745098039217) : HopeRGB(1, 1, 1)
case .focus: return dark ? HopeRGB(0.592156862745098, 0.807843137254902, 0.9333333333333333) : HopeRGB(0.15294117647058825, 0.396078431372549, 0.5490196078431373)
case .controlBorder: return dark ? HopeRGB(0.7019607843137254, 0.796078431372549, 0.8705882352941177) : HopeRGB(0.3254901960784314, 0.40784313725490196, 0.47058823529411764)
case .alert: return dark ? HopeRGB(1, 0.604, 0.675) : HopeRGB(0.675, 0.208, 0.282)
case .onAlert: return dark ? HopeRGB(0.08235294117647059, 0.12941176470588237, 0.16862745098039217) : HopeRGB(1, 1, 1)
case .separator: return dark ? HopeRGB(0.2380392156862745, 0.30086274509803923, 0.35247058823529415) : HopeRGB(0.828627450980392, 0.8615686274509804, 0.8827450980392156)
case .selectedWash: return dark ? HopeRGB(0.17215686274509806, 0.25372549019607843, 0.31568627450980397) : HopeRGB(0.8623529411764705, 0.9113725490196078, 0.9407843137254902)
case .errorWash: return dark ? HopeRGB(0.19545098039215686, 0.22510431372549022, 0.28129411764705886) : HopeRGB(0.9198823529411766, 0.9077772549019608, 0.9281286274509805)
}
case .sage:
switch role {
case .ground: return dark ? HopeRGB(0.09803921568627451, 0.13725490196078433, 0.10980392156862745) : HopeRGB(0.9882352941176471, 0.996078431372549, 0.9882352941176471)
case .surface: return dark ? HopeRGB(0.14901960784313725, 0.20392156862745098, 0.16470588235294117) : HopeRGB(0.9411764705882353, 0.9647058823529412, 0.9450980392156862)
case .personaSurface: return dark ? HopeRGB(0.18823529411764706, 0.25882352941176473, 0.21176470588235294) : HopeRGB(0.9058823529411765, 0.9411764705882353, 0.9137254901960784)
case .ink: return dark ? HopeRGB(0.9411764705882353, 0.9725490196078431, 0.9450980392156862) : HopeRGB(0.1607843137254902, 0.2196078431372549, 0.17647058823529413)
case .quiet: return dark ? HopeRGB(0.7254901960784313, 0.8117647058823529, 0.7450980392156863) : HopeRGB(0.34901960784313724, 0.4196078431372549, 0.36470588235294116)
case .accent: return dark ? HopeRGB(0.6470588235294118, 0.8313725490196079, 0.6862745098039216) : HopeRGB(0.23529411764705882, 0.4392156862745098, 0.30196078431372547)
case .onAccent: return dark ? HopeRGB(0.09803921568627451, 0.13725490196078433, 0.10980392156862745) : HopeRGB(1, 1, 1)
case .focus: return dark ? HopeRGB(0.6470588235294118, 0.8313725490196079, 0.6862745098039216) : HopeRGB(0.23529411764705882, 0.4392156862745098, 0.30196078431372547)
case .controlBorder: return dark ? HopeRGB(0.7254901960784313, 0.8117647058823529, 0.7450980392156863) : HopeRGB(0.34901960784313724, 0.4196078431372549, 0.36470588235294116)
case .alert: return dark ? HopeRGB(1, 0.604, 0.675) : HopeRGB(0.675, 0.208, 0.282)
case .onAlert: return dark ? HopeRGB(0.09803921568627451, 0.13725490196078433, 0.10980392156862745) : HopeRGB(1, 1, 1)
case .separator: return dark ? HopeRGB(0.25992156862745097, 0.3115294117647059, 0.2739607843137255) : HopeRGB(0.8319215686274509, 0.8603921568627451, 0.8374901960784313)
case .selectedWash: return dark ? HopeRGB(0.19882352941176473, 0.26666666666666666, 0.2168627450980392) : HopeRGB(0.8705882352941177, 0.9121568627450981, 0.8807843137254902)
case .errorWash: return dark ? HopeRGB(0.21709803921568627, 0.2359278431372549, 0.2055294117647059) : HopeRGB(0.9198823529411766, 0.9041694117647059, 0.8920501960784314)
}
case .lavender:
switch role {
case .ground: return dark ? HopeRGB(0.12549019607843137, 0.10588235294117647, 0.1607843137254902) : HopeRGB(0.996078431372549, 0.9921568627450981, 1)
case .surface: return dark ? HopeRGB(0.1803921568627451, 0.15294117647058825, 0.23529411764705882) : HopeRGB(0.9647058823529412, 0.9490196078431372, 0.9882352941176471)
case .personaSurface: return dark ? HopeRGB(0.23137254901960785, 0.19607843137254902, 0.30196078431372547) : HopeRGB(0.9333333333333333, 0.9098039215686274, 0.9725490196078431)
case .ink: return dark ? HopeRGB(0.9686274509803922, 0.9450980392156862, 1) : HopeRGB(0.20392156862745098, 0.17647058823529413, 0.2627450980392157)
case .quiet: return dark ? HopeRGB(0.7843137254901961, 0.7294117647058823, 0.8745098039215686) : HopeRGB(0.40784313725490196, 0.3686274509803922, 0.4823529411764706)
case .accent: return dark ? HopeRGB(0.8117647058823529, 0.7058823529411765, 0.9568627450980393) : HopeRGB(0.4392156862745098, 0.3215686274509804, 0.611764705882353)
case .onAccent: return dark ? HopeRGB(0.12549019607843137, 0.10588235294117647, 0.1607843137254902) : HopeRGB(1, 1, 1)
case .focus: return dark ? HopeRGB(0.8117647058823529, 0.7058823529411765, 0.9568627450980393) : HopeRGB(0.4392156862745098, 0.3215686274509804, 0.611764705882353)
case .controlBorder: return dark ? HopeRGB(0.7843137254901961, 0.7294117647058823, 0.8745098039215686) : HopeRGB(0.40784313725490196, 0.3686274509803922, 0.4823529411764706)
case .alert: return dark ? HopeRGB(1, 0.604, 0.675) : HopeRGB(0.675, 0.208, 0.282)
case .onAlert: return dark ? HopeRGB(0.12549019607843137, 0.10588235294117647, 0.1607843137254902) : HopeRGB(1, 1, 1)
case .separator: return dark ? HopeRGB(0.29074509803921567, 0.263843137254902, 0.3423529411764706) : HopeRGB(0.8581960784313726, 0.8408627450980392, 0.8866666666666667)
case .selectedWash: return dark ? HopeRGB(0.24352941176470588, 0.20823529411764707, 0.3074509803921569) : HopeRGB(0.9121568627450981, 0.8862745098039215, 0.9505882352941177)
case .errorWash: return dark ? HopeRGB(0.24596078431372548, 0.18902588235294118, 0.27047058823529413) : HopeRGB(0.941529411764706, 0.8897380392156863, 0.9317364705882354)
}
}
}
static func color(_ role: HopeThemeRole, palette: HopePalette = .defaultPalette,
appearance: HopeAppearance = .system) -> Color {
let light = rgb(role, palette: palette, dark: false).uiColor
let dark = rgb(role, palette: palette, dark: true).uiColor
switch appearance {
case .light: return Color(uiColor: light)
case .dark: return Color(uiColor: dark)
case .system:
return Color(uiColor: UIColor { traits in
traits.userInterfaceStyle == .dark ? dark : light
})
}
}
}
@@ -0,0 +1,713 @@
import AVFoundation
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 = 2
@StateObject private var chat: ChatStore
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, bearerToken: bearerToken, userID: user.id))
let api: ChatTransport = auth.fixture ? OfflineChatAPI() : ChatAPI(config: auth.config, token: bearerToken)
_chat = StateObject(wrappedValue: ChatStore(api: api))
#if DEBUG
if auth.fixture && !ProcessInfo.processInfo.arguments.contains("--ui-chat") { _selectedTab = State(initialValue: 0) }
#endif
}
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, bearerToken: bearerToken), settings: settings, userID: userID)
#if DEBUG
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()
}
if ProcessInfo.processInfo.arguments.contains("--ui-long-transcript") {
for index in 1...30 {
let role: TranscriptEvent.Role = index.isMultiple(of: 2) ? .agent : .user
session.transcriptHandler.receiveDataTranscript(
role: role,
text: "Earlier message \(index): a deliberately long restored transcript row for reading-position verification."
)
}
}
#endif
return session
}
var body: some View {
TabView(selection: $selectedTab) {
NativeChatView(store: chat, talk: { id in
await session.bindConversation(id)
selectedTab = 0
}, logout: {
await auth.logout { chat.close(); await session.disconnect() }
})
.tabItem { Label("Chats", systemImage: "bubble.left.and.text.bubble.right") }
.tag(2)
NavigationStack {
ConversationContent(settings: settings, session: session, displayName: user.displayName ?? user.username)
.toolbar {
settingsLink
ToolbarItem(placement: .topBarLeading) {
Menu {
Button("Sign out", role: .destructive) {
Task { await auth.logout { chat.close(); await session.disconnect() } }
}
} label: {
Image(systemName: "person.crop.circle")
.frame(minWidth: 44, minHeight: 44)
}
.accessibilityLabel("Account")
}
}
}
.tabItem { Label("Talk", systemImage: "bubble.left.and.bubble.right") }
.tag(0)
ActivityTab(taskStore: session.taskStore, settings: settings, session: session)
.tag(1)
}
.tint(HopeTheme.color(.accent, palette: HopePalette(rawValue: chat.preferences.palette) ?? .opal))
.preferredColorScheme(chat.preferences.appearance == "system" ? nil : (chat.preferences.appearance == "dark" ? .dark : .light))
.onChange(of: settings.textMode) { _, enabled in
Task { await session.applyTextMode(enabled) }
}
.onChange(of: settings.serverURL) { _, value in
if auth.fixture { replaceServer(value) }
}
.onAppear {
auth.disconnectAccount = { [weak session, weak chat] in chat?.close(); await session?.disconnect() }
let unauthorized: () async -> Void = { [weak auth, weak session, weak chat] in
await auth?.logout { chat?.close(); await session?.disconnect() }
}
session.onUnauthorized = unauthorized
chat.onUnauthorized = unauthorized
chat.features.onUnauthorized = unauthorized
}
.disabled(auth.isBusy || auth.token == nil)
}
@ToolbarContentBuilder private var settingsLink: some ToolbarContent {
ToolbarItem(placement: .topBarTrailing) {
NavigationLink {
SettingsView(settings: settings, session: session)
} label: {
Image(systemName: "gearshape").frame(minWidth: 44, minHeight: 44)
}
.accessibilityLabel("Settings")
}
}
private func replaceServer(_ value: String) {
let url = value.trimmingCharacters(in: .whitespacesAndNewlines)
guard case .success = ServerConfig.parse(url) else { return }
pendingServerURL = url
guard !isReplacingSession else { return }
isReplacingSession = true
Task { @MainActor in
// Coalesce rapid Apply actions; construct from the captured URL.
while let next = pendingServerURL {
pendingServerURL = nil
guard next != activeServerURL else { continue }
await session.disconnect()
activeServerURL = next
session = Self.makeSession(next, settings: settings, bearerToken: bearerToken, userID: user.id)
}
isReplacingSession = false
}
}
}
private struct ActivityTab: View {
@ObservedObject var taskStore: TaskStore
@ObservedObject var settings: SettingsStore
@ObservedObject var session: VoiceSession
var body: some View {
NavigationStack {
TaskPanelView(taskStore: taskStore)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
NavigationLink {
SettingsView(settings: settings, session: session)
} label: {
Image(systemName: "gearshape").frame(minWidth: 44, minHeight: 44)
}
.accessibilityLabel("Settings")
}
}
}
.tabItem { Label("Activity", systemImage: "checklist") }
.badge(taskStore.activeCount)
}
}
/// Maps session + conversation models onto Lightwell presentation phases.
@MainActor
private func lightwellPhase(session: VoiceSession, conversation: ConversationStateModel,
textMode: Bool) -> LightwellView.Phase {
if session.state.isFailed { return .failed }
switch session.state {
case .idle: return .idle
case .connecting: return .connecting
case .waitingAgent: return .waiting
case .tearingDown: return .tearingDown
case .failed: return .failed
case .ready:
// Only substitute idle input geometry when otherwise LISTENING.
// Assistant output (speaking/thinking) must stay represented in
// Type and muted modes per spec.
switch conversation.phase {
case .speaking: return .speaking(level: conversation.agentLevel)
case .thinking: return .thinking
default:
if textMode || session.isMuted { return .readyNoInput }
return .listening(level: conversation.micLevel)
}
}
}
private struct ConversationContent: View {
@ObservedObject var settings: SettingsStore
@ObservedObject var session: VoiceSession
@ObservedObject private var transcripts: TranscriptHandler
@ObservedObject private var conversation: ConversationStateModel
let displayName: String
@State private var composerText = ""
@State private var sendError: String?
@State private var isSending = false
@FocusState private var composerFocused: Bool
@Environment(\.dynamicTypeSize) private var typeSize
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@Environment(\.colorSchemeContrast) private var contrast
init(settings: SettingsStore, session: VoiceSession, displayName: String) {
self.settings = settings
self.session = session
self.displayName = displayName
transcripts = session.transcriptHandler
conversation = session.stateModel
}
var body: some View {
VStack(spacing: 0) {
if transcripts.events.isEmpty && !session.isReady {
welcome
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)
}
}
.background(Nearlight.ground)
.navigationTitle("Hope")
.navigationBarTitleDisplayMode(.inline)
.safeAreaInset(edge: .bottom, spacing: 0) { dock }
.onChange(of: session.state) { _, state in
if state == .ready && settings.textMode { composerFocused = true }
}
.sensoryFeedback(.success, trigger: session.state == .ready) { old, new in
// Success only on the transition INTO readiness, not out.
!old && new
}
.sensoryFeedback(.error, trigger: session.state.isFailed) { old, new in
// Error only on entering failure, not leaving it.
!old && new
}
}
// MARK: Welcome (pre-connection, no history)
private var welcome: some View {
ScrollView {
VStack(spacing: Nearlight.s16) {
VStack(spacing: Nearlight.s8) {
Text("A little room for you,")
.font(.largeTitle.weight(.regular))
Text(greetingName + ".")
.font(.system(.largeTitle, design: .serif).italic())
.foregroundStyle(Nearlight.rosewood)
Text("No perfect words needed. Start anywhere.")
.font(.subheadline)
.foregroundStyle(Nearlight.quiet)
}
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
.padding(.top, Nearlight.s24)
LightwellView(phase: lightwellPhase(session: session, conversation: conversation, textMode: settings.textMode))
.padding(.top, Nearlight.s8)
Text(statusTitle)
.font(.footnote)
.foregroundStyle(Nearlight.rosewood)
.accessibilityIdentifier("talk.status")
if !typeSize.isAccessibilitySize {
VStack(spacing: Nearlight.s8) {
HStack(spacing: Nearlight.s8) {
prompt("Talk through my day", draft: "Help me talk through my day.")
prompt("Explore an idea", draft: "Help me explore an idea.")
}
prompt("Make a plan", draft: "Help me make a plan.")
}
.padding(.top, Nearlight.s8)
}
}
.frame(maxWidth: .infinity)
.padding(.horizontal, Nearlight.s20)
.padding(.bottom, Nearlight.s24)
}
}
private var greetingName: String {
displayName.split(separator: " ").first.map(String.init) ?? displayName
}
private func prompt(_ title: String, draft: String) -> some View {
Button {
// Prepare an editable draft, never submit a prompt or enable capture implicitly.
composerText = draft
settings.textMode = true
composerFocused = true
} label: {
Text(title)
.font(.footnote)
.padding(.horizontal, Nearlight.s16)
.frame(minHeight: 44)
.background(Nearlight.selectedWash, in: Capsule())
.foregroundStyle(Nearlight.rosewood)
}
.accessibilityHint("Prepares a message you can edit. Connect before sending.")
}
/// Status text 24 below the supporting copy on welcome.
private var statusLine: some View {
Text(statusTitle)
.font(NearlightFont.support(typeSize))
.foregroundStyle(Nearlight.quiet)
.padding(.top, Nearlight.s24)
.accessibilityIdentifier("talk.status")
}
// MARK: Status strip (active conversation)
private var statusStrip: some View {
VStack(spacing: Nearlight.s12) {
LightwellView(phase: lightwellPhase(session: session, conversation: conversation, textMode: settings.textMode), compact: true)
VStack(alignment: .center, spacing: 2) {
Text(statusTitle)
.font(NearlightFont.control(typeSize))
.foregroundStyle(Nearlight.ink)
.accessibilityIdentifier("talk.status")
if showStatusDetail {
Text(statusDetail)
.font(NearlightFont.support(typeSize))
.foregroundStyle(Nearlight.quiet)
}
}
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
}
.frame(maxWidth: .infinity)
.padding(.horizontal, Nearlight.s20)
.padding(.vertical, Nearlight.s12)
.background(Nearlight.ground)
}
/// Subtitle omitted for ordinary listening/thinking/speaking per spec.
private var showStatusDetail: Bool {
guard session.isReady else { return true }
if settings.textMode || session.isMuted { return true }
return false
}
private var reconnectBanner: some View {
VStack(alignment: .leading, spacing: 2) {
Label("Reconnecting…", systemImage: "wifi.exclamationmark")
.font(NearlightFont.control(typeSize))
Text("Your conversation is still here.")
.font(NearlightFont.support(typeSize))
.foregroundStyle(Nearlight.quiet)
}
.padding(Nearlight.s12)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Nearlight.surface)
.clipShape(RoundedRectangle(cornerRadius: Nearlight.radiusControl))
.padding(.horizontal, Nearlight.s20)
.accessibilityIdentifier("talk.connection.notice")
}
/// Classified failure notice with actionable recovery.
private var failureBanner: some View {
VStack(alignment: .leading, spacing: Nearlight.s8) {
Label(failureTitle, systemImage: "exclamationmark.circle")
.font(NearlightFont.control(typeSize))
.foregroundStyle(Nearlight.alert)
Text(failureBody)
.font(NearlightFont.support(typeSize))
.foregroundStyle(Nearlight.ink)
if micDenied {
Button("Open iPhone Settings") {
if let url = URL(string: UIApplication.openSettingsURLString) {
UIApplication.shared.open(url)
}
}
.font(NearlightFont.support(typeSize))
.frame(minHeight: 44)
.accessibilityIdentifier("talk.connection.openSystemSettings")
}
}
.padding(Nearlight.s16)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Nearlight.errorWash)
.clipShape(RoundedRectangle(cornerRadius: Nearlight.radiusControl))
.padding(.horizontal, Nearlight.s20)
.accessibilityIdentifier("talk.connection.notice")
}
private var micDenied: Bool {
guard session.state == .ready, let micError = session.micError else {
return false
}
return AVAudioApplication.shared.recordPermission == .denied
|| micError.localizedCaseInsensitiveContains("denied")
}
private var failureTitle: String {
if session.micError != nil { return "Microphone unavailable" }
return session.everConnected ? "Connection interrupted" : "Couldn’t connect"
}
private var failureBody: String {
if let micError = session.micError {
return micError
}
let raw = failureMessage ?? ""
// Only the agent-timeout path is a known classified cause; every
// other failure keeps its actual readable error per spec.
if raw.contains("Agent never joined") {
return "Check that Hope is running on your server."
}
return raw.isEmpty ? "The connection failed. Try again." : raw
}
// MARK: Dock
private var dock: some View {
VStack(spacing: Nearlight.s12) {
if let sendError {
Label(sendError, systemImage: "exclamationmark.circle")
.font(NearlightFont.support(typeSize))
.foregroundStyle(Nearlight.alert)
.frame(maxWidth: .infinity, alignment: .leading)
.accessibilityIdentifier("talk.composer.error")
}
modeSelector
if session.isReady {
if settings.textMode { typeControls } else { voiceControls }
} else if isBusy && !session.state.isFailed {
// Connecting/waiting: keep the draft readable and expose the
// existing cancellation path per spec.
if settings.textMode { typeControls }
else { HStack(spacing: Nearlight.s12) { muteButton; micMeter } }
endButton
} else {
if settings.textMode { draftPreview }
startControls
}
}
.padding(.horizontal, Nearlight.s20)
.padding(.top, Nearlight.s12)
.padding(.bottom, Nearlight.s12)
.background(Nearlight.surface, in: RoundedRectangle(cornerRadius: Nearlight.radiusDock))
.padding(.horizontal, Nearlight.s16)
.padding(.bottom, Nearlight.s8)
}
@ViewBuilder private var modeSelector: some View {
if typeSize.isAccessibilitySize {
// Two stacked 52-point selection buttons at accessibility sizes.
VStack(spacing: Nearlight.s8) {
modeButton("Voice", systemImage: "mic", selected: !settings.textMode, id: "talk.mode.voice") {
settings.textMode = false
}
modeButton("Type", systemImage: "keyboard", selected: settings.textMode, id: "talk.mode.type") {
settings.textMode = true
}
}
.disabled(isBusy)
} else {
Picker("Conversation mode", selection: $settings.textMode) {
Label("Voice", systemImage: "mic").tag(false)
Label("Type", systemImage: "keyboard").tag(true)
}
.pickerStyle(.segmented)
.disabled(isBusy)
}
}
private func modeButton(_ title: String, systemImage: String, selected: Bool,
id: String, action: @escaping () -> Void) -> some View {
Button(action: action) {
HStack(spacing: Nearlight.s8) {
Image(systemName: systemImage)
Text(title)
if selected { Spacer(); Image(systemName: "checkmark") }
}
.font(NearlightFont.control(typeSize))
.foregroundStyle(selected ? Nearlight.rosewood : Nearlight.ink)
.frame(maxWidth: .infinity, minHeight: 52)
.background(selected && contrast == .increased ? Nearlight.surface : selected ? Nearlight.selectedWash : Nearlight.surface,
in: RoundedRectangle(cornerRadius: Nearlight.radiusControl))
.overlay(
RoundedRectangle(cornerRadius: Nearlight.radiusControl)
.strokeBorder(selected && contrast == .increased ? Nearlight.rosewood : selected ? Nearlight.rosewood.opacity(0.4) : Nearlight.quiet,
lineWidth: selected && contrast == .increased ? 2 : 1)
)
}
.accessibilityIdentifier(id)
.accessibilityAddTraits(selected ? .isSelected : [])
}
private var draftPreview: some View {
TextField("What’s on your mind?", text: $composerText, axis: .vertical)
.lineLimit(1...3)
.font(.body)
.padding(Nearlight.s12)
.background(Nearlight.selectedWash, in: RoundedRectangle(cornerRadius: Nearlight.radiusControl))
.focused($composerFocused)
.accessibilityLabel("Message Hope")
}
private var startControls: some View {
VStack(spacing: Nearlight.s8) {
Text(settings.textMode ? "Your microphone stays off." : "Your microphone starts after you connect.")
.font(NearlightFont.support(typeSize))
.foregroundStyle(Nearlight.quiet)
Button {
sendError = nil
Task { await session.connect() }
} label: {
HStack(spacing: Nearlight.s8) {
if isBusy {
ProgressView().tint(Nearlight.onRosewood)
} else {
Image(systemName: settings.textMode ? "keyboard" : "mic")
}
Text(isBusy ? statusTitle : session.state.isFailed ? "Try again" : "Start chatting")
.font(NearlightFont.control(typeSize))
}
.frame(maxWidth: .infinity, minHeight: 52)
.background(isBusy ? Nearlight.rosewood.opacity(0.6) : Nearlight.rosewood,
in: RoundedRectangle(cornerRadius: Nearlight.radiusControl))
.foregroundStyle(Nearlight.onRosewood)
}
.disabled(isBusy)
.accessibilityIdentifier("start.chatting")
}
}
private var voiceControls: some View {
Group {
if typeSize.isAccessibilitySize {
VStack(spacing: Nearlight.s12) {
HStack(spacing: Nearlight.s12) {
muteButton
micMeter
}
endButton
}
} else {
HStack(spacing: Nearlight.s12) {
muteButton
micMeter
endButton
}
}
}
}
private var muteButton: some View {
Button {
Task { await session.setMuted(!session.isMuted) }
} label: {
Image(systemName: session.isMuted ? "mic.slash" : "mic")
.font(.system(size: 20, weight: .medium))
.frame(width: 52, height: 52)
.background(session.isMuted ? Nearlight.selectedWash : Nearlight.surface,
in: RoundedRectangle(cornerRadius: Nearlight.radiusControl))
.overlay(
RoundedRectangle(cornerRadius: Nearlight.radiusControl)
.strokeBorder(Nearlight.quiet, lineWidth: 1)
)
.foregroundStyle(session.isMuted ? Nearlight.rosewood : Nearlight.ink)
}
.accessibilityLabel(session.isMuted ? "Unmute microphone" : "Mute microphone")
.accessibilityIdentifier("talk.microphone.toggle")
.sensoryFeedback(.selection, trigger: session.isMuted)
}
/// Five-bar capsule meter, 64×24, leading aligned.
private var micMeter: some View {
HStack(spacing: 6) {
let level = session.isMuted || settings.textMode ? 0.0 : conversation.micLevel
let filled = Int(ceil(level * 5))
ForEach(0..<5, id: \.self) { index in
Capsule()
.fill(index < filled ? Nearlight.rosewood : Color.clear)
.overlay(index < filled ? nil : Capsule().strokeBorder(Nearlight.quiet, lineWidth: 1))
.frame(width: 6, height: [6.0, 12.0, 24.0, 12.0, 6.0][index])
}
}
.frame(width: 64, height: 24, alignment: .leading)
.accessibilityElement(children: .ignore)
.accessibilityLabel("Microphone level")
.accessibilityValue(session.isMuted || settings.textMode ? "Off" : "\(Int(round(conversation.micLevel * 100)))%")
}
private var typeControls: some View {
VStack(spacing: Nearlight.s12) {
HStack(alignment: .bottom, spacing: Nearlight.s8) {
TextField("What’s on your mind?", text: $composerText, axis: .vertical)
.lineLimit(1...5)
.padding(Nearlight.s12)
.background(Nearlight.surface, in: RoundedRectangle(cornerRadius: Nearlight.radiusControl))
.overlay(
RoundedRectangle(cornerRadius: Nearlight.radiusControl)
.strokeBorder(Nearlight.quiet, lineWidth: 1)
)
.focused($composerFocused)
.disabled(isSending)
.accessibilityLabel("Message Hope")
Button(action: sendComposer) {
if isSending {
ProgressView().frame(width: 52, height: 52)
} else {
Image(systemName: "arrow.up")
.font(.system(size: 20, weight: .medium))
.frame(width: 52, height: 52)
.background(Nearlight.rosewood, in: RoundedRectangle(cornerRadius: Nearlight.radiusControl))
.foregroundStyle(Nearlight.onRosewood)
}
}
.disabled(composerText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isSending || session.isReconnecting || !session.isReady)
.accessibilityLabel("Send message")
}
endButton
}
}
private var endButton: some View {
Button(role: .destructive) {
composerFocused = false
Task { await session.disconnect() }
} label: {
Label("End conversation", systemImage: "stop.fill")
.font(NearlightFont.control(typeSize))
.lineLimit(2...3)
.frame(maxWidth: .infinity, minHeight: 52)
.background(Nearlight.surface, in: RoundedRectangle(cornerRadius: Nearlight.radiusControl))
.overlay(
RoundedRectangle(cornerRadius: Nearlight.radiusControl)
.strokeBorder(Nearlight.alert, lineWidth: 1)
)
.foregroundStyle(Nearlight.alert)
}
.accessibilityIdentifier("end.conversation")
}
// MARK: Status copy
private var isBusy: Bool {
session.state == .connecting || session.state == .waitingAgent || session.state == .tearingDown
}
private var failureMessage: String? {
if case .failed(let message) = session.state { return message }
return nil
}
private var failureNotice: String? { session.micError ?? failureMessage }
private var statusTitle: String {
switch session.state {
case .idle: return "Ready when you are"
case .connecting: return "Connecting to your server…"
case .waitingAgent: return "Waiting for Hope…"
case .tearingDown: return "Ending conversation…"
case .failed: return "Connection interrupted"
case .ready:
// Assistant output outranks input-mode substitution.
switch conversation.phase {
case .speaking: return "Hope is speaking"
case .thinking: return "Thinking…"
default:
if settings.textMode { return "Ready for your message" }
if session.isMuted { return "Microphone off" }
return "Listening"
}
}
}
private var statusDetail: String {
if session.state == .waitingAgent { return "The server is connected. Hope has not joined yet." }
if session.state == .connecting { return "Reaching your server." }
if !session.isReady { return "" }
if settings.textMode { return "Microphone off · Type whenever you’re ready" }
if session.isMuted { return "You can still hear Hope." }
return ""
}
private func sendComposer() {
let text = composerText.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty, !isSending else { return }
isSending = true
sendError = nil
Task {
do {
try await session.sendText(text)
composerText = ""
} catch {
sendError = "Your message wasn’t sent. Try again when connected."
}
isSending = false
}
}
}
extension VoiceSession {
/// Whether any connection reached ready during this session lifetime.
/// Used to title the failure notice per spec.
var everConnected: Bool {
// Sessions are recreated on replacement; reaching ready is recorded
// indirectly through transcript presence. Conservative: treat any
// prior transcript activity as evidence of a completed connection.
!transcriptHandler.events.isEmpty
}
}
#Preview { AuthenticationRoot(settings: SettingsStore()) }
@@ -0,0 +1,125 @@
import SwiftUI
/// Hope's square LED presence, matching the web's 21 × 21 dot matrix.
/// One Canvas replaces hundreds of subviews. The adjacent status text is the
/// single accessible description; individual LEDs are never focusable.
struct LightwellView: View {
enum Phase: Equatable {
case idle, connecting, waiting, thinking, tearingDown, failed, readyNoInput
case listening(level: Double)
case speaking(level: Double)
}
let phase: Phase
var compact = false
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@Environment(\.colorSchemeContrast) private var contrast
@Environment(\.scenePhase) private var scenePhase
@Environment(\.dynamicTypeSize) private var typeSize
private var side: CGFloat {
if compact { return typeSize.isAccessibilitySize ? 80 : 112 }
return typeSize.isAccessibilitySize ? 136 : 184
}
private var animated: Bool {
guard !reduceMotion, scenePhase == .active else { return false }
switch phase {
case .connecting, .waiting, .thinking, .listening, .speaking: return true
default: return false
}
}
var body: some View {
Group {
if animated {
TimelineView(.animation(minimumInterval: 1.0 / 20.0)) { timeline in
matrix(time: timeline.date.timeIntervalSinceReferenceDate)
}
} else {
matrix(time: 0)
}
}
.frame(width: side, height: side)
.background(Nearlight.personaSurface, in: RoundedRectangle(cornerRadius: Nearlight.radiusDock))
.overlay {
RoundedRectangle(cornerRadius: Nearlight.radiusDock)
.strokeBorder(contrast == .increased ? Nearlight.quiet : Nearlight.separator, lineWidth: 1)
}
.accessibilityHidden(true)
}
private var tint: Color {
switch phase {
case .failed: return Nearlight.alert
case .tearingDown: return Nearlight.quiet
default: return Nearlight.rosewood
}
}
private func matrix(time: Double) -> some View {
Canvas { context, size in
let spacing = (min(size.width, size.height) - 28) / 20
for row in 0..<21 {
for column in 0..<21 {
let dot = Self.dot(row: row, column: column, phase: phase,
time: time, reduceMotion: reduceMotion)
let diameter = max(1.6, spacing * 0.28) * dot.scale
let center = CGPoint(x: 14 + CGFloat(column) * spacing,
y: 14 + CGFloat(row) * spacing)
let rect = CGRect(x: center.x - diameter / 2, y: center.y - diameter / 2,
width: diameter, height: diameter)
// Increased contrast brightens the whole lattice; state is
// encoded in its pattern and adjacent text, never color alone.
let opacity = contrast == .increased ? min(1, dot.opacity * 1.25 + 0.16) : dot.opacity
context.fill(Path(ellipseIn: rect), with: .color(tint.opacity(opacity)))
}
}
}
}
struct Dot: Equatable {
let opacity: Double
let scale: Double
}
/// Deterministic sampling is shared with offline tests. Reduce Motion freezes
/// waves and signal-driven geometry, while keeping each state's pattern clear.
static func dot(row: Int, column: Int, phase: Phase, time: Double, reduceMotion: Bool) -> Dot {
let x = Double(column - 10) / 10.5
let y = Double(row - 10) / 10.5
let radius = sqrt(x * x + y * y)
let t = reduceMotion ? 0 : time
let baseline = max(0.08, 0.20 - radius * 0.075)
let offset = sin(Double(row) * 12.9898 + Double(column) * 78.233)
var brightness = baseline
var scale = 1.0
switch phase {
case .idle, .tearingDown:
break
case .readyNoInput:
brightness += 0.28 * exp(-radius * radius * 3)
case .failed:
// A stable diagonal cross rather than an animated alarm.
if abs(row - column) <= 1 || abs(row + column - 20) <= 1 { brightness = 0.65 }
case .connecting, .waiting:
let ring = exp(-pow((radius - 0.53) * 7, 2))
let sweep = reduceMotion ? 0.75 : 0.55 + 0.45 * cos(atan2(y, x) - t * 2)
brightness += ring * sweep * 0.65
case .thinking:
let bandCenter = reduceMotion ? 0 : sin(t * 1.8) * 0.5
let band = exp(-pow((y - bandCenter) * 4, 2)) * exp(-x * x * 2)
brightness += band * 0.65
case .listening(let raw), .speaking(let raw):
let level = reduceMotion ? 0.35 : (raw.isFinite ? min(max(raw, 0), 1) : 0)
let speaking: Bool
if case .speaking = phase { speaking = true } else { speaking = false }
let signal = min(1, level * 1.3)
let bloom = exp(-radius * radius * (3.0 - signal * 1.5))
let ripple = reduceMotion ? 1 : 0.85 + 0.15 * sin(t * 4 + offset)
brightness += bloom * (0.25 + signal * 0.7) * ripple * (speaking ? 1 : 0.65)
scale += max(0, 1 - radius) * (speaking ? 1.4 : 0.85) * signal
}
return Dot(opacity: min(max(brightness, 0.02), 1), scale: scale)
}
}
@@ -0,0 +1,153 @@
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?
@State private var savedServer: String?
@State private var passwordVisible = false
@Environment(\.dynamicTypeSize) private var typeSize
var body: some View {
NavigationStack {
Form {
Section {
VStack(alignment: .leading, spacing: Nearlight.s12) {
Text("Sign in to Hope")
.font(NearlightFont.welcome(typeSize))
.foregroundStyle(Nearlight.ink)
.accessibilityAddTraits(.isHeader)
Text("Use the account provided by your server administrator. Hermes is managed by your server.")
.font(.body)
.foregroundStyle(Nearlight.quiet)
.fixedSize(horizontal: false, vertical: true)
}
.padding(.vertical, Nearlight.s16)
.listRowBackground(Nearlight.ground)
.listRowSeparator(.hidden)
}
Section("Account") {
TextField("Username", text: $username).textContentType(.username)
.textInputAutocapitalization(.never).autocorrectionDisabled()
HStack {
Group {
if passwordVisible { TextField("Password", text: $password) }
else { SecureField("Password", text: $password) }
}
.textContentType(.password)
Button(passwordVisible ? "Hide" : "Show") { passwordVisible.toggle() }
.accessibilityLabel(passwordVisible ? "Hide password" : "Show password")
}
Button("Sign in") {
let suppliedPassword = password
Task {
await auth.login(username: username, password: suppliedPassword)
if auth.user != nil { password = "" }
}
}
.font(NearlightFont.control(typeSize))
.buttonStyle(.borderedProminent)
.tint(Nearlight.rosewood)
.foregroundStyle(Nearlight.onRosewood)
.frame(minHeight: 44)
.padding(.vertical, Nearlight.s4)
.disabled(auth.isBusy || username.isEmpty || password.isEmpty)
if auth.isBusy { ProgressView("Signing in…").tint(Nearlight.rosewood) }
if let error = auth.error {
Text(error).foregroundStyle(Nearlight.alert)
.fixedSize(horizontal: false, vertical: true)
}
}
.listRowBackground(Nearlight.surface)
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):
server = config.baseURL.absoluteString
settings.serverURL = config.baseURL.absoluteString
serverError = nil
savedServer = config.baseURL.absoluteString
case .failure(let error): serverError = error.localizedDescription; savedServer = nil
}
}.disabled(auth.isBusy || server.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || server == settings.serverURL)
if let serverError {
Label(serverError, systemImage: "exclamationmark.circle").foregroundStyle(Nearlight.alert)
.fixedSize(horizontal: false, vertical: true)
} else if savedServer != nil {
Label("Server address saved. Sign in uses this server.", systemImage: "checkmark.circle").font(.footnote).foregroundStyle(Nearlight.quiet)
}
Text("Connections use HTTPS and secure WebSockets. A private server certificate must be trusted by this iPhone for conversations.")
.font(.footnote).foregroundStyle(Nearlight.quiet)
.fixedSize(horizontal: false, vertical: true)
if auth.config.baseURL.host == TLSPinning.host, auth.config.baseURL.port == 9178 {
Link("Install Hope server certificate in Safari", destination: auth.config.baseURL.appendingPathComponent("hope-server.mobileconfig"))
Text("Install Hope LAN Server Trust in Settings → General → VPN & Device Management. Then enable 192.168.86.2 in Settings → General → About → Certificate Trust Settings. Only trust this certificate for your own server.")
.font(.footnote).foregroundStyle(Nearlight.quiet)
.fixedSize(horizontal: false, vertical: true)
}
}
.listRowBackground(Nearlight.surface)
}
.foregroundStyle(Nearlight.ink)
.tint(Nearlight.rosewood)
.scrollContentBackground(.hidden)
.background(Nearlight.ground)
.navigationTitle("Hope")
.toolbarBackground(Nearlight.ground, for: .navigationBar)
.onAppear { server = settings.serverURL }
.onChange(of: server) { _, _ in serverError = nil; savedServer = nil }
}
}
}
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)
}
}
}
@@ -0,0 +1,236 @@
import SwiftUI
/// Nearlight transcript: a legible shared record. Full-width rows, no
/// bubbles; metadata row per event; "Live" placeholder on partials.
struct TranscriptListView: View {
let events: [TranscriptEvent]
var onClear: () -> Void
@State private var confirmClear = false
@State private var followingLatest = true
@State private var selectionActive = false
@State private var viewportHeight: CGFloat = 0
@State private var latestBottom: CGFloat = 0
@AccessibilityFocusState private var focusedEventID: String?
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@Environment(\.dynamicTypeSize) private var typeSize
init(events: [TranscriptEvent], onClear: @escaping () -> Void) {
self.events = events
self.onClear = onClear
_followingLatest = State(initialValue: events.isEmpty)
}
/// Auto-follow eligibility: never scroll while the user is selecting
/// text or reading history.
private var isFollowingEligible: Bool {
followingLatest && !selectionActive && focusedEventID == nil
}
private static let deliveryMarkers = #"\[(?:appreciative|calm|concerned|curious|encouraging|excited|happy|joking|quiet|reassuring|reflective|sad|serious|surprised|sympathetic|urgent|whispering|laughter|sighing|breathing|neutral)\]\s*"#
var body: some View {
ScrollViewReader { proxy in
VStack(spacing: 0) {
HStack {
Text("Conversation")
.font(NearlightFont.support(typeSize).weight(.semibold))
.foregroundStyle(Nearlight.quiet)
Spacer()
Menu {
Button("Clear conversation", systemImage: "trash", role: .destructive) { confirmClear = true }
} label: {
Image(systemName: "ellipsis").frame(width: 44, height: 44)
}
.accessibilityLabel("Conversation options")
.disabled(events.isEmpty)
}
.padding(.horizontal, Nearlight.s20)
ScrollView {
LazyVStack(alignment: .leading, spacing: Nearlight.s24) {
if events.isEmpty {
Text("Your conversation will appear here.")
.font(.body)
.foregroundStyle(Nearlight.quiet)
.padding(.top, Nearlight.s24)
}
ForEach(events) { event in transcriptRow(event) }
GeometryReader { geometry in
Color.clear.preference(
key: TranscriptLatestBottomKey.self,
value: geometry.frame(in: .named("transcriptScroll")).maxY
)
}
.frame(height: 1)
.id("latest")
}
.padding(.horizontal, Nearlight.s20)
.padding(.top, Nearlight.s16)
.padding(.bottom, Nearlight.s20)
.frame(minHeight: 200, alignment: .topLeading)
}
.coordinateSpace(name: "transcriptScroll")
.background {
GeometryReader { geometry in
Color.clear.preference(
key: TranscriptViewportHeightKey.self,
value: geometry.size.height
)
}
}
.scrollDismissesKeyboard(.interactively)
.defaultScrollAnchor(.top)
.simultaneousGesture(DragGesture().onChanged { _ in
// Any manual drag suspends automatic following.
followingLatest = false
})
.onChange(of: events.last?.text) { _, _ in
guard isFollowingEligible else { return }
proxy.scrollTo("latest", anchor: .bottom)
}
.onChange(of: events.count) { _, count in
if count == 0 {
selectionActive = false
followingLatest = true
} else if isFollowingEligible {
proxy.scrollTo("latest", anchor: .bottom)
}
}
.onChange(of: selectionActive) { _, active in
// Beginning text selection suspends auto-follow so the
// user's reading position is never disturbed.
if active { followingLatest = false }
}
.onChange(of: focusedEventID) { _, eventID in
// VoiceOver focus on any transcript row means the user is
// reading history. Only Latest resumes automatic following.
if eventID != nil { followingLatest = false }
}
.onPreferenceChange(TranscriptViewportHeightKey.self) { height in
viewportHeight = height
updateFollowingEligibility(bottom: latestBottom, viewport: height)
}
.onPreferenceChange(TranscriptLatestBottomKey.self) { bottom in
latestBottom = bottom
updateFollowingEligibility(bottom: bottom, viewport: viewportHeight)
}
if !followingLatest && !events.isEmpty {
HStack {
Spacer()
Button {
// Explicit Latest restores full eligibility,
// clearing any stale selection suspension.
selectionActive = false
focusedEventID = nil
followingLatest = true
withAnimation(reduceMotion ? nil : Nearlight.standard) {
proxy.scrollTo("latest", anchor: .bottom)
}
} label: {
Label("Latest", systemImage: "arrow.down")
.font(NearlightFont.support(typeSize))
.padding(.horizontal, Nearlight.s12)
.frame(minHeight: 44)
.background(Nearlight.surface, in: Capsule())
.overlay(Capsule().strokeBorder(Nearlight.quiet, lineWidth: 1))
}
.accessibilityIdentifier("talk.latest")
}
.padding(.horizontal, Nearlight.s12)
.padding(.bottom, Nearlight.s12)
}
}
}
.alert("Clear this conversation?", isPresented: $confirmClear) {
Button("Clear conversation", role: .destructive, action: onClear)
Button("Cancel", role: .cancel) {}
} message: {
Text("This removes the transcript saved on this iPhone. It doesn’t reset Hope’s memory or end your current conversation.")
}
}
private func updateFollowingEligibility(bottom: CGFloat, viewport: CGFloat) {
guard bottom > 0,
viewport > 0,
!selectionActive,
focusedEventID == nil else { return }
let shouldFollow = bottom <= viewport + 48
if followingLatest != shouldFollow {
followingLatest = shouldFollow
}
}
private func transcriptRow(_ event: TranscriptEvent) -> some View {
let isUser = event.role == .user
let text = event.text.replacingOccurrences(of: Self.deliveryMarkers, with: "", options: .regularExpression)
.trimmingCharacters(in: .whitespaces)
return VStack(alignment: .leading, spacing: Nearlight.s8) {
// Metadata row: speaker, timestamp trailing. Stacks at
// accessibility sizes per spec.
Group {
if typeSize.isAccessibilitySize {
VStack(alignment: .leading, spacing: Nearlight.s4) {
speakerLabel(isUser)
timeLabel(event)
}
} else {
HStack(spacing: Nearlight.s8) {
speakerLabel(isUser)
timeLabel(event)
}
}
}
Text(text)
.font(.body)
.foregroundStyle(Nearlight.ink)
.textSelection(.enabled)
.fixedSize(horizontal: false, vertical: true)
}
.frame(maxWidth: .infinity, alignment: .leading)
.onLongPressGesture(minimumDuration: 0.05, pressing: { pressing in
// Text-selection interaction begins with long-press on the row;
// suspend auto-follow while pressing/held and restore when the
// press ends so following can resume.
selectionActive = pressing
}, perform: {})
.contextMenu {
Button("Copy message", systemImage: "doc.on.doc") { UIPasteboard.general.string = text }
}
.accessibilityElement(children: .combine)
.accessibilityFocused($focusedEventID, equals: event.id)
.id(event.id)
}
private func speakerLabel(_ isUser: Bool) -> some View {
Text(isUser ? "You" : "Hope")
.font(NearlightFont.metadata(typeSize).weight(.semibold))
.foregroundStyle(Nearlight.ink)
}
private func timeLabel(_ event: TranscriptEvent) -> some View {
Group {
if event.isFinal {
Text(event.timestamp, style: .time)
} else {
Text("Live")
}
}
.font(NearlightFont.metadata(typeSize))
.foregroundStyle(Nearlight.quiet)
.monospacedDigit()
}
}
private struct TranscriptViewportHeightKey: PreferenceKey {
static var defaultValue: CGFloat = 0
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = nextValue()
}
}
private struct TranscriptLatestBottomKey: PreferenceKey {
static var defaultValue: CGFloat = 0
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = nextValue()
}
}
@@ -0,0 +1,89 @@
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?
var body: some View {
Form {
Section("Server") {
LabeledContent("URL") {
Text(settings.trimmedServerURL)
.font(.footnote.monospaced())
.foregroundStyle(.secondary)
}
if let urlError {
Label(urlError, systemImage: "xmark.octagon")
.foregroundStyle(.red)
}
}
Section {
Button(action: runTest) {
HStack {
Spacer()
if isBusy {
ProgressView()
} else {
Text("Test connection")
}
Spacer()
}
}
.disabled(isBusy || settings.trimmedServerURL.isEmpty)
}
if let result {
switch result {
case .success(let response):
Section("Server access verified") {
LabeledContent("Room") {
Text(response.room)
.font(.footnote.monospaced())
}
LabeledContent("User") {
Text(response.user.displayName ?? response.user.id)
}
Label("Account access and session setup verified. This does not test microphone permission, secure voice transport, or spoken replies.", systemImage: "checkmark.circle")
.foregroundStyle(.green)
Text("Start a conversation to verify live voice and replies.").font(.footnote).foregroundStyle(.secondary)
}
case .failure(let error):
Section("Error") {
Label(error.localizedDescription, systemImage: "xmark.octagon")
.foregroundStyle(.red)
.font(.footnote)
}
}
}
}
.navigationTitle("Connection test")
.navigationBarTitleDisplayMode(.inline)
}
private func runTest() {
result = nil
urlError = nil
switch ServerConfig.parse(settings.trimmedServerURL) {
case .success(let config):
isBusy = true
Task {
let client = TokenClient(config: config, bearerToken: bearerToken)
do {
let response = try await client.fetchToken()
result = .success(response)
} catch {
result = .failure(error)
}
isBusy = false
}
case .failure(let error):
urlError = error.localizedDescription
}
}
}
#Preview {
ConnectionTestView(settings: SettingsStore())
}
@@ -0,0 +1,261 @@
import SwiftUI
struct SettingsView: View {
@ObservedObject var settings: SettingsStore
/// The current voice session, directly observed so the Apply button's
/// disabled state and help text update reactively when the session
/// transitions (e.g. `.tearingDown` → `.failed`).
@ObservedObject var session: VoiceSession
/// Local draft of the server URL. Only written to `settings` when the user
/// explicitly applies a value that passes validation — keystrokes never
/// reach storage or the network layer.
@State private var urlText: String = ""
@State private var urlError: String?
@State private var showVoicePicker = false
@State private var savedURLConfirmation: String?
@State private var pendingServerChange: String?
@FocusState private var urlFocused: Bool
init(settings: SettingsStore, session: VoiceSession) {
self.settings = settings
self.session = session
}
/// Whether the current session is safe to replace. Apply is permitted for
/// `.idle` (no active call) and `.failed` (fully torn down after failure —
/// the user needs to correct an unreachable server without relaunching).
/// Disabled during `.tearingDown`, `.connecting`, `.waitingAgent`, and
/// `.ready` to avoid disconnecting an active call or racing cleanup.
private var canApplyServerURL: Bool {
session.state == .idle || session.state.isFailed
}
var body: some View {
Form {
Section {
Button {
showVoicePicker = true
} label: {
LabeledContent("Speaking voice", value: Voices.display(for: settings.voiceID) ?? settings.voiceID)
}
Picker("Conversation mode", selection: $settings.textMode) {
Text("Voice").tag(false)
Text("Type · Microphone off").tag(true)
}
} header: {
Text("Make it yours")
} footer: {
Text("Voice and typing preferences are saved on this iPhone. You can switch modes from the Talk tab.")
}
Section {
Text("Server URL")
.font(.footnote.weight(.semibold))
.foregroundStyle(Nearlight.quiet)
TextField(SettingsStore.defaultServerURL, text: $urlText)
.keyboardType(.URL)
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
.accessibilityLabel("Server URL")
.focused($urlFocused)
.onAppear { urlText = settings.serverURL }
.onChange(of: urlText) { _, newValue in
// Keep confirmation across the asynchronous onChange
// caused by successful whitespace normalization.
guard newValue != savedURLConfirmation else { return }
savedURLConfirmation = nil
urlError = nil
}
.overlay(alignment: .trailing) {
if savedURLConfirmation != nil {
Image(systemName: "checkmark.circle")
.foregroundStyle(Nearlight.quiet)
.padding(.trailing, 8)
.accessibilityHidden(true)
}
}
if let urlError {
Label(urlError, systemImage: "exclamationmark.circle")
.font(.footnote)
.foregroundStyle(Nearlight.alert)
.accessibilityIdentifier("settings.server.error")
} else if savedURLConfirmation != nil {
Text("Server address saved.")
.font(.footnote)
.foregroundStyle(Nearlight.quiet)
.accessibilityIdentifier("settings.server.saved")
}
Button("Change server") { requestServerURLChange() }
.disabled(!canApplyServerURL || urlText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || urlText == settings.serverURL)
if !canApplyServerURL {
Text(session.state == .tearingDown
? "Waiting for failure cleanup to finish…"
: "End the conversation before changing servers.")
.font(.footnote)
.foregroundStyle(Nearlight.quiet)
}
NavigationLink("Connection test") {
ConnectionTestView(settings: settings, bearerToken: session.bootstrapBearerToken)
}
} header: {
Text("Connection")
} footer: {
Text("Hope runs on your own server. Your iPhone needs to be on the same network or a network that can reach it.")
}
}
.navigationTitle("Settings")
.navigationBarTitleDisplayMode(.inline)
.scrollContentBackground(.hidden)
.background(Nearlight.ground)
.tint(Nearlight.rosewood)
.sheet(isPresented: $showVoicePicker) {
VoicePickerView(settings: settings, session: session)
}
.confirmationDialog("Change Hope server?", isPresented: Binding(get: { pendingServerChange != nil }, set: { if !$0 { pendingServerChange = nil } }), titleVisibility: .visible) {
Button("Change server and sign out", role: .destructive) {
if let pendingServerChange { applyServerURL(pendingServerChange) }
pendingServerChange = nil
}
Button("Cancel", role: .cancel) { pendingServerChange = nil }
} message: {
Text("Changing servers signs you out and ends the current session. Conversations and account data remain with the current server.")
}
}
/// Validates the draft and persists it only on success. On failure the
/// draft stays in the field with an inline error; `settings.serverURL` is
/// left untouched so the active session keeps using its original URL.
/// Guarded: only applies when the session is idle (no active call).
private func requestServerURLChange() {
savedURLConfirmation = nil
guard canApplyServerURL else { urlError = "End the conversation before changing servers."; return }
let trimmed = urlText.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { urlError = "Enter a server URL."; return }
switch ServerConfig.parse(trimmed) {
case .success(let config):
pendingServerChange = config.baseURL.absoluteString
urlError = nil
case .failure(let error):
urlError = error.localizedDescription
urlFocused = true
}
}
private func applyServerURL(_ canonicalURL: String) {
settings.serverURL = canonicalURL
urlText = canonicalURL
savedURLConfirmation = canonicalURL
urlError = nil
urlFocused = false
}
}
struct VoicePickerView: View {
@ObservedObject var settings: SettingsStore
@ObservedObject var session: VoiceSession
@Environment(\.dismiss) private var dismiss
@State private var voices = Voices.all
@State private var search = ""
@State private var loading = false
@State private var catalogMessage: String?
@State private var catalogAvailable = false
@State private var requestID = UUID()
@State private var applyingVoiceID: String?
@State private var voiceError: String?
private var filteredVoices: [VoiceOption] {
let query = search.trimmingCharacters(in: .whitespacesAndNewlines)
return voices.filter { query.isEmpty || "\($0.name) \($0.details)".localizedCaseInsensitiveContains(query) }
}
@MainActor private func loadVoices() async {
guard !loading else { return }
loading = true
let id = UUID()
requestID = id
defer { if requestID == id { loading = false } }
do {
guard case .success(let config) = ServerConfig.parse(settings.serverURL) else { throw AuthError.invalidResponse }
let response = try await AuthClient(config: config).voices(token: session.bootstrapBearerToken)
try Task.checkCancellation()
guard requestID == id else { return }
let valid = response.voices.filter { $0.id.range(of: "^[A-Za-z0-9:_-]{1,120}$", options: .regularExpression) != nil }
guard !valid.isEmpty else { throw AuthError.invalidResponse }
voices = valid
catalogAvailable = response.catalogAvailable
catalogMessage = response.catalogAvailable ? nil : response.message
} catch is CancellationError {
return
} catch {
guard !Task.isCancelled, requestID == id else { return }
catalogAvailable = false
catalogMessage = "Couldn’t load all English voices. Your current choices remain available. Check your sign-in and server update, then retry."
}
}
var body: some View {
NavigationStack {
List {
Section {
Text("Ava Dragon HD Omni is the recommended female default. Search by name, locale, gender, or model.")
.font(.footnote)
.foregroundStyle(.secondary)
if loading { ProgressView("Loading English voices…") }
if let catalogMessage { Text(catalogMessage).font(.footnote).foregroundStyle(.secondary) }
if let voiceError { Label(voiceError, systemImage: "exclamationmark.circle").font(.footnote).foregroundStyle(.red) }
if !catalogAvailable && !loading {
Button("Retry voice catalog") { Task { await loadVoices() } }
}
}
if filteredVoices.isEmpty {
Text("No matching voices. Try another name or locale.").foregroundStyle(.secondary)
}
ForEach(filteredVoices, id: \.id) { voice in
Button {
guard applyingVoiceID == nil else { return }
applyingVoiceID = voice.id
voiceError = nil
Task {
let applied = await session.selectVoice(voice.id)
applyingVoiceID = nil
if applied { dismiss() }
else { voiceError = "Couldn’t apply this voice to the live conversation. Your previous voice is still selected." }
}
} label: {
HStack {
VStack(alignment: .leading, spacing: 3) {
Text(voice.name)
Text(voice.details).font(.caption).foregroundStyle(.secondary)
}
if settings.voiceID == voice.id {
Spacer()
Image(systemName: "checkmark")
.foregroundStyle(Nearlight.rosewood)
}
}
}
.disabled(applyingVoiceID != nil)
.accessibilityAddTraits(settings.voiceID == voice.id ? .isSelected : [])
}
}
.navigationTitle("English voices")
.searchable(text: $search, prompt: "Name, locale, gender, or Omni")
.task { await loadVoices() }
.onDisappear { requestID = UUID(); loading = false }
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
}
}
}
}
}
#Preview {
let previewSettings = SettingsStore()
guard case .success(let previewConfig) = ServerConfig.parse(SettingsStore.defaultServerURL) else {
return AnyView(Text("Invalid default server URL"))
}
let previewSession = VoiceSession(config: previewConfig, tokenClient: TokenClient(config: previewConfig), settings: previewSettings)
return AnyView(NavigationStack {
SettingsView(settings: previewSettings, session: previewSession)
})
}
@@ -0,0 +1,265 @@
import SwiftUI
/// Nearlight Activity: a working log. Plain rows on Ground, no cards;
/// live updates land in place without reordering while reading.
struct TaskPanelView: View {
@ObservedObject var taskStore: TaskStore
@Environment(\.dynamicTypeSize) private var typeSize
var body: some View {
Group {
if taskStore.sortedTasks.isEmpty {
emptyState
} else {
taskList
}
}
.navigationTitle("Activity")
.background(Nearlight.ground)
}
private var taskList: some View {
ScrollView {
LazyVStack(alignment: .leading, spacing: 0) {
ForEach(taskStore.sortedTasks) { task in
NavigationLink {
TaskDetailView(taskStore: taskStore, taskID: task.id)
} label: {
TaskRowView(task: task)
}
.buttonStyle(.plain)
Divider()
.overlay(Nearlight.separator)
}
}
.padding(.horizontal, Nearlight.s20)
}
}
private var emptyState: some View {
VStack(alignment: .leading, spacing: Nearlight.s8) {
Image(systemName: "checklist")
.font(.system(size: 28))
.foregroundStyle(Nearlight.quiet)
Text("No tasks yet")
.font(NearlightFont.heading(typeSize))
Text("Tasks from your conversations will appear here.")
.font(.body)
.foregroundStyle(Nearlight.quiet)
}
.padding(.top, Nearlight.s32)
.padding(.horizontal, Nearlight.s20)
.frame(maxWidth: .infinity, alignment: .leading)
.accessibilityIdentifier("activity.empty")
}
}
/// One activity row: status symbol, title, current step/status, chevron.
private struct TaskRowView: View {
let task: TaskItem
@Environment(\.dynamicTypeSize) private var typeSize
var body: some View {
HStack(spacing: Nearlight.s12) {
Image(systemName: statusSymbol)
.font(.system(size: 20))
.foregroundStyle(statusColor)
.accessibilityHidden(true)
VStack(alignment: .leading, spacing: Nearlight.s4) {
Text(statusTitle)
.font(NearlightFont.support(typeSize))
.foregroundStyle(statusColor)
.fixedSize(horizontal: false, vertical: true)
Text(task.description)
.font(NearlightFont.control(typeSize))
.foregroundStyle(Nearlight.ink)
.fixedSize(horizontal: false, vertical: true)
if let latest = latestStep {
Text(latest)
.font(NearlightFont.support(typeSize))
.foregroundStyle(Nearlight.quiet)
.fixedSize(horizontal: false, vertical: true)
}
Text(task.createdAt, style: .time)
.font(NearlightFont.metadata(typeSize))
.foregroundStyle(Nearlight.quiet)
.monospacedDigit()
}
Spacer(minLength: 0)
Image(systemName: "chevron.right")
.font(.system(size: 13, weight: .medium))
.foregroundStyle(Nearlight.quiet)
.accessibilityHidden(true)
}
.padding(.vertical, Nearlight.s16)
.contentShape(Rectangle())
}
private var statusTitle: String {
switch task.status {
case .pending: return "Queued"
case .running: return "In progress"
case .completed: return "Complete"
case .failed: return "Needs attention"
}
}
private var latestStep: String? {
if let error = task.error { return error }
if let result = task.result { return result }
return task.steps.last?.content
}
private var statusSymbol: String {
switch task.status {
case .pending: return "circle.dotted"
case .running: return "arrow.triangle.2.circlepath"
case .completed: return "checkmark.circle"
case .failed: return "exclamationmark.circle"
}
}
private var statusColor: Color {
switch task.status {
case .pending: return Nearlight.quiet
case .running: return Nearlight.rosewood
case .completed: return Nearlight.quiet
case .failed: return Nearlight.alert
}
}
}
/// Native push detail for one task: title, status, steps, result/error.
/// Looks the task up live in the store so removal shows the missing state.
struct TaskDetailView: View {
@ObservedObject var taskStore: TaskStore
let taskID: String
@Environment(\.dynamicTypeSize) private var typeSize
private var task: TaskItem? {
taskStore.tasks[taskID]
}
var body: some View {
Group {
if let task {
TaskDetailContent(task: task)
} else {
Text("This task is no longer available.")
.font(.body)
.foregroundStyle(Nearlight.quiet)
.padding(Nearlight.s20)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
.background(Nearlight.ground)
.navigationTitle("Task")
.navigationBarTitleDisplayMode(.inline)
}
}
private struct TaskDetailContent: View {
let task: TaskItem
@Environment(\.dynamicTypeSize) private var typeSize
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: Nearlight.s24) {
VStack(alignment: .leading, spacing: Nearlight.s12) {
Text(task.description)
.font(NearlightFont.heading(typeSize))
.fixedSize(horizontal: false, vertical: true)
Label(statusTitle, systemImage: statusSymbol)
.font(NearlightFont.support(typeSize))
.foregroundStyle(statusColor)
Text(task.createdAt, style: .time)
.font(NearlightFont.metadata(typeSize))
.foregroundStyle(Nearlight.quiet)
.monospacedDigit()
}
VStack(alignment: .leading, spacing: 0) {
Text("Steps")
.font(NearlightFont.heading(typeSize))
.padding(.bottom, Nearlight.s8)
if task.steps.isEmpty {
Text("No steps reported yet.")
.font(.body)
.foregroundStyle(Nearlight.quiet)
} else {
ForEach(task.steps) { step in
VStack(alignment: .leading, spacing: Nearlight.s4) {
if let role = step.role {
Text(roleLabel(role))
.font(NearlightFont.support(typeSize).weight(.semibold))
.foregroundStyle(Nearlight.quiet)
}
Text(step.content ?? "")
.font(.body)
.foregroundStyle(Nearlight.ink)
.textSelection(.enabled)
.fixedSize(horizontal: false, vertical: true)
}
.padding(.vertical, Nearlight.s16)
}
}
}
if let result = task.result {
Text(result)
.font(.body)
.foregroundStyle(Nearlight.ink)
.textSelection(.enabled)
}
if let error = task.error {
Text(error)
.font(.body)
.foregroundStyle(Nearlight.alert)
.textSelection(.enabled)
}
}
.padding(.horizontal, Nearlight.s20)
.padding(.top, Nearlight.s24)
.padding(.bottom, Nearlight.s24)
}
}
private var statusTitle: String {
switch task.status {
case .pending: return "Queued"
case .running: return "In progress"
case .completed: return "Complete"
case .failed: return "Needs attention"
}
}
private var statusSymbol: String {
switch task.status {
case .pending: return "circle.dotted"
case .running: return "arrow.triangle.2.circlepath"
case .completed: return "checkmark.circle"
case .failed: return "exclamationmark.circle"
}
}
private var statusColor: Color {
switch task.status {
case .pending: return Nearlight.quiet
case .running: return Nearlight.rosewood
case .completed: return Nearlight.quiet
case .failed: return Nearlight.alert
}
}
private func roleLabel(_ role: String) -> String {
switch role {
case "thinking": return "Thinking"
case "tool_call": return "Tool"
default: return role
}
}
}
#Preview {
NavigationStack {
TaskPanelView(taskStore: TaskStore())
}
}
+38
View File
@@ -0,0 +1,38 @@
import Foundation
import LiveKit
/// Observes the agent participant's audio level for UI state inference.
@MainActor
final class AgentMonitor: ObservableObject {
@Published private(set) var agentSpeaking = false
@Published private(set) var localSpeaking = false
private let session: VoiceSession
private var pollTask: Task<Void, Never>?
init(session: VoiceSession) {
self.session = session
}
func start() {
stop()
pollTask = Task { [weak self] in
while !Task.isCancelled {
guard let self else { return }
let remote = self.session.room.remoteParticipants.values.first(where: {
$0.kind == .agent || $0.identity?.stringValue == "voice-assistant"
})
self.agentSpeaking = remote?.isSpeaking ?? false
self.localSpeaking = self.session.room.localParticipant.isSpeaking
try? await Task.sleep(for: .milliseconds(250))
}
}
}
func stop() {
pollTask?.cancel()
pollTask = nil
agentSpeaking = false
localSpeaking = false
}
}
+123
View File
@@ -0,0 +1,123 @@
import Foundation
import LiveKit
/// Outbound helpers for agent control topics and inbound parsing of
/// voice-control / conversation-control data messages.
@MainActor
final class ControlChannel: NSObject, RoomDelegate {
enum InboundEvent {
case agentReady
case taskEvent(TaskEventPayload)
}
struct TaskEventPayload: Equatable {
let type: String
let taskId: String?
let description: String?
let role: String?
let content: String?
let result: String?
let error: String?
let createdAt: Double?
}
var onEvent: ((InboundEvent) -> Void)?
private let decoder = JSONDecoder()
/// Inbound voice-control handling. Returns true when the message was consumed.
func handleVoiceControl(_ data: Data, session: VoiceSession) -> Bool {
guard let json = try? decoder.decode([String: JSONValue].self, from: data),
let type = json["type"]?.stringValue else { return false }
switch type {
case "agent_ready":
Task { @MainActor [weak session] in
session?.agentReadyReceived()
}
return true
default:
return false
}
}
/// Inbound task_event handling (topic-agnostic per web UI). The agent publishes
/// {type:"task_event", task_id, event, ...} — the discriminator is `event`, not `type`.
func handleTaskEvent(_ data: Data) -> Bool {
guard let json = try? decoder.decode([String: JSONValue].self, from: data),
json["type"]?.stringValue == "task_event",
let type = json["event"]?.stringValue,
["created", "step", "completed", "failed"].contains(type) else { return false }
let taskObj = json["task"]?.objectValue ?? [:]
let payload = TaskEventPayload(
type: type,
taskId: json["task_id"]?.stringValue,
description: json["description"]?.stringValue ?? taskObj["description"]?.stringValue,
role: json["role"]?.stringValue,
content: json["content"]?.stringValue,
result: json["result"]?.stringValue,
error: json["error"]?.stringValue,
createdAt: json["created_at"]?.doubleValue ?? taskObj["created_at"]?.doubleValue
)
onEvent?(.taskEvent(payload))
return true
}
// MARK: Outbound (topic'd JSON over reliable data streams)
private func publish(_ object: [String: Any], topic: String, room: Room) async throws {
let data = try JSONSerialization.data(withJSONObject: object)
try await room.localParticipant.publish(data: data, options: DataPublishOptions(topic: topic, reliable: true))
}
func setVoice(_ voiceID: String, room: Room) async throws {
try await publish(["type": "set_voice", "voice": voiceID], topic: "voice-control", room: room)
}
func setMode(_ mode: String, room: Room) async throws {
try await publish(["type": "set_mode", "mode": mode], topic: "conversation-control", room: room)
}
}
// MARK: - Minimal JSON value decoder for flexible agent payloads
indirect enum JSONValue: Decodable {
case string(String)
case number(Double)
case bool(Bool)
case object([String: JSONValue])
case array([JSONValue])
case null
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
if container.decodeNil() { self = .null }
else if let value = try? container.decode(Bool.self) { self = .bool(value) }
else if let value = try? container.decode(Double.self) { self = .number(value) }
else if let value = try? container.decode(String.self) { self = .string(value) }
else if let value = try? container.decode([JSONValue].self) { self = .array(value) }
else if let value = try? container.decode([String: JSONValue].self) { self = .object(value) }
else { throw DecodingError.dataCorruptedError(in: container, debugDescription: "Unsupported JSON value") }
}
var stringValue: String? {
if case .string(let value) = self { return value }
return nil
}
var doubleValue: Double? {
if case .number(let value) = self { return value }
return nil
}
var objectValue: [String: JSONValue]? {
if case .object(let value) = self { return value }
return nil
}
var arrayValue: [JSONValue]? {
if case .array(let value) = self { return value }
return nil
}
}
@@ -0,0 +1,94 @@
import Foundation
import os
enum ConversationPhase: String {
case idle
case connecting
case listening
case thinking
case speaking
}
/// Ports the web UI's `inferState` + EMA smoothing (app.js): mic/agent levels
/// are smoothed with `prev * 0.6 + raw * 0.4`, thresholds are user > 0.035 and
/// agent > 0.02. When remote audio ends, the state returns to listening
/// (matching the current web reference at app.js:414-426) rather than becoming
/// "thinking" forever.
@MainActor
final class ConversationStateModel: ObservableObject {
@Published private(set) var phase: ConversationPhase = .idle
@Published private(set) var micLevel: Double = 0
@Published private(set) var agentLevel: Double = 0
@Published private(set) var isThinkingChipVisible = false
static let userThreshold = 0.035
static let agentThreshold = 0.02
static let thinkingDelay: Duration = .seconds(2)
private var smoothedMic: Double = 0
private var smoothedAgent: Double = 0
private var lastTransition = Date()
private var chipTask: Task<Void, Never>?
func start() {
stop()
setPhase(.connecting)
}
func stop() {
chipTask?.cancel()
chipTask = nil
smoothedMic = 0
smoothedAgent = 0
micLevel = 0
agentLevel = 0
isThinkingChipVisible = false
setPhase(.idle)
}
func tick(localAudioLevel: Float, remoteAudioLevel: Float) {
guard phase != .idle else { return }
smoothedMic = smoothedMic * 0.6 + Double(localAudioLevel) * 0.4
smoothedAgent = smoothedAgent * 0.6 + Double(remoteAudioLevel) * 0.4
micLevel = smoothedMic
agentLevel = smoothedAgent
let userSpeaking = smoothedMic > Self.userThreshold
let next: ConversationPhase
if smoothedAgent > Self.agentThreshold {
next = .speaking
} else if userSpeaking {
next = .listening
} else {
// Both silent: return to listening. The previous implementation
// converted speaking → thinking forever, leaving the UI apparently
// frozen after an agent response (matches current web reference).
next = .listening
}
setPhase(next)
}
private func setPhase(_ next: ConversationPhase) {
guard next != phase else { return }
let old = phase
logger.log("state \(old.rawValue, privacy: .public) -> \(next.rawValue, privacy: .public)")
phase = next
lastTransition = Date()
if next == .thinking {
chipTask?.cancel()
chipTask = Task { [weak self] in
try? await Task.sleep(for: Self.thinkingDelay)
guard !Task.isCancelled, let self else { return }
if self.phase == .thinking {
self.isThinkingChipVisible = true
}
}
} else {
chipTask?.cancel()
chipTask = nil
isThinkingChipVisible = false
}
}
}
private let logger = Logger(subsystem: "dev.shane.voice", category: "state")
+130
View File
@@ -0,0 +1,130 @@
import Foundation
import os
private let logger = Logger(subsystem: "dev.shane.voice", category: "tasks")
struct TaskStep: Identifiable, Equatable {
let id = UUID()
let role: String?
let content: String?
}
struct TaskItem: Identifiable, Equatable {
enum Status: Equatable {
case pending
case running
case completed
case failed
}
let id: String
var description: String
var status: Status
var steps: [TaskStep]
var result: String?
var error: String?
var createdAt: Date
var completedAt: Date?
}
/// Folds task_event messages into task items, mirroring the web UI's tasks Map.
@MainActor
final class TaskStore: ObservableObject {
@Published private(set) var tasks: [String: TaskItem] = [:]
var sortedTasks: [TaskItem] {
tasks.values.sorted { $0.createdAt > $1.createdAt }
}
var activeCount: Int {
tasks.values.filter { $0.status == .pending || $0.status == .running }.count
}
func apply(_ payload: ControlChannel.TaskEventPayload) {
guard let taskId = payload.taskId, !taskId.isEmpty else { return }
switch payload.type {
case "created":
tasks[taskId] = TaskItem(
id: taskId,
description: payload.description ?? "Background task",
status: .pending,
steps: [],
result: nil,
error: nil,
createdAt: date(from: payload.createdAt),
completedAt: nil
)
case "step":
var task = tasks[taskId] ?? TaskItem(
id: taskId,
description: payload.description ?? "Background task",
status: .pending,
steps: [],
result: nil,
error: nil,
createdAt: date(from: payload.createdAt),
completedAt: nil
)
task.steps.append(TaskStep(role: payload.role, content: payload.content))
if task.status == .pending { task.status = .running }
tasks[taskId] = task
case "completed":
var task = tasks[taskId] ?? TaskItem(
id: taskId,
description: payload.description ?? "Background task",
status: .pending,
steps: [],
result: nil,
error: nil,
createdAt: date(from: payload.createdAt),
completedAt: nil
)
task.status = .completed
task.result = payload.result
task.completedAt = Date()
tasks[taskId] = task
case "failed":
var task = tasks[taskId] ?? TaskItem(
id: taskId,
description: payload.description ?? "Background task",
status: .pending,
steps: [],
result: nil,
error: nil,
createdAt: date(from: payload.createdAt),
completedAt: nil
)
task.status = .failed
task.error = payload.error
task.completedAt = Date()
tasks[taskId] = task
default:
return
}
logger.log("task \(payload.type, privacy: .public) id=\(taskId, privacy: .public) steps=\(self.tasks[taskId]?.steps.count ?? 0, privacy: .public)")
}
func clear() {
tasks.removeAll()
}
#if DEBUG
func injectSampleEvents() {
let now = Date().timeIntervalSince1970
apply(ControlChannel.TaskEventPayload(type: "created", taskId: "sample-a", description: "Research the best coffee roasters in Portland", role: nil, content: nil, result: nil, error: nil, createdAt: now - 45))
apply(ControlChannel.TaskEventPayload(type: "step", taskId: "sample-a", description: nil, role: "thinking", content: "Searching for Portland roasteries…", result: nil, error: nil, createdAt: nil))
apply(ControlChannel.TaskEventPayload(type: "step", taskId: "sample-a", description: nil, role: "tool_result", content: "Found 12 results across 3 review sites.", result: nil, error: nil, createdAt: nil))
apply(ControlChannel.TaskEventPayload(type: "completed", taskId: "sample-a", description: nil, role: nil, content: nil, result: "Top three: Coava, Stumptown, and Heart Roasters — all scored 4.5+ across reviews.", error: nil, createdAt: nil))
apply(ControlChannel.TaskEventPayload(type: "created", taskId: "sample-b", description: "Compare iPhone 17 vs Pixel 10 battery life", role: nil, content: nil, result: nil, error: nil, createdAt: now - 20))
apply(ControlChannel.TaskEventPayload(type: "step", taskId: "sample-b", description: nil, role: "thinking", content: "Fetching benchmark articles…", result: nil, error: nil, createdAt: nil))
apply(ControlChannel.TaskEventPayload(type: "failed", taskId: "sample-b", description: nil, role: nil, content: nil, result: nil, error: "Search backend timed out after 30s", createdAt: nil))
}
#endif
}
private extension TaskStore {
func date(from interval: Double?) -> Date {
guard let interval, interval > 0 else { return Date() }
return Date(timeIntervalSince1970: interval)
}
}
+214
View File
@@ -0,0 +1,214 @@
import Foundation
import LiveKit
struct TranscriptEvent: Equatable, Identifiable {
enum Role: String {
case user
case agent
}
let id: String
let role: Role
var text: String
var isFinal: Bool
let timestamp: Date
}
/// Merges the primary transcription segments (Room delegate) with the
/// secondary `transcript` data topic (finals only).
///
/// **Single canonical collection:** `events` is the one visible transcript.
/// It is initialized from the persisted history snapshot at init time and
/// updated in place by live events. The view renders only `events`.
///
/// **Cross-source dedupe:** Records `(role, normalized text, source, timestamp)`
/// and suppresses only the *opposite-source* copy within `dedupeWindow` seconds.
/// Same-source repeated utterances are never suppressed, even inside the window.
@MainActor
final class TranscriptHandler: ObservableObject {
/// The single canonical visible transcript. Initialized from persisted
/// history; live events append/update here. Trimmed to 50 finals.
@Published private(set) var events: [TranscriptEvent] = []
var acceptsRoom: ((Room) -> Bool)?
let history: TranscriptHistory
// MARK: Cross-source dedupe state
enum Source {
case primary // LiveKit transcription segments
case data // `transcript` data topic
}
struct RecentFinal {
let source: Source
let timestamp: Date
}
/// Keyed by normalized (role, text). Records the most recent final for
/// each key and which source produced it. Only the opposite-source copy
/// is suppressed within the window; same-source repeats pass through.
private var recentFinals: [String: RecentFinal] = [:]
static let dedupeWindow: TimeInterval = 5
static let maxEvents = 50
/// Injectable clock for deterministic tests. Defaults to `Date()`.
var now: () -> Date = { Date() }
convenience init() {
self.init(history: TranscriptHistory())
}
init(history: TranscriptHistory) {
self.history = history
// Initialize the canonical events from the persisted snapshot so the
// UI shows prior-session transcripts without a separate "restored" store.
events = history.entries.map { entry in
TranscriptEvent(
id: "hist-\(entry.timestamp.timeIntervalSince1970)-\(entry.role)",
role: entry.role == "agent" ? .agent : .user,
text: entry.text,
isFinal: true,
timestamp: entry.timestamp
)
}
}
// MARK: Ingest
func receiveSegment(id: String, role: TranscriptEvent.Role, text: String, isFinal: Bool) {
guard !text.isEmpty else { return }
if isFinal {
// Cross-source dedupe: suppress only if the *data* source already
// recorded this exact final within the window. Same-source (primary)
// repeats are always allowed. When suppressing, consume (remove) the
// matched record so a second same-source repeat from primary is not
// also suppressed — the next arrival establishes a new pair.
let key = Self.dedupeKey(role: role, text: text)
if let recent = recentFinals[key],
recent.source == .data,
now().timeIntervalSince(recent.timestamp) < Self.dedupeWindow {
// Consume: remove the matched opposite-source record.
recentFinals[key] = nil
return
}
}
upsert(id: id, role: role, text: text, isFinal: isFinal)
if isFinal {
recordFinal(role: role, text: text, source: .primary)
trimEvents()
syncPersistence()
}
}
func receiveDataTranscript(role: TranscriptEvent.Role, text: String) {
guard !text.isEmpty else { return }
// When a data final arrives for a role that has an open partial, replace
// the partial's text with the final and mark it final in place. The data
// topic is the authoritative final; the user sees one row either way.
if let index = events.lastIndex(where: { !$0.isFinal && $0.role == role }) {
events[index].text = text
events[index].isFinal = true
recordFinal(role: role, text: text, source: .data)
trimEvents()
syncPersistence()
return
}
// No open partial: check whether the *primary* source already recorded
// this exact final within the window (primary→data order). Same-source
// (data) repeats are always allowed. When suppressing, consume (remove)
// the matched record so a second same-source repeat from data is not
// also suppressed — the next arrival establishes a new pair.
let key = Self.dedupeKey(role: role, text: text)
if let recent = recentFinals[key],
recent.source == .primary,
now().timeIntervalSince(recent.timestamp) < Self.dedupeWindow {
// Consume: remove the matched opposite-source record.
recentFinals[key] = nil
return
}
upsert(id: "final-\(role.rawValue)-\(UUID().uuidString)", role: role, text: text, isFinal: true)
recordFinal(role: role, text: text, source: .data)
trimEvents()
syncPersistence()
}
/// Mirrors the canonical finals into the persisted snapshot so the UI and
/// storage never diverge. Same-source repeats are preserved verbatim.
private func syncPersistence() {
let finals = events.filter(\.isFinal).map { (role: $0.role.rawValue, text: $0.text, timestamp: $0.timestamp) }
history.sync(finals: finals)
}
// MARK: Clear
/// Clears visible rows, dedupe state, and persistence atomically.
func clearAll() {
events.removeAll()
recentFinals.removeAll()
history.clear()
}
// MARK: Private
private func upsert(id: String, role: TranscriptEvent.Role, text: String, isFinal: Bool) {
if let index = events.firstIndex(where: { $0.id == id }) {
events[index].text = text
events[index].isFinal = isFinal
} else {
events.append(TranscriptEvent(id: id, role: role, text: text, isFinal: isFinal, timestamp: now()))
}
}
private func recordFinal(role: TranscriptEvent.Role, text: String, source: Source) {
let key = Self.dedupeKey(role: role, text: text)
recentFinals[key] = RecentFinal(source: source, timestamp: now())
// Prune expired entries to bound the dictionary.
let current = now()
for (k, recent) in recentFinals where current.timeIntervalSince(recent.timestamp) >= Self.dedupeWindow {
recentFinals[k] = nil
}
}
/// Keeps at most `maxEvents` finals in the canonical collection. Partial
/// (non-final) rows are never trimmed; only surplus old finals are dropped.
private func trimEvents() {
let finalCount = events.filter(\.isFinal).count
guard finalCount > Self.maxEvents else { return }
// Drop oldest finals until we're back to maxEvents.
var toRemove = finalCount - Self.maxEvents
var writeIndex = 0
for event in events {
if event.isFinal && toRemove > 0 {
toRemove -= 1
continue
}
events[writeIndex] = event
writeIndex += 1
}
events.removeSubrange(writeIndex...)
}
private static func dedupeKey(role: TranscriptEvent.Role, text: String) -> String {
let normalized = text.trimmingCharacters(in: .whitespacesAndNewlines)
.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression)
return "\(role.rawValue):\(normalized)"
}
}
extension TranscriptHandler: RoomDelegate {
nonisolated func room(_ room: Room, participant: Participant, trackPublication: TrackPublication, didReceiveTranscriptionSegments segments: [TranscriptionSegment]) {
let role: TranscriptEvent.Role = participant.isAgent ? .agent : .user
let isLocal = participant.identity?.stringValue == room.localParticipant.identity?.stringValue
let resolvedRole: TranscriptEvent.Role = isLocal ? .user : role
let events = segments.map {
TranscriptEvent(id: $0.id, role: resolvedRole, text: $0.text, isFinal: $0.isFinal, timestamp: $0.lastReceivedTime)
}
Task { @MainActor [weak self] in
guard let self, self.acceptsRoom?(room) ?? true else { return }
for event in events {
self.receiveSegment(id: event.id, role: event.role, text: event.text, isFinal: event.isFinal)
}
}
}
}
@@ -0,0 +1,53 @@
import Foundation
struct HistoryEntry: Codable, Equatable {
let role: String
let text: String
let timestamp: Date
}
/// Last 50 final transcript entries persisted as JSON in UserDefaults.
///
/// Persistence-only store. The visible transcript is the single canonical
/// collection owned by `TranscriptHandler.events`; this class provides the
/// durable snapshot it initializes from and syncs to. It performs no dedupe —
/// all suppression is source-aware and time-bounded in the handler.
@MainActor
final class TranscriptHistory: ObservableObject {
@Published private(set) var entries: [HistoryEntry] = []
static let key = "voice_history_isolated_tests"
private let storageKey: String
static let maxEntries = 50
init(origin: String? = nil, userID: String? = nil) {
// 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 decoded = try? JSONDecoder().decode([HistoryEntry].self, from: data) {
entries = Array(decoded.suffix(Self.maxEntries))
}
}
/// Replaces the persisted snapshot with exactly the finals currently in the
/// canonical visible collection. Called by `TranscriptHandler` after every
/// change so persistence and the UI never diverge. No dedupe is applied here:
/// same-source repeats are legitimate entries.
func sync(finals: [(role: String, text: String, timestamp: Date)]) {
entries = Array(finals.suffix(Self.maxEntries).map {
HistoryEntry(role: $0.role, text: $0.text, timestamp: $0.timestamp)
})
persist()
}
func clear() {
entries.removeAll()
persist()
}
private func persist() {
guard let data = try? JSONEncoder().encode(entries) else { return }
AppPreferences.store.set(String(decoding: data, as: UTF8.self), forKey: storageKey)
}
}
+822
View File
@@ -0,0 +1,822 @@
import UIKit
import AVFoundation
import Foundation
import LiveKit
import os
private let logger = Logger(subsystem: "dev.shane.voice", category: "session")
enum VoiceSessionState: Equatable {
case idle
case connecting
case waitingAgent
case ready
/// Failure teardown in progress. Start is unavailable; the session is not
/// yet safe for a fresh connect. Transitions to `.failed` when cleanup completes.
case tearingDown
case failed(String)
var isFailed: Bool {
if case .failed = self { return true }
return false
}
}
struct AgentInfo: Equatable {
let identity: String
let name: String?
}
/// Room lifecycle: connect, agent discovery, agent_ready wait, reconnect-once,
/// disconnect teardown. Ports the web UI's connect flow.
@MainActor
final class VoiceSession: ObservableObject {
@Published private(set) var state: VoiceSessionState = .idle
@Published private(set) var agent: AgentInfo?
@Published private(set) var isMuted = false
@Published private(set) var micError: String?
@Published private(set) var isReconnecting = false
@Published private(set) var assistantActivity: String?
private var workingRequests: [String: String] = [:]
/// Identity of the current room. Every async callback that could outlive a
/// disconnect/reconnect compares its captured generation against this before
/// mutating state (port of the web UI's `=== room` checks).
private(set) var sessionGeneration = 0
private(set) var room = Room()
let controlChannel = ControlChannel()
let transcriptHandler: TranscriptHandler
let stateModel = ConversationStateModel()
let taskStore = TaskStore()
private let config: ServerConfig
private let tokenClient: TokenClient
private let settings: SettingsStore
private var reconnectAttempted = false
private var connectAttempts = 0
private var agentWaitTask: Task<Void, Never>?
/// Whether the user (or session mode) intentionally wants the microphone
/// muted, independent of interruption-owned hardware state. Set by public
/// mute actions (`setMuted(true)`, text-mode enable) after the operation
/// succeeds; cleared by a successful normal mic enable (`enableMicrophone`).
/// Interruption begin operations use this *after* awaiting the predecessor
/// to decide whether to mute, rather than snapshotting transient `isMuted`
/// before the predecessor completes (Iteration 08 Item 1).
private(set) var userWantsMicMuted = false
/// Whether the current audio interruption actually muted the mic. Set only
/// *after* the mute operation completes successfully; cleared on `.ended`
/// and `disconnect()`.
private var micMutedByInterruption = false
/// Sequence number for interruption-owned operations. Incremented on every
/// `.began`; a `.ended` handler captures the sequence at `.began` time and
/// only resumes if its sequence is still current (no newer `.began` has
/// superseded it). This prevents out-of-order mute/unmute when interruptions
/// overlap or arrive in rapid succession.
private var interruptionSequence = 0
/// The retained operation chain for microphone interruption operations.
/// Both `.began` (mute) and `.ended` (unmute) are assigned to this chain so
/// every new operation awaits its predecessor. This guarantees:
/// - A newer `.began` orders after an in-flight old end/unmute and finishes
/// by muting again (the old unmute cannot leave hardware enabled).
/// - An `.ended` never launches unretained; it is part of the chain.
/// Cancelled on disconnect/failure.
private var micOpTask: Task<Void, Never>?
/// Set before awaiting `room.disconnect()` during intentional teardown so
/// the resulting `.disconnected` callback is recognized as expected (not a
/// terminal failure) and ignored. Cleared when the new room connects.
private var isTearingDown = false
init(config: ServerConfig, tokenClient: TokenClient, settings: SettingsStore, userID: String? = nil) {
transcriptHandler = TranscriptHandler(history: TranscriptHistory(origin: config.baseURL.absoluteString, userID: userID))
self.config = config
self.tokenClient = tokenClient
self.settings = settings
transcriptHandler.acceptsRoom = { [weak self] source in
guard let self else { return false }
return self.room === source && !self.isTearingDown && self.state == .ready
}
room.add(delegate: self)
room.add(delegate: controlChannel)
room.add(delegate: transcriptHandler)
controlChannel.onEvent = { [weak self] event in
Task { @MainActor [weak self] in
self?.handleControlEvent(event)
}
}
NotificationCenter.default.addObserver(
self, selector: #selector(handleAudioInterruption),
name: AVAudioSession.interruptionNotification, object: nil
)
}
deinit {
NotificationCenter.default.removeObserver(self)
}
/// Explicit chat-to-Talk navigation tears down the prior room first.
func bindConversation(_ id: String?) async {
await disconnect()
tokenClient.conversationID = id
}
private func handleControlEvent(_ event: ControlChannel.InboundEvent) {
// Restrict inbound control-event application to active lifecycle states
// so late events from a failed/tearing-down room cannot repopulate stale
// fields (agent, models, mic state) into a fresh retry (Iteration 08 Item 3).
guard !isTearingDown, state == .connecting || state == .waitingAgent || state == .ready else { return }
switch event {
case .taskEvent(let payload):
taskStore.apply(payload)
default:
break
}
}
var onUnauthorized: (() async -> Void)? {
get { tokenClient.onUnauthorized }
set { tokenClient.onUnauthorized = newValue }
}
var bootstrapBearerToken: String { tokenClient.bearerToken }
var isReady: Bool { state == .ready }
/// Clear all per-room/session ephemeral published fields so the failed or
/// idle UI and any subsequent retry do not display stale identity, error,
/// mute, model, or reconnect data from a torn-down room.
///
/// Deliberately preserved across reset: `settings` (durable user
/// preferences), `transcriptHandler` events (conversation history), and
/// `taskStore` (task history). These are not per-room ephemeral state.
///
/// Reused from `teardownForFailure` (both the initial pass and the final
/// pass immediately before `.failed` publication) and `disconnect()`.
private func resetEphemeralFields() {
agent = nil
isMuted = false
userWantsMicMuted = false
micError = nil
workingRequests.removeAll()
assistantActivity = nil
isReconnecting = false
reconnectAttempted = false
micMutedByInterruption = false
}
/// Pure predicate: whether a direct `RoomDelegate` callback that mutates
/// published ephemeral fields (agent discovery, connection-state changes)
/// should be accepted given the current lifecycle state and teardown flag.
///
/// Callbacks are accepted only for active lifecycle states (`.connecting`,
/// `.waitingAgent`, `.ready`) AND only when `isTearingDown` is false. This
/// prevents old-room callbacks delivered during failure teardown from
/// repopulating ephemeral fields that were just cleared (Iteration 09 Item 1).
func shouldAcceptRoomCallback(state: VoiceSessionState, isTearingDown: Bool) -> Bool {
!isTearingDown && (state == .connecting || state == .waitingAgent || state == .ready)
}
/// Start (or restart) a session. Accepts `.idle` and `.failed` so the
/// Start button can retry after a failure without first resetting to idle.
/// Rejects `.tearingDown`: failure cleanup must complete before a fresh
/// connect is allowed, preventing the old failure task from tearing down
/// the new room.
func connect() async {
guard state == .idle || state.isFailed else { return }
reconnectAttempted = false
await start()
}
/// Whether a failure teardown is currently in progress. Exposed for UI and
/// test purposes; `connect()` rejects while this is true.
var isFailureTearingDown: Bool { state == .tearingDown }
private func makeRoom() -> Room {
let room = Room()
room.add(delegate: self)
room.add(delegate: controlChannel)
room.add(delegate: transcriptHandler)
return room
}
private func start() async {
state = .connecting
isReconnecting = false
sessionGeneration += 1
let generation = sessionGeneration
connectAttempts = 0
await attemptConnect(generation: generation)
}
private func attemptConnect(generation: Int) async {
guard generation == sessionGeneration else { return }
let result: Result<Void, Error>
do {
let token = try await tokenClient.fetchToken()
guard generation == sessionGeneration, !Task.isCancelled, !isTearingDown else { return }
try await room.connect(url: config.livekitURL.absoluteString, token: token.token, connectOptions: makeConnectOptions())
result = .success(())
} catch {
result = .failure(error)
}
guard generation == sessionGeneration else { return }
switch result {
case .success:
logger.log("room connected")
UIApplication.shared.isIdleTimerDisabled = true
scanForAgent()
startAgentWait()
case .failure(let error):
let isTimeout = (error as? LiveKitError)?.type == .timedOut
if isTimeout, connectAttempts < 2 {
connectAttempts += 1
let attempt = connectAttempts
logger.log("connect attempt \(attempt) timed out, retrying with fresh room")
await teardownRoom()
guard generation == sessionGeneration else { return }
await attemptConnect(generation: generation)
return
}
logger.error("connect failed: \(String(describing: error), privacy: .public)")
await teardownForFailure(message: error.localizedDescription)
}
}
private func makeConnectOptions() -> ConnectOptions {
return ConnectOptions(
socketConnectTimeoutInterval: 15,
primaryTransportConnectTimeout: 20,
publisherTransportConnectTimeout: 20
)
}
/// Intentional, idempotent room teardown. Sets the teardown flag *before*
/// awaiting `room.disconnect()` so the resulting `.disconnected` callback is
/// recognized as expected and ignored (not treated as a terminal failure).
/// Safe to call when the room is already disconnected: LiveKit's
/// `disconnect()` is a no-op in that case, and the flag is cleared either way.
private func teardownRoom() async {
isTearingDown = true
await room.disconnect()
room = makeRoom()
isTearingDown = false
}
private func handleAgentTimeout(generation: Int) async {
guard generation == sessionGeneration, state == .waitingAgent else { return }
guard !reconnectAttempted else {
// Final failure: tear down the live room and restore the idle timer.
logger.log("agent timeout final, tearing down")
await teardownForFailure(message: "Agent never joined. Is the agent worker running?")
return
}
reconnectAttempted = true
logger.log("agent did not join in time, retrying with fresh token")
await teardownRoom()
guard generation == sessionGeneration else { return }
await start()
}
/// Centralized failure teardown: mark teardown active before any await so
/// `connect()` is rejected during cleanup, cancel wait work, invalidate the
/// current generation (so stale callbacks become no-ops), clear all
/// per-room/session ephemeral published fields (agent, mute state, model
/// list, error state, interruption ownership), disconnect and recreate the
/// room, stop conversation state, restore the idle timer, and publish
/// `.failed(message)`, making retry safe: the old room is fully torn down
/// before Start becomes available again.
///
/// Deliberately preserved across failure: `settings` (durable user
/// preferences), `transcriptHandler` events (conversation history), and
/// `taskStore` (task history). These are not per-room ephemeral state.
///
/// Idempotent: if already tearing down or failed, keeps the first message.
private func teardownForFailure(message: String) async {
// Mark teardown active *before* any await so connect() rejects during
// cleanup and the UI does not expose Start prematurely.
if state == .tearingDown || state.isFailed { return }
state = .tearingDown
agentWaitTask?.cancel()
agentWaitTask = nil
sessionGeneration += 1
resetEphemeralFields()
// Cancel any in-flight interruption operation and invalidate the
// sequence so a pending .ended cannot resume the mic after teardown.
micOpTask?.cancel()
micOpTask = nil
interruptionSequence += 1
stateModel.stop()
UIApplication.shared.isIdleTimerDisabled = false
await teardownRoom()
// Final reset immediately before publishing .failed: any old-room
// callback delivered during the teardownRoom() await (participantDidConnect,
// connection-state changes) may have repopulated ephemeral fields. This
// second pass erases them so the published .failed state is clean
// (Iteration 09 Item 1).
resetEphemeralFields()
// Publish .failed only after all cleanup has completed. This makes the
// state a truthful signal: retry is safe because the old room is gone.
state = .failed(message)
}
private func startAgentWait() {
state = .waitingAgent
let generation = sessionGeneration
agentWaitTask?.cancel()
agentWaitTask = Task { [weak self] in
try? await Task.sleep(for: .seconds(10))
guard !Task.isCancelled else { return }
await self?.handleAgentTimeout(generation: generation)
}
}
func agentReadyReceived() {
guard !isTearingDown, state == .waitingAgent, sessionGeneration > 0 else {
logger.log("agent_ready ignored in state \(String(describing: self.state), privacy: .public)")
return
}
agentWaitTask?.cancel()
state = .ready
stateModel.start()
let generation = sessionGeneration
// Honor the persisted interaction mode: text mode joins with the mic
// off and the agent in text mode; voice mode enables the mic.
if settings.textMode {
isMuted = true
userWantsMicMuted = true
logger.log("state=ready (text mode), mic stays off")
} else {
logger.log("state=ready, enabling mic")
Task { await enableMicrophone() }
}
Task { await syncSettingsOnJoin(generation: generation) }
}
private func syncSettingsOnJoin(generation: Int) async {
guard generation == sessionGeneration, isReady else { return }
// Each sync operation is independent and best-effort: a failed set_voice
// must not prevent set_mode from being sent. Hermes is server-managed.
let voiceID = settings.voiceID
do {
try await controlChannel.setVoice(voiceID, room: room)
guard generation == sessionGeneration else { return }
logger.log("sync-on-join set_voice sent: \(voiceID, privacy: .public)")
} catch {
logger.error("sync-on-join set_voice failed: \(String(describing: error), privacy: .public)")
}
guard generation == sessionGeneration else { return }
// Sync the agent's conversation mode with the persisted setting.
let mode = settings.textMode ? "text" : "voice"
do {
try await controlChannel.setMode(mode, room: room)
guard generation == sessionGeneration else { return }
logger.log("sync-on-join set_mode sent: \(mode, privacy: .public)")
} catch {
logger.error("sync-on-join set_mode failed: \(String(describing: error), privacy: .public)")
}
}
@discardableResult
func selectVoice(_ id: String) async -> Bool {
let previous = settings.voiceID
guard isReady else { settings.voiceID = id; return true }
let generation = sessionGeneration
do {
try await controlChannel.setVoice(id, room: room)
guard generation == sessionGeneration else { return false }
settings.voiceID = id
logger.log("set_voice sent: \(id, privacy: .public)")
return true
} catch {
settings.voiceID = previous
logger.error("set_voice failed: \(String(describing: error), privacy: .public)")
return false
}
}
private func scanForAgent() {
for participant in room.remoteParticipants.values {
if isAgentParticipant(participant) {
foundAgent(participant)
return
}
}
}
private func isAgentParticipant(_ participant: RemoteParticipant) -> Bool {
participant.kind == .agent || participant.identity?.stringValue == "voice-assistant"
}
private func foundAgent(_ participant: RemoteParticipant) {
agent = AgentInfo(identity: participant.identity?.stringValue ?? "", name: participant.name)
}
func enableMicrophone() async {
let predecessor = micOpTask
let generation = sessionGeneration
let task = Task { @MainActor in
await predecessor?.value
guard !Task.isCancelled, generation == sessionGeneration, isReady, !settings.textMode else { return }
await enableMicrophoneSerialized()
}
micOpTask = task
await task.value
}
private func enableMicrophoneSerialized() async {
// Text mode invariant: the microphone must never be enabled while text
// mode is active. This enforces the privacy contract at the API level,
// not just in the view layer (Finding 4).
guard !settings.textMode else {
logger.log("enableMicrophone blocked: text mode active")
return
}
let generation = sessionGeneration
for attempt in 0..<3 {
guard generation == sessionGeneration, isReady, !settings.textMode, !Task.isCancelled else { return }
do {
try await room.localParticipant.setMicrophone(enabled: true)
guard generation == sessionGeneration else { return }
if settings.textMode || Task.isCancelled {
try? await room.localParticipant.setMicrophone(enabled: false)
isMuted = true
return
}
isMuted = false
userWantsMicMuted = false
micError = nil
logger.log("mic enabled")
return
} catch {
let message = String(describing: error)
if attempt < 2 {
logger.log("mic enable failed (\(message, privacy: .public)), retrying in 1s")
try? await Task.sleep(for: .seconds(1))
guard generation == sessionGeneration else { return }
continue
}
// Non-fatal: keep the session usable (text mode still works).
logger.error("mic enable failed after retries: \(message, privacy: .public)")
guard generation == sessionGeneration else { return }
micError = "Microphone unavailable (\(error.localizedDescription)). Text mode still works."
}
}
}
func setMuted(_ muted: Bool) async {
let predecessor = micOpTask
let generation = sessionGeneration
let task = Task { @MainActor in
await predecessor?.value
guard !Task.isCancelled, generation == sessionGeneration else { return }
await setMutedSerialized(muted)
}
micOpTask = task
await task.value
}
private func setMutedSerialized(_ muted: Bool) async {
guard isReady else { return }
// Text mode invariant: unmuting (enabling the mic) is not permitted
// while text mode is active. Muting is always allowed.
if !muted && settings.textMode {
logger.log("setMuted(false) blocked: text mode active")
return
}
let generation = sessionGeneration
do {
try await room.localParticipant.setMicrophone(enabled: !muted)
guard generation == sessionGeneration else { return }
if !muted && settings.textMode {
try? await room.localParticipant.setMicrophone(enabled: false)
isMuted = true
return
}
isMuted = muted
// Track explicit user mute intent separately from hardware state.
// Interruption begin operations use this after awaiting the
// predecessor to decide whether to mute (Iteration 08 Item 1).
userWantsMicMuted = muted
} catch {
guard generation == sessionGeneration else { return }
micError = "Microphone error: \(error.localizedDescription)"
}
}
func setMode(_ mode: String) async {
guard isReady else { return }
let generation = sessionGeneration
do {
try await controlChannel.setMode(mode, room: room)
guard generation == sessionGeneration else { return }
logger.log("set_mode sent: \(mode, privacy: .public)")
} catch {
logger.error("set_mode failed: \(String(describing: error), privacy: .public)")
}
}
/// Apply a persisted text-mode change to the live session: mute + set_mode
/// when enabling, set_mode + mic on when disabling. Called from
/// ConversationView's `.onChange(of: settings.textMode)` so changes from
/// either the conversation toggle or the Settings sheet apply to the
/// current session.
func applyTextMode(_ enabled: Bool) async {
guard isReady else { return }
guard enabled == settings.textMode else { return }
if enabled {
await setMuted(true)
guard settings.textMode else { return }
await setMode("text")
} else {
await setMode("voice")
guard !settings.textMode else { return }
await enableMicrophone()
}
}
func sendText(_ text: String) async throws {
guard isReady else { throw URLError(.notConnectedToInternet) }
logger.log("sendText lk.chat chars=\(text.count)")
try await room.localParticipant.sendText(text, for: "lk.chat")
}
func disconnect() async {
tokenClient.cancel()
// Set the teardown guard first so any .disconnected callback from our
// own room.disconnect() is ignored (not treated as a terminal failure).
isTearingDown = true
agentWaitTask?.cancel()
agentWaitTask = nil
sessionGeneration += 1
resetEphemeralFields()
// Cancel any in-flight interruption operation and invalidate the
// sequence so a pending .ended cannot resume the mic after disconnect.
micOpTask?.cancel()
micOpTask = nil
interruptionSequence += 1
stateModel.stop()
UIApplication.shared.isIdleTimerDisabled = false
await teardownRoom()
// Final reset before publishing .idle: any old-room callback delivered
// during the teardownRoom() await may have repopulated ephemeral fields.
// This ensures the idle state is clean for server replacement (Iteration 09).
resetEphemeralFields()
// Publish .idle only after room teardown has completed: the old session
// is fully torn down before the UI shows "ready to start".
state = .idle
}
// MARK: Audio session interruptions (phone call, Siri)
/// Handles audio session interruptions with a single retained operation
/// chain (`micOpTask`) for both begin and end operations.
///
/// **Serialization invariants (Iteration 07 Item 2):**
/// - Every new operation captures the prior `micOpTask` and awaits it before
/// executing. This guarantees strict ordering: a newer `.began` orders
/// after an in-flight old end/unmute and finishes by muting again, so a
/// stale unmute cannot leave hardware enabled during a newer interruption.
/// - `.began` increments `interruptionSequence`, then after predecessor
/// completion mutes and records ownership (`micMutedByInterruption`) only
/// after the mute succeeds.
/// - `.ended` is also assigned to the retained chain (never unretained),
/// awaits begin, and un-mutes only for matching sequence/generation/
/// ownership/shouldResume.
/// - Cancel/invalidate on disconnect or failure teardown.
@objc private func handleAudioInterruption(_ notification: Notification) {
guard let info = notification.userInfo else { return }
guard let typeValue = info[AVAudioSessionInterruptionTypeKey] as? UInt,
let type = AVAudioSession.InterruptionType(rawValue: typeValue) else { return }
switch type {
case .began:
// Increment the sequence so any pending `.ended` from a prior
// interruption sees a stale sequence and does not resume.
interruptionSequence += 1
let seq = interruptionSequence
let generation = sessionGeneration
let predecessor = micOpTask
// Assign the new begin operation to the retained chain. It awaits
// the predecessor (which may be an in-flight end/unmute) *before*
// deciding whether to mute. The mute decision uses the user's
// explicit intent (`userWantsMicMuted`) and current text mode, NOT
// a pre-await snapshot of transient `isMuted` which may still
// reflect the predecessor's in-flight state (Iteration 08 Item 1).
//
// This guarantees: begin A → end A/unmute in flight → begin B.
// B awaits A's unmute (which sets isMuted=false, userWantsMicMuted
// already false from the successful enable). B then sees
// userWantsMicMuted == false and textMode == false, so it mutes —
// the final state is muted during interruption B.
micOpTask = Task { [weak self] in
guard let self else { return }
if let pred = predecessor { await pred.value }
guard !Task.isCancelled, self.sessionGeneration == generation else { return }
// Reset ownership: a new interruption supersedes any prior claim.
self.micMutedByInterruption = false
// Decide whether to mute AFTER the predecessor completes, using
// user intent (not a pre-await isMuted snapshot). The mic should
// be muted during the interruption unless the user explicitly
// wanted it muted already (in which case hardware is already off)
// or text mode is active (mic must stay off by invariant).
let shouldMute = !self.userWantsMicMuted && !self.settings.textMode
&& self.state == .ready
guard shouldMute else {
logger.log("audio interruption began, no mute needed (userMuted=\(self.userWantsMicMuted), textMode=\(self.settings.textMode), state=\(String(describing: self.state)), seq=\(seq))")
return
}
do {
try await self.room.localParticipant.setMicrophone(enabled: false)
// Mark ownership only after the mute actually completed and
// the sequence is still current (no even newer .began).
guard !Task.isCancelled,
self.sessionGeneration == generation,
seq == self.interruptionSequence else { return }
self.isMuted = true
self.micMutedByInterruption = true
logger.log("interruption mute succeeded (seq=\(seq))")
} catch {
logger.log("interruption mute failed: \(error.localizedDescription) (seq=\(seq))")
}
}
logger.log("audio interruption began, seq=\(seq)")
case .ended:
let optionsValue = info[AVAudioSessionInterruptionOptionKey] as? UInt ?? 0
let shouldResume = AVAudioSession.InterruptionOptions(rawValue: optionsValue).contains(.shouldResume)
// Capture the current sequence and generation before any async hop.
let currentSeq = interruptionSequence
let generation = sessionGeneration
let predecessor = micOpTask
// Assign the end operation to the retained chain (never unretained).
// It awaits the predecessor (the begin's mute) before deciding.
micOpTask = Task { [weak self] in
guard let self else { return }
if let pred = predecessor { await pred.value }
guard !Task.isCancelled, self.sessionGeneration == generation else { return }
// Re-check all conditions after the await:
// 1. The mute succeeded (flag set only on success).
// 2. No newer .began has superseded this sequence.
// 3. The session generation still matches.
// 4. Text mode is off.
// 5. .shouldResume is present.
let shouldUnmute = self.micMutedByInterruption
&& currentSeq == self.interruptionSequence
&& self.sessionGeneration == generation
&& self.state == .ready
&& !self.settings.textMode
&& shouldResume
// Clear the flag regardless of whether we resume, so a stale
// true cannot cause an incorrect future resume.
self.micMutedByInterruption = false
if shouldUnmute {
do {
try await self.room.localParticipant.setMicrophone(enabled: true)
guard !Task.isCancelled, self.sessionGeneration == generation else { return }
if self.settings.textMode {
try? await self.room.localParticipant.setMicrophone(enabled: false)
self.isMuted = true
return
}
self.isMuted = false
logger.log("audio interruption ended, resuming mic (seq=\(currentSeq))")
} catch {
logger.log("interruption resume failed: \(error.localizedDescription) (seq=\(currentSeq))")
}
} else {
let description = String(describing: self.state)
logger.log("audio interruption ended, no resume (state=\(description, privacy: .public), shouldResume=\(shouldResume), seq=\(currentSeq))")
}
}
@unknown default:
break
}
}
func receiveAssistantProgress(_ data: Data) {
struct Progress: Decodable { let type: String; let request_id: String; let status: String; let message: String }
guard let progress = try? JSONDecoder().decode(Progress.self, from: data),
progress.type == "assistant_progress", !progress.request_id.isEmpty else { return }
switch progress.status {
case "working": workingRequests[progress.request_id] = progress.message.isEmpty ? "Hope is working on your request…" : progress.message
case "completed": workingRequests.removeValue(forKey: progress.request_id)
case "failed": workingRequests.removeValue(forKey: progress.request_id)
default: return
}
assistantActivity = workingRequests.values.sorted().first
if progress.status == "failed" { assistantActivity = "Hope could not complete that request. Please try again." }
}
func tickAudioLevels(local: Float, remote: Float) {
stateModel.tick(localAudioLevel: local, remoteAudioLevel: remote)
}
/// Pure decision seam for interruption-begin mute logic (Iteration 08 Item 1).
/// Extracted from `handleAudioInterruption` so the begin A → end A/unmute →
/// begin B transient-state case can be tested deterministically without a
/// live microphone or AVAudioSession.
///
/// Returns `true` if the begin operation should mute the mic, given the
/// user's explicit intent and current session state *after* the predecessor
/// has completed (not a pre-await snapshot).
func interruptionBeginShouldMute(
userWantsMicMuted: Bool,
textMode: Bool,
state: VoiceSessionState
) -> Bool {
!userWantsMicMuted && !textMode && state == .ready
}
}
extension VoiceSession: RoomDelegate {
nonisolated func room(_ room: Room, participantDidConnect participant: RemoteParticipant) {
Task { @MainActor [weak self] in
guard let self, self.room === room else { return }
// Gate on active lifecycle states and teardown flag so old-room
// callbacks delivered during failure teardown cannot repopulate
// the agent field after it was cleared (Iteration 09 Item 1).
guard self.shouldAcceptRoomCallback(state: self.state, isTearingDown: self.isTearingDown) else { return }
guard self.isAgentParticipant(participant) else { return }
self.foundAgent(participant)
}
}
nonisolated func room(_ room: Room, didUpdateSpeakingParticipants participants: [Participant]) {
Task { @MainActor [weak self] in
guard let self, self.room === room, self.state == .ready else { return }
var local = Float(0)
var remote = Float(0)
for participant in participants {
if participant.identity?.stringValue == room.localParticipant.identity?.stringValue {
local = max(local, participant.audioLevel)
} else {
remote = max(remote, participant.audioLevel)
}
}
self.tickAudioLevels(local: local, remote: remote)
}
}
nonisolated func room(_ room: Room, participant: RemoteParticipant?, didReceiveData data: Data, forTopic topic: String, encryptionType: EncryptionType) {
Task { @MainActor [weak self] in
guard let self, self.room === room, !self.isTearingDown, self.state == .ready || self.state == .waitingAgent else { return }
if topic == "assistant-progress" {
self.receiveAssistantProgress(data)
return
}
// Secondary transcript source (finals only).
if topic == "transcript",
let json = try? JSONDecoder().decode([String: JSONValue].self, from: data),
json["type"]?.stringValue == "transcript",
let text = json["text"]?.stringValue {
let role: TranscriptEvent.Role = json["role"]?.stringValue == "agent" ? .agent : .user
self.transcriptHandler.receiveDataTranscript(role: role, text: text)
return
}
if self.controlChannel.handleTaskEvent(data) { return }
_ = self.controlChannel.handleVoiceControl(data, session: self)
}
}
nonisolated func room(_ room: Room, didUpdateConnectionState connectionState: ConnectionState, from oldConnectionState: ConnectionState) {
Task { @MainActor [weak self] in
guard let self, self.room === room else { return }
// Ignore all connection-state updates while intentional teardown is
// in progress. The caller (teardownForFailure or disconnect) has
// already set the correct terminal/transitional state; any callback
// from the old room during the disconnect await must not mutate
// published fields (Iteration 09 Item 1).
if self.isTearingDown {
logger.log("connection-state callback ignored during teardown: \(String(describing: connectionState))")
return
}
switch (oldConnectionState, connectionState) {
case (.connected, .reconnecting):
// Only mutate published fields for active lifecycle states.
guard self.state == .connecting || self.state == .waitingAgent || self.state == .ready else { return }
self.isReconnecting = true
logger.log("connection lost, waiting for LiveKit reconnect")
case (.reconnecting, .connected):
guard self.state == .connecting || self.state == .waitingAgent || self.state == .ready else { return }
self.isReconnecting = false
logger.log("connection restored")
case (_, .disconnected):
// Terminal disconnect (not a transient reconnect). If the
// session was active, tear down cleanly so no stale room or
// wake lock survives. The user can retry via Start.
if self.state == .ready || self.state == .waitingAgent {
logger.log("terminal disconnect, tearing down session")
await self.teardownForFailure(message: "Connection lost.")
} else {
self.isReconnecting = false
}
default:
break
}
}
}
}
+100
View File
@@ -0,0 +1,100 @@
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 ConnectionFailure {
static func message(_ error: Error, server: URL) -> String {
let ns = error as NSError
let certificateCodes: Set<Int> = [-1200, -1201, -1202, -1203, -1204, -1205, -1206]
if ns.domain == NSURLErrorDomain && certificateCodes.contains(ns.code) {
return "The certificate for \(server.host ?? "this server") is not trusted. For your private Hope server, install the verified certificate profile in Safari, then enable it in Settings → General → About → Certificate Trust Settings. Do not continue if the server address is unfamiliar."
}
return error.localizedDescription
}
}
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 voices(token: String) async throws -> VoiceCatalogResponse {
#if DEBUG
if ProcessInfo.processInfo.arguments.contains("--ui-testing"),
!ProcessInfo.processInfo.arguments.contains("--live-testing") {
return VoiceCatalogResponse(defaultVoice: Voices.defaultID, voices: Voices.all,
catalogAvailable: false, message: "Offline test voice catalog.")
}
#endif
guard !token.isEmpty else { throw AuthError.status(401) }
return try JSONDecoder().decode(VoiceCatalogResponse.self, from: await request("voices", method: "GET", token: token))
}
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 }
}
+57
View File
@@ -0,0 +1,57 @@
import Foundation
struct ServerConfig: Equatable {
let baseURL: URL
let livekitURL: URL
enum ConfigError: LocalizedError, Equatable {
case notHTTPS(String)
case invalidURL(String)
var errorDescription: String? {
switch self {
case .notHTTPS(let raw):
return "Server URL must use https: \(raw)"
case .invalidURL(let raw):
return "Invalid server URL: \(raw)"
}
}
}
static func parse(_ raw: String) -> Result<ServerConfig, ConfigError> {
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return .failure(.invalidURL(raw)) }
var candidate = trimmed
if !candidate.contains("://") { candidate = "https://" + candidate }
guard let components = URLComponents(string: candidate),
let scheme = components.scheme?.lowercased(),
let host = components.host, !host.isEmpty else {
return .failure(.invalidURL(raw))
}
if scheme != "https" { return .failure(.notHTTPS(raw)) }
guard components.user == nil, components.password == nil,
components.query == nil, components.fragment == nil else {
return .failure(.invalidURL(raw))
}
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))
}
static func isPrivateIP(_ host: String) -> Bool {
let parts = host.split(separator: ".").compactMap { UInt8($0) }
guard parts.count == 4 else { return false }
return parts[0] == 10
|| (parts[0] == 172 && (16...31).contains(parts[1]))
|| (parts[0] == 192 && parts[1] == 168)
}
var host: String { baseURL.host ?? "" }
}
+72
View File
@@ -0,0 +1,72 @@
import Foundation
import CommonCrypto
import Security
/// Pins the self-signed cert of the LAN deployment (CN=192.168.86.2, regenerated
/// at container start — re-export the hash after server cert rotation, see README).
/// Extract with:
/// openssl s_client -connect 192.168.86.2:9178 </dev/null 2>/dev/null \
/// | openssl x509 -outform der | openssl dgst -sha256
enum TLSPinning {
static let pinnedCertSHA256Hex: Set<String> = [
"07907abde3bb9c685265909927627d9ea4705d7e7b27056eefe06e10b192af46"
]
static let host = "192.168.86.2"
static func session() -> URLSession {
let config = URLSessionConfiguration.ephemeral
config.urlCache = nil
config.httpCookieStorage = nil
config.urlCredentialStorage = nil
config.timeoutIntervalForRequest = 15
return URLSession(configuration: config, delegate: PinnedTrustDelegate(), delegateQueue: nil)
}
static func sha256Hex(_ data: Data) -> String {
var hash = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH))
data.withUnsafeBytes { buffer in
guard let base = buffer.baseAddress else { return }
CC_SHA256(base, CC_LONG(data.count), &hash)
}
return hash.map { String(format: "%02x", $0) }.joined()
}
static func matchesPinnedCert(_ trust: SecTrust) -> Bool {
guard let chain = SecTrustCopyCertificateChain(trust) as? [SecCertificate],
let leaf = chain.first,
let der = SecCertificateCopyData(leaf) as Data? else { return false }
return pinnedCertSHA256Hex.contains(sha256Hex(der))
}
}
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) {
guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust,
let trust = challenge.protectionSpace.serverTrust else {
completionHandler(.performDefaultHandling, nil)
return
}
// Apply the fixed self-signed pin only to the pinned LAN deployment
// host. Other explicitly configured HTTPS hosts use normal system
// trust evaluation (Finding 12). This allows public deployments and
// other LAN hosts with valid certificates while never weakening trust
// for the default self-signed host.
let host = challenge.protectionSpace.host
if host == TLSPinning.host {
if TLSPinning.matchesPinnedCert(trust) {
completionHandler(.useCredential, URLCredential(trust: trust))
} else {
completionHandler(.cancelAuthenticationChallenge, nil)
}
} else {
// Non-pinned host: let the system evaluate the certificate chain.
completionHandler(.performDefaultHandling, nil)
}
}
}
+93
View File
@@ -0,0 +1,93 @@
import Foundation
struct TokenResponse: Decodable, Equatable {
let token: String
let room: String
let user: User
let conversationID: String?
enum CodingKeys: String, CodingKey {
case token, room, user
case conversationID = "conversation_id"
}
struct User: Decodable, Equatable {
let id: String
let displayName: String?
enum CodingKeys: String, CodingKey {
case id
case displayName = "display_name"
}
}
}
enum TokenError: LocalizedError {
case invalidServerURL(String)
case httpStatus(Int, String)
case decodeFailed
case network(Error)
var errorDescription: String? {
switch self {
case .invalidServerURL(let raw):
return "Invalid server URL: \(raw)"
case .httpStatus(let code, let detail):
if code == 503 {
return "Server is starting up or the user file is missing (HTTP 503). Check the deployment on the server. \(detail)"
}
return "Token request failed with HTTP \(code). \(detail)"
case .decodeFailed:
return "Unexpected response from the token endpoint."
case .network(let error):
return "Could not reach the server: \(error.localizedDescription)"
}
}
}
final class TokenClient {
let config: ServerConfig
private let session: URLSession
var onUnauthorized: (() async -> Void)?
let bearerToken: String
var conversationID: String?
init(config: ServerConfig, bearerToken: String = "", session: URLSession? = nil) {
self.config = config
self.session = session ?? TLSPinning.session()
self.bearerToken = bearerToken
}
func cancel() { session.getAllTasks { $0.forEach { $0.cancel() } } }
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"))
request.httpMethod = "POST"
if let conversationID {
request.httpBody = try JSONSerialization.data(withJSONObject: ["conversation_id": conversationID])
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
}
request.setValue("Bearer \(bearerToken)", forHTTPHeaderField: "Authorization")
request.timeoutInterval = 10
let (data, response) = try await session.data(for: request)
guard let http = response as? HTTPURLResponse else {
throw TokenError.decodeFailed
}
guard (200..<300).contains(http.statusCode) else {
if http.statusCode == 401 || http.statusCode == 403 { await onUnauthorized?() }
let detail = ""
throw TokenError.httpStatus(http.statusCode, detail)
}
do {
return try JSONDecoder().decode(TokenResponse.self, from: data)
} catch {
throw TokenError.decodeFailed
}
}
}