Compare commits

...
9 Commits
41 changed files with 3596 additions and 437 deletions

No files matched your search

+1
View File
@@ -4,3 +4,4 @@ xcuserdata/
*.xcresult
build/
.swiftpm/
.runtime/
Binary file not shown.

Before

Width:  |  Height:  |  Size: 545 KiB

After

Width:  |  Height:  |  Size: 183 KiB

+1 -16
View File
@@ -5,7 +5,7 @@
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Hope Voice</string>
<string>Hope</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
@@ -24,21 +24,6 @@
<dict>
<key>NSAllowsLocalNetworking</key>
<true/>
<key>NSPinnedDomains</key>
<dict>
<key>192.168.86.2</key>
<dict>
<key>NSIncludesSubdomains</key>
<true/>
<key>NSPinnedCAIdentities</key>
<array>
<dict>
<key>SPKI-SHA256-BASE64</key>
<string>vmrfRuHv1oHBBiEmJP4WX3C7mtVy3C4+UJ1Yk/Q5hMU=</string>
</dict>
</array>
</dict>
</dict>
</dict>
<key>NSMicrophoneUsageDescription</key>
<string>Hope Voice uses the microphone for live voice conversation.</string>
+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
}
}
+21 -19
View File
@@ -8,21 +8,24 @@ import UIKit
enum Nearlight {
// MARK: Palette (light / dark variants)
/// Screen background.
static let ground = Color(light: UIColor(red: 0.957, green: 0.949, blue: 0.965, alpha: 1),
dark: UIColor(red: 0.110, green: 0.098, blue: 0.125, alpha: 1))
/// Controls, grouped rows, dock.
static let surface = Color(light: .white,
dark: UIColor(red: 0.157, green: 0.137, blue: 0.180, alpha: 1))
/// Primary text.
static let ink = Color(light: UIColor(red: 0.161, green: 0.137, blue: 0.184, alpha: 1),
dark: UIColor(red: 0.965, green: 0.937, blue: 0.969, alpha: 1))
/// Supporting text, inactive icons.
static let quiet = Color(light: UIColor(red: 0.396, green: 0.365, blue: 0.424, alpha: 1),
dark: UIColor(red: 0.749, green: 0.698, blue: 0.776, alpha: 1))
/// Action tint, selection, Lightwell.
static let rosewood = Color(light: UIColor(red: 0.529, green: 0.278, blue: 0.380, alpha: 1),
dark: UIColor(red: 0.890, green: 0.635, blue: 0.737, alpha: 1))
/// 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))
@@ -35,10 +38,9 @@ enum Nearlight {
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: Surface in light, Ground in dark.
static var onRosewood: Color {
UITraitCollection.current.userInterfaceStyle == .dark ? ground : surface
}
/// 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
+2 -7
View File
@@ -8,9 +8,6 @@ final class SettingsStore: ObservableObject {
@Published var voiceID: String {
didSet { AppPreferences.store.set(voiceID, forKey: Keys.voiceID) }
}
@Published var modelID: String? {
didSet { AppPreferences.store.set(modelID, forKey: Keys.modelID) }
}
@Published var textMode: Bool {
didSet { AppPreferences.store.set(textMode, forKey: Keys.textMode) }
}
@@ -18,7 +15,6 @@ final class SettingsStore: ObservableObject {
private enum Keys {
static let serverURL = "serverURL"
static let voiceID = "voiceID"
static let modelID = "modelID"
static let textMode = "textMode"
}
@@ -40,9 +36,9 @@ final class SettingsStore: ObservableObject {
d.set(Self.defaultServerURL, forKey: Keys.serverURL)
}
let storedVoice = d.string(forKey: Keys.voiceID)
voiceID = Voices.all.contains { $0.id == storedVoice } ? (storedVoice ?? "") : Voices.defaultID
modelID = d.string(forKey: Keys.modelID)
voiceID = Voices.restoredID(storedVoice)
textMode = d.bool(forKey: Keys.textMode)
d.set(voiceID, forKey: Keys.voiceID)
}
var trimmedServerURL: String {
@@ -64,7 +60,6 @@ enum AppPreferences {
if ProcessInfo.processInfo.arguments.contains("--live-testing") {
defaults.set(SettingsStore.defaultServerURL, forKey: "serverURL")
defaults.set(true, forKey: "textMode")
defaults.set("qwen2.5-vl-3b", forKey: "modelID")
}
return defaults
}
+2 -2
View File
@@ -6,12 +6,12 @@ struct VoiceApp: App {
@StateObject private var settings = SettingsStore()
init() {
LiveKitSDK.setLogLevel(.debug)
LiveKitSDK.setLogLevel(.error)
}
var body: some Scene {
WindowGroup {
ConversationView(settings: settings)
AuthenticationRoot(settings: settings)
.tint(Nearlight.rosewood)
}
}
+42 -12
View File
@@ -1,33 +1,63 @@
import Foundation
struct VoiceOption: Equatable {
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-JennyNeural", name: "Jenny", tag: nil),
VoiceOption(id: "en-US-GuyNeural", name: "Guy", tag: nil),
VoiceOption(id: "en-US-AndrewNeural", name: "Andrew", tag: nil),
VoiceOption(id: "en-US-AriaNeural", name: "Aria", tag: nil),
VoiceOption(id: "en-US-EmmaNeural", name: "Emma", tag: nil),
VoiceOption(id: "en-US-EricNeural", name: "Eric", tag: nil),
VoiceOption(id: "en-US-BrianNeural", name: "Brian", tag: nil),
VoiceOption(id: "en-US-AshleyNeural", name: "Ashley", tag: nil),
VoiceOption(id: "en-US-RichardNeural", name: "Richard", tag: nil),
VoiceOption(id: "en-US-TinaNeural", name: "Tina", tag: nil),
VoiceOption(id: "en-US-SteffanNeural", name: "Steffan", tag: nil),
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
})
}
}
}
@@ -4,26 +4,38 @@ import SwiftUI
/// Owns one session across all tabs. Changing tabs never ends a conversation.
struct ConversationView: View {
@ObservedObject var settings: SettingsStore
@ObservedObject var auth: AuthStore
let user: AuthUser
let bearerToken: String
@State private var session: VoiceSession
@State private var activeServerURL: String
@State private var pendingServerURL: String?
@State private var isReplacingSession = false
@State private var selectedTab = 0
@State private var selectedTab = 2
@StateObject private var chat: ChatStore
init(settings: SettingsStore) {
init(settings: SettingsStore, auth: AuthStore, user: AuthUser, bearerToken: String) {
self.auth = auth
self.user = user
self.bearerToken = bearerToken
self.settings = settings
let url = settings.trimmedServerURL
_activeServerURL = State(initialValue: url)
_session = State(initialValue: Self.makeSession(url, settings: settings))
_session = State(initialValue: Self.makeSession(url, settings: settings, bearerToken: bearerToken, userID: user.id))
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) -> VoiceSession {
private static func makeSession(_ url: String, settings: SettingsStore, bearerToken: String, userID: String) -> VoiceSession {
guard case .success(let config) = ServerConfig.parse(url) else {
preconditionFailure("Stored server URL failed validation; init sanitizes to a valid value.")
}
let session = VoiceSession(config: config, tokenClient: TokenClient(config: config), settings: settings)
let session = VoiceSession(config: config, tokenClient: TokenClient(config: config, bearerToken: bearerToken), settings: settings, userID: userID)
#if DEBUG
if ProcessInfo.processInfo.arguments.contains("--ui-fixtures") {
if userID == "offline-fixture", ProcessInfo.processInfo.arguments.contains("--ui-fixtures") {
session.transcriptHandler.receiveDataTranscript(role: .user, text: "Help me plan a quiet weekend.")
session.transcriptHandler.receiveDataTranscript(role: .agent, text: "Let’s leave some room to breathe. A walk, a good book, and time for your favorite music would be a lovely start.")
session.taskStore.injectSampleEvents()
@@ -43,9 +55,30 @@ struct ConversationView: View {
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)
.toolbar { settingsLink }
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)
@@ -53,11 +86,24 @@ struct ConversationView: View {
ActivityTab(taskStore: session.taskStore, settings: settings, session: session)
.tag(1)
}
.tint(Nearlight.rosewood)
.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 replaceServer(value) }
.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 {
@@ -84,7 +130,7 @@ struct ConversationView: View {
guard next != activeServerURL else { continue }
await session.disconnect()
activeServerURL = next
session = Self.makeSession(next, settings: settings)
session = Self.makeSession(next, settings: settings, bearerToken: bearerToken, userID: user.id)
}
isReplacingSession = false
}
@@ -145,6 +191,7 @@ private struct ConversationContent: View {
@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
@@ -153,9 +200,10 @@ private struct ConversationContent: View {
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@Environment(\.colorSchemeContrast) private var contrast
init(settings: SettingsStore, session: VoiceSession) {
init(settings: SettingsStore, session: VoiceSession, displayName: String) {
self.settings = settings
self.session = session
self.displayName = displayName
transcripts = session.transcriptHandler
conversation = session.stateModel
}
@@ -167,6 +215,10 @@ private struct ConversationContent: View {
if failureNotice != nil { failureBanner }
} else {
statusStrip
if let activity = session.assistantActivity {
Label(activity, systemImage: "clock").font(.callout)
.padding().accessibilityIdentifier("assistant.progress")
}
if session.isReconnecting { reconnectBanner }
if failureNotice != nil { failureBanner }
TranscriptListView(events: transcripts.events, onClear: transcripts.clearAll)
@@ -193,25 +245,66 @@ private struct ConversationContent: View {
private var welcome: some View {
ScrollView {
VStack(alignment: .leading, spacing: Nearlight.s24) {
LightwellView(phase: lightwellPhase(session: session, conversation: conversation, textMode: settings.textMode))
.padding(.top, Nearlight.s32)
VStack(alignment: .leading, spacing: Nearlight.s12) {
Text("A moment with Hope.")
.font(NearlightFont.welcome(typeSize))
.fixedSize(horizontal: false, vertical: true)
Text("Speak or type to your own assistant.")
.font(NearlightFont.support(typeSize))
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)
}
statusLine
.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, alignment: .leading)
.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)
@@ -224,9 +317,9 @@ private struct ConversationContent: View {
// MARK: Status strip (active conversation)
private var statusStrip: some View {
HStack(spacing: Nearlight.s12) {
VStack(spacing: Nearlight.s12) {
LightwellView(phase: lightwellPhase(session: session, conversation: conversation, textMode: settings.textMode), compact: true)
VStack(alignment: .leading, spacing: 2) {
VStack(alignment: .center, spacing: 2) {
Text(statusTitle)
.font(NearlightFont.control(typeSize))
.foregroundStyle(Nearlight.ink)
@@ -237,11 +330,12 @@ private struct ConversationContent: View {
.foregroundStyle(Nearlight.quiet)
}
}
Spacer(minLength: 0)
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
}
.frame(maxWidth: .infinity)
.padding(.horizontal, Nearlight.s20)
.padding(.vertical, Nearlight.s12)
.frame(minHeight: 60, alignment: .leading)
.background(Nearlight.ground)
}
@@ -343,21 +437,17 @@ private struct ConversationContent: View {
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(
// Dock: Surface, 16-point top corners, decorative top separator.
VStack(spacing: 0) {
Rectangle().fill(Nearlight.separator).frame(height: 1 / UIScreen.main.scale)
Nearlight.surface
}
.ignoresSafeArea(.container, edges: .bottom)
.clipShape(UnevenRoundedRectangle(topLeadingRadius: Nearlight.radiusDock, topTrailingRadius: Nearlight.radiusDock))
)
.background(Nearlight.surface, in: RoundedRectangle(cornerRadius: Nearlight.radiusDock))
.padding(.horizontal, Nearlight.s16)
.padding(.bottom, Nearlight.s8)
}
@ViewBuilder private var modeSelector: some View {
@@ -405,6 +495,16 @@ private struct ConversationContent: View {
.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.")
@@ -494,7 +594,7 @@ private struct ConversationContent: View {
private var typeControls: some View {
VStack(spacing: Nearlight.s12) {
HStack(alignment: .bottom, spacing: Nearlight.s8) {
TextField("Message Hope", text: $composerText, axis: .vertical)
TextField("What’s on your mind?", text: $composerText, axis: .vertical)
.lineLimit(1...5)
.padding(Nearlight.s12)
.background(Nearlight.surface, in: RoundedRectangle(cornerRadius: Nearlight.radiusControl))
@@ -610,4 +710,4 @@ extension VoiceSession {
}
}
#Preview { ConversationView(settings: SettingsStore()) }
#Preview { AuthenticationRoot(settings: SettingsStore()) }
+97 -123
View File
@@ -1,151 +1,125 @@
import SwiftUI
import UIKit
/// The Nearlight signature element: an upright, softly lit aperture.
///
/// Two capsules in a ZStack — no assets, shaders, blur, or particles. The
/// inner capsule carries a vertical Rosewood gradient; its geometry encodes
/// conversation state and audio level. Noninteractive and accessibility
/// hidden: the companion status text carries meaning.
/// 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 {
/// Lightwell presentation state, mapped from session/conversation models.
enum Phase: Equatable {
case idle
case connecting
case waiting
case idle, connecting, waiting, thinking, tearingDown, failed, readyNoInput
case listening(level: Double)
case thinking
case speaking(level: Double)
case tearingDown
case failed
/// Input never drives the light (muted or Type mode).
case readyNoInput
}
let phase: Phase
/// Compact (24×36) in the conversation status strip; full (64×96) on welcome.
var compact: Bool = false
var compact = false
@Environment(\.accessibilityReduceMotion) private var reduceMotion
/// Frame dimensions never animate; inner dimensions multiply by 0.375
/// in the compact frame.
private var frameSize: CGSize {
compact ? CGSize(width: 24, height: 36) : CGSize(width: 64, height: 96)
}
private var baseInner: CGSize {
let base = CGSize(width: 16, height: 40)
return compact
? CGSize(width: base.width * 0.375, height: base.height * 0.375)
: base
}
@Environment(\.colorSchemeContrast) private var contrast
@Environment(\.scenePhase) private var scenePhase
@Environment(\.dynamicTypeSize) private var typeSize
var body: some View {
Group {
if pulseWanted {
// Isolated TimelineView: ticks only while pending states are
// showing; pauses when offscreen/inactive automatically.
TimelineView(.periodic(from: .now, by: 0.05)) { context in
lightwell(pulse: pulseValue(at: context.date))
}
} else {
lightwell(pulse: 0)
}
}
private var side: CGFloat {
if compact { return typeSize.isAccessibilitySize ? 80 : 112 }
return typeSize.isAccessibilitySize ? 136 : 184
}
private func lightwell(pulse: Double) -> some View {
let inner = innerSize
return ZStack {
Capsule()
.fill(Nearlight.surface)
Capsule()
.fill(innerFill)
.frame(width: inner.width, height: inner.height)
.opacity(overallOpacity(pulse: pulse))
}
.frame(width: frameSize.width, height: frameSize.height)
.overlay(
Capsule().strokeBorder(
contrast == .increased ? Nearlight.rosewood : Nearlight.quiet,
lineWidth: 1
)
)
.accessibilityHidden(true)
.animation(reduceMotion ? nil : Nearlight.settle, value: innerSize)
}
/// Increase Contrast promotes the gradient to solid Rosewood.
private var innerFill: AnyShapeStyle {
if contrast == .increased {
return AnyShapeStyle(Nearlight.rosewood)
}
return AnyShapeStyle(
LinearGradient(
stops: [
.init(color: Nearlight.rosewood.opacity(0.35), location: 0),
.init(color: Nearlight.rosewood.opacity(1), location: 0.5),
.init(color: Nearlight.rosewood.opacity(0.55), location: 1),
],
startPoint: .top, endPoint: .bottom
)
)
}
/// Whether a pulse TimelineView should run for this phase.
private var pulseWanted: Bool {
private var animated: Bool {
guard !reduceMotion, scenePhase == .active else { return false }
switch phase {
case .connecting, .waiting, .thinking: return !reduceMotion
case .connecting, .waiting, .thinking, .listening, .speaking: return true
default: return false
}
}
/// Triangle wave 0→1→0 over the 2.4s cycle from the timeline date.
private func pulseValue(at date: Date) -> Double {
let t = date.timeIntervalSinceReferenceDate.truncatingRemainder(dividingBy: 2.4) / 2.4
return t < 0.5 ? t * 2 : (1 - t) * 2
}
/// Inner capsule geometry per spec table. `a` is the smoothed level.
/// Reduce Motion: static 16×40 (idle/listening) and 16×48 (pending/
/// thinking/speaking) with no level-driven resizing.
private var innerSize: CGSize {
let base = baseInner
if reduceMotion {
switch phase {
case .waiting, .thinking, .speaking:
return CGSize(width: base.width, height: base.height + (compact ? 3 : 8))
default:
return base
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 .listening(let a):
let level = min(max(a, 0), 1)
return CGSize(width: base.width + (compact ? 3 : 8) * level,
height: base.height + (compact ? 6 : 16) * level)
case .speaking(let a):
let level = min(max(a, 0), 1)
return CGSize(width: base.width + (compact ? 4.5 : 12) * level,
height: base.height + (compact ? 9 : 24) * level)
case .waiting, .thinking:
// Pulse drives opacity; height steps up one band.
return CGSize(width: base.width, height: base.height + (compact ? 3 : 8))
case .connecting, .idle, .failed, .tearingDown, .readyNoInput:
return base
case .failed: return Nearlight.alert
case .tearingDown: return Nearlight.quiet
default: return Nearlight.rosewood
}
}
private func overallOpacity(pulse: Double) -> Double {
switch phase {
case .connecting, .waiting:
return 0.45 + 0.45 * pulse
case .thinking:
return 0.65 + 0.35 * pulse
case .idle, .failed, .tearingDown:
return 0.45
case .listening, .speaking, .readyNoInput:
return 1
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)
}
}
}
@@ -2,6 +2,7 @@ import SwiftUI
struct ConnectionTestView: View {
@ObservedObject var settings: SettingsStore
var bearerToken: String = ""
@State private var isBusy = false
@State private var result: Result<TokenResponse, Error>?
@State private var urlError: String?
@@ -36,7 +37,7 @@ struct ConnectionTestView: View {
if let result {
switch result {
case .success(let response):
Section("Success") {
Section("Server access verified") {
LabeledContent("Room") {
Text(response.room)
.font(.footnote.monospaced())
@@ -44,8 +45,9 @@ struct ConnectionTestView: View {
LabeledContent("User") {
Text(response.user.displayName ?? response.user.id)
}
Label("Connected to your server. Start a conversation to check Hope’s voice and replies.", systemImage: "checkmark.circle")
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") {
@@ -67,7 +69,7 @@ struct ConnectionTestView: View {
case .success(let config):
isBusy = true
Task {
let client = TokenClient(config: config)
let client = TokenClient(config: config, bearerToken: bearerToken)
do {
let response = try await client.fetchToken()
result = .success(response)
+98 -84
View File
@@ -13,7 +13,7 @@ struct SettingsView: View {
@State private var urlError: String?
@State private var showVoicePicker = false
@State private var savedURLConfirmation: String?
@State private var showModelPicker = false
@State private var pendingServerChange: String?
@FocusState private var urlFocused: Bool
init(settings: SettingsStore, session: VoiceSession) {
self.settings = settings
@@ -46,19 +46,6 @@ struct SettingsView: View {
} footer: {
Text("Voice and typing preferences are saved on this iPhone. You can switch modes from the Talk tab.")
}
Section {
if session.isReady, !session.models.isEmpty {
Button {
showModelPicker = true
} label: {
LabeledContent("Model", value: session.selectedModel ?? settings.modelID ?? "Server default")
}
} else {
LabeledContent("Model", value: settings.modelID ?? "Server default")
Text("Connect from Talk to see the models available on your server.")
.font(.footnote).foregroundStyle(Nearlight.quiet)
}
} header: { Text("Intelligence") }
Section {
Text("Server URL")
.font(.footnote.weight(.semibold))
@@ -96,7 +83,7 @@ struct SettingsView: View {
.foregroundStyle(Nearlight.quiet)
.accessibilityIdentifier("settings.server.saved")
}
Button("Save server address") { applyServerURL() }
Button("Change server") { requestServerURLChange() }
.disabled(!canApplyServerURL || urlText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || urlText == settings.serverURL)
if !canApplyServerURL {
Text(session.state == .tearingDown
@@ -106,7 +93,7 @@ struct SettingsView: View {
.foregroundStyle(Nearlight.quiet)
}
NavigationLink("Connection test") {
ConnectionTestView(settings: settings)
ConnectionTestView(settings: settings, bearerToken: session.bootstrapBearerToken)
}
} header: {
Text("Connection")
@@ -116,60 +103,128 @@ struct SettingsView: View {
}
.navigationTitle("Settings")
.navigationBarTitleDisplayMode(.inline)
.tint(Nearlight.quiet)
.scrollContentBackground(.hidden)
.background(Nearlight.ground)
.tint(Nearlight.rosewood)
.sheet(isPresented: $showVoicePicker) {
VoicePickerView(settings: settings, session: session)
}
.sheet(isPresented: $showModelPicker) {
ModelPickerView(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 applyServerURL() {
private func requestServerURLChange() {
savedURLConfirmation = nil
guard canApplyServerURL else {
urlError = "End the conversation before changing servers."
return
}
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
}
guard !trimmed.isEmpty else { urlError = "Enter a server URL."; return }
switch ServerConfig.parse(trimmed) {
case .success:
settings.serverURL = trimmed
urlText = trimmed
case .success(let config):
pendingServerChange = config.baseURL.absoluteString
urlError = nil
savedURLConfirmation = trimmed
urlFocused = false
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 {
ForEach(Voices.all, id: \.id) { voice in
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 {
settings.voiceID = voice.id
Task { await session.selectVoice(voice.id) }
dismiss()
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 {
Text(voice.displayName)
VStack(alignment: .leading, spacing: 3) {
Text(voice.name)
Text(voice.details).font(.caption).foregroundStyle(.secondary)
}
if settings.voiceID == voice.id {
Spacer()
Image(systemName: "checkmark")
@@ -177,55 +232,14 @@ struct VoicePickerView: View {
}
}
}
.disabled(applyingVoiceID != nil)
.accessibilityAddTraits(settings.voiceID == voice.id ? .isSelected : [])
}
}
.navigationTitle("Voice")
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
}
}
}
}
}
struct ModelPickerView: View {
@ObservedObject var settings: SettingsStore
@ObservedObject var session: VoiceSession
@Environment(\.dismiss) private var dismiss
private var activeModelID: String? {
if !session.models.isEmpty,
let selected = session.selectedModel ?? settings.modelID,
session.models.contains(where: { $0.id == selected }) {
return selected
}
return session.models.first?.id
}
var body: some View {
NavigationStack {
List {
ForEach(session.models, id: \.id) { model in
Button {
settings.modelID = model.id
Task { await session.selectModel(model.id) }
dismiss()
} label: {
HStack {
Text(model.name ?? model.id)
if activeModelID == model.id {
Spacer()
Image(systemName: "checkmark")
.foregroundStyle(Nearlight.rosewood)
}
}
}
.accessibilityAddTraits(activeModelID == model.id ? .isSelected : [])
}
}
.navigationTitle("Model")
.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() }
-31
View File
@@ -6,15 +6,8 @@ import LiveKit
@MainActor
final class ControlChannel: NSObject, RoomDelegate {
struct ModelInfo: Equatable {
let id: String
let name: String?
}
enum InboundEvent {
case agentReady
case models([ModelInfo], selected: String?)
case modelChanged(String)
case taskEvent(TaskEventPayload)
}
@@ -43,22 +36,6 @@ final class ControlChannel: NSObject, RoomDelegate {
session?.agentReadyReceived()
}
return true
case "models":
var models: [ModelInfo] = []
for item in json["models"]?.arrayValue ?? [] {
if let obj = item.objectValue, let id = obj["id"]?.stringValue {
models.append(ModelInfo(id: id, name: obj["name"]?.stringValue))
} else if let id = item.stringValue {
models.append(ModelInfo(id: id, name: nil))
}
}
onEvent?(.models(models, selected: json["selected"]?.stringValue))
return true
case "model_changed":
if let model = json["model"]?.stringValue {
onEvent?(.modelChanged(model))
}
return true
default:
return false
}
@@ -97,14 +74,6 @@ final class ControlChannel: NSObject, RoomDelegate {
try await publish(["type": "set_voice", "voice": voiceID], topic: "voice-control", room: room)
}
func setModel(_ modelID: String, room: Room) async throws {
try await publish(["type": "set_model", "model": modelID], topic: "voice-control", room: room)
}
func getModels(room: Room) async throws {
try await publish(["type": "get_models"], topic: "voice-control", room: room)
}
func setMode(_ mode: String, room: Room) async throws {
try await publish(["type": "set_mode", "mode": mode], topic: "conversation-control", room: room)
}
+3 -1
View File
@@ -30,6 +30,7 @@ final class TranscriptHandler: ObservableObject {
/// 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
@@ -204,8 +205,9 @@ extension TranscriptHandler: RoomDelegate {
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)
self.receiveSegment(id: event.id, role: event.role, text: event.text, isFinal: event.isFinal)
}
}
}
+7 -4
View File
@@ -16,11 +16,14 @@ struct HistoryEntry: Codable, Equatable {
final class TranscriptHistory: ObservableObject {
@Published private(set) var entries: [HistoryEntry] = []
static let key = "voice_history"
static let key = "voice_history_isolated_tests"
private let storageKey: String
static let maxEntries = 50
init() {
if let raw = AppPreferences.store.string(forKey: Self.key),
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))
@@ -45,6 +48,6 @@ final class TranscriptHistory: ObservableObject {
private func persist() {
guard let data = try? JSONEncoder().encode(entries) else { return }
AppPreferences.store.set(String(decoding: data, as: UTF8.self), forKey: Self.key)
AppPreferences.store.set(String(decoding: data, as: UTF8.self), forKey: storageKey)
}
}
+100 -53
View File
@@ -36,8 +36,8 @@ final class VoiceSession: ObservableObject {
@Published private(set) var isMuted = false
@Published private(set) var micError: String?
@Published private(set) var isReconnecting = false
@Published private(set) var models: [ControlChannel.ModelInfo] = []
@Published private(set) var selectedModel: String?
@Published private(set) var assistantActivity: String?
private var workingRequests: [String: String] = [:]
/// Identity of the current room. Every async callback that could outlive a
/// disconnect/reconnect compares its captured generation against this before
@@ -45,7 +45,7 @@ final class VoiceSession: ObservableObject {
private(set) var sessionGeneration = 0
private(set) var room = Room()
let controlChannel = ControlChannel()
let transcriptHandler = TranscriptHandler()
let transcriptHandler: TranscriptHandler
let stateModel = ConversationStateModel()
let taskStore = TaskStore()
private let config: ServerConfig
@@ -85,10 +85,15 @@ final class VoiceSession: ObservableObject {
/// terminal failure) and ignored. Cleared when the new room connects.
private var isTearingDown = false
init(config: ServerConfig, tokenClient: TokenClient, settings: SettingsStore) {
init(config: ServerConfig, tokenClient: TokenClient, settings: SettingsStore, userID: String? = nil) {
transcriptHandler = TranscriptHandler(history: TranscriptHistory(origin: config.baseURL.absoluteString, userID: userID))
self.config = config
self.tokenClient = tokenClient
self.settings = settings
transcriptHandler.acceptsRoom = { [weak self] source in
guard let self else { return false }
return self.room === source && !self.isTearingDown && self.state == .ready
}
room.add(delegate: self)
room.add(delegate: controlChannel)
room.add(delegate: transcriptHandler)
@@ -107,25 +112,18 @@ final class VoiceSession: ObservableObject {
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 state == .connecting || state == .waitingAgent || state == .ready else { return }
guard !isTearingDown, state == .connecting || state == .waitingAgent || state == .ready else { return }
switch event {
case .models(let received, let selected):
models = received
// The persisted preference is deterministic; an early `models.selected`
// event must not silently replace it before sync-on-join has run.
if selectedModel == nil, settings.modelID == nil, let selected {
selectedModel = selected
settings.modelID = selected
}
logger.log("models received count=\(received.count) selected=\(selected ?? "nil", privacy: .public)")
case .modelChanged(let model):
selectedModel = model
settings.modelID = model
logger.log("model_changed applied: \(model, privacy: .public)")
case .taskEvent(let payload):
taskStore.apply(payload)
default:
@@ -133,6 +131,13 @@ final class VoiceSession: ObservableObject {
}
}
var onUnauthorized: (() async -> Void)? {
get { tokenClient.onUnauthorized }
set { tokenClient.onUnauthorized = newValue }
}
var bootstrapBearerToken: String { tokenClient.bearerToken }
var isReady: Bool { state == .ready }
/// Clear all per-room/session ephemeral published fields so the failed or
@@ -150,8 +155,8 @@ final class VoiceSession: ObservableObject {
isMuted = false
userWantsMicMuted = false
micError = nil
models = []
selectedModel = nil
workingRequests.removeAll()
assistantActivity = nil
isReconnecting = false
reconnectAttempted = false
micMutedByInterruption = false
@@ -206,7 +211,7 @@ final class VoiceSession: ObservableObject {
let result: Result<Void, Error>
do {
let token = try await tokenClient.fetchToken()
logger.log("token ok room=\(token.room, privacy: .public)")
guard generation == sessionGeneration, !Task.isCancelled, !isTearingDown else { return }
try await room.connect(url: config.livekitURL.absoluteString, token: token.token, connectOptions: makeConnectOptions())
result = .success(())
} catch {
@@ -327,7 +332,7 @@ final class VoiceSession: ObservableObject {
}
func agentReadyReceived() {
guard state == .waitingAgent, sessionGeneration > 0 else {
guard !isTearingDown, state == .waitingAgent, sessionGeneration > 0 else {
logger.log("agent_ready ignored in state \(String(describing: self.state), privacy: .public)")
return
}
@@ -351,7 +356,7 @@ final class VoiceSession: ObservableObject {
private func syncSettingsOnJoin(generation: Int) async {
guard generation == sessionGeneration, isReady else { return }
// Each sync operation is independent and best-effort: a failed set_voice
// or invalid persisted model must not prevent set_mode from being sent.
// must not prevent set_mode from being sent. Hermes is server-managed.
let voiceID = settings.voiceID
do {
try await controlChannel.setVoice(voiceID, room: room)
@@ -361,17 +366,6 @@ final class VoiceSession: ObservableObject {
logger.error("sync-on-join set_voice failed: \(String(describing: error), privacy: .public)")
}
guard generation == sessionGeneration else { return }
let model = settings.modelID ?? selectedModel
if let model {
do {
try await controlChannel.setModel(model, room: room)
guard generation == sessionGeneration else { return }
logger.log("sync-on-join set_model sent: \(model, privacy: .public)")
} catch {
logger.error("sync-on-join set_model failed: \(String(describing: error), privacy: .public)")
}
}
guard generation == sessionGeneration else { return }
// Sync the agent's conversation mode with the persisted setting.
let mode = settings.textMode ? "text" : "voice"
do {
@@ -383,29 +377,21 @@ final class VoiceSession: ObservableObject {
}
}
func selectVoice(_ id: String) async {
settings.voiceID = id
guard isReady else { return }
@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 }
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)")
}
}
func selectModel(_ id: String) async {
settings.modelID = id
guard isReady else { return }
let generation = sessionGeneration
do {
try await controlChannel.setModel(id, room: room)
guard generation == sessionGeneration else { return }
logger.log("set_model sent: \(id, privacy: .public)")
} catch {
logger.error("set_model failed: \(String(describing: error), privacy: .public)")
return false
}
}
@@ -427,6 +413,18 @@ final class VoiceSession: ObservableObject {
}
func enableMicrophone() async {
let predecessor = micOpTask
let generation = sessionGeneration
let task = Task { @MainActor in
await predecessor?.value
guard !Task.isCancelled, generation == sessionGeneration, isReady, !settings.textMode else { return }
await enableMicrophoneSerialized()
}
micOpTask = task
await task.value
}
private func enableMicrophoneSerialized() async {
// Text mode invariant: the microphone must never be enabled while text
// mode is active. This enforces the privacy contract at the API level,
// not just in the view layer (Finding 4).
@@ -436,9 +434,15 @@ final class VoiceSession: ObservableObject {
}
let generation = sessionGeneration
for attempt in 0..<3 {
guard generation == sessionGeneration, isReady, !settings.textMode, !Task.isCancelled else { return }
do {
try await room.localParticipant.setMicrophone(enabled: true)
guard generation == sessionGeneration else { return }
if settings.textMode || Task.isCancelled {
try? await room.localParticipant.setMicrophone(enabled: false)
isMuted = true
return
}
isMuted = false
userWantsMicMuted = false
micError = nil
@@ -461,6 +465,18 @@ final class VoiceSession: ObservableObject {
}
func setMuted(_ muted: Bool) async {
let predecessor = micOpTask
let generation = sessionGeneration
let task = Task { @MainActor in
await predecessor?.value
guard !Task.isCancelled, generation == sessionGeneration else { return }
await setMutedSerialized(muted)
}
micOpTask = task
await task.value
}
private func setMutedSerialized(_ muted: Bool) async {
guard isReady else { return }
// Text mode invariant: unmuting (enabling the mic) is not permitted
// while text mode is active. Muting is always allowed.
@@ -472,6 +488,11 @@ final class VoiceSession: ObservableObject {
do {
try await room.localParticipant.setMicrophone(enabled: !muted)
guard generation == sessionGeneration else { return }
if !muted && settings.textMode {
try? await room.localParticipant.setMicrophone(enabled: false)
isMuted = true
return
}
isMuted = muted
// Track explicit user mute intent separately from hardware state.
// Interruption begin operations use this after awaiting the
@@ -502,11 +523,14 @@ final class VoiceSession: ObservableObject {
/// current session.
func applyTextMode(_ enabled: Bool) async {
guard isReady else { return }
guard enabled == settings.textMode else { return }
if enabled {
await setMuted(true)
guard settings.textMode else { return }
await setMode("text")
} else {
await setMode("voice")
guard !settings.textMode else { return }
await enableMicrophone()
}
}
@@ -518,6 +542,7 @@ final class VoiceSession: ObservableObject {
}
func disconnect() async {
tokenClient.cancel()
// Set the teardown guard first so any .disconnected callback from our
// own room.disconnect() is ignored (not treated as a terminal failure).
isTearingDown = true
@@ -651,6 +676,11 @@ final class VoiceSession: ObservableObject {
do {
try await self.room.localParticipant.setMicrophone(enabled: true)
guard !Task.isCancelled, self.sessionGeneration == generation else { return }
if self.settings.textMode {
try? await self.room.localParticipant.setMicrophone(enabled: false)
self.isMuted = true
return
}
self.isMuted = false
logger.log("audio interruption ended, resuming mic (seq=\(currentSeq))")
} catch {
@@ -666,6 +696,20 @@ final class VoiceSession: ObservableObject {
}
}
func receiveAssistantProgress(_ data: Data) {
struct Progress: Decodable { let type: String; let request_id: String; let status: String; let message: String }
guard let progress = try? JSONDecoder().decode(Progress.self, from: data),
progress.type == "assistant_progress", !progress.request_id.isEmpty else { return }
switch progress.status {
case "working": workingRequests[progress.request_id] = progress.message.isEmpty ? "Hope is working on your request…" : progress.message
case "completed": workingRequests.removeValue(forKey: progress.request_id)
case "failed": workingRequests.removeValue(forKey: progress.request_id)
default: return
}
assistantActivity = workingRequests.values.sorted().first
if progress.status == "failed" { assistantActivity = "Hope could not complete that request. Please try again." }
}
func tickAudioLevels(local: Float, remote: Float) {
stateModel.tick(localAudioLevel: local, remoteAudioLevel: remote)
}
@@ -718,9 +762,12 @@ extension VoiceSession: RoomDelegate {
}
nonisolated func room(_ room: Room, participant: RemoteParticipant?, didReceiveData data: Data, forTopic topic: String, encryptionType: EncryptionType) {
logger.log("data topic=\(topic, privacy: .public) bytes=\(data.count) payload=\(String(data: data.prefix(300), encoding: .utf8) ?? "<binary>", privacy: .public)")
Task { @MainActor [weak self] in
guard let self, self.room === room else { return }
guard let self, self.room === room, !self.isTearingDown, self.state == .ready || self.state == .waitingAgent else { return }
if topic == "assistant-progress" {
self.receiveAssistantProgress(data)
return
}
// Secondary transcript source (finals only).
if topic == "transcript",
let json = try? JSONDecoder().decode([String: JSONValue].self, from: data),
+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 }
}
+12 -12
View File
@@ -29,19 +29,19 @@ struct ServerConfig: Equatable {
return .failure(.invalidURL(raw))
}
if scheme != "https" { return .failure(.notHTTPS(raw)) }
var c = components
c.path = ""
c.fragment = nil
guard let base = c.url else { return .failure(.invalidURL(raw)) }
// LAN deployment: LiveKit is reachable directly on :7880 (plain ws).
// Real domains use wss via same origin. The SDK derives /rtc from the base.
let livekit: URL
if Self.isPrivateIP(host) {
livekit = URL(string: "ws://\(host):7880")!
} else {
let portSuffix = components.port.map { ":\($0)" } ?? ""
livekit = URL(string: "wss://\(host)\(portSuffix)/livekit")!
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))
}
+10 -2
View File
@@ -15,7 +15,10 @@ enum TLSPinning {
static let host = "192.168.86.2"
static func session() -> URLSession {
let config = URLSessionConfiguration.default
let config = URLSessionConfiguration.ephemeral
config.urlCache = nil
config.httpCookieStorage = nil
config.urlCredentialStorage = nil
config.timeoutIntervalForRequest = 15
return URLSession(configuration: config, delegate: PinnedTrustDelegate(), delegateQueue: nil)
}
@@ -37,7 +40,12 @@ enum TLSPinning {
}
}
final class PinnedTrustDelegate: NSObject, URLSessionDelegate {
final class PinnedTrustDelegate: NSObject, URLSessionDelegate, URLSessionTaskDelegate {
func urlSession(_ session: URLSession, task: URLSessionTask, willPerformHTTPRedirection response: HTTPURLResponse, newRequest request: URLRequest, completionHandler: @escaping (URLRequest?) -> Void) {
// Authentication/password-bearing requests must never be redirected to
// another endpoint or origin. Correct the configured server instead.
completionHandler(nil)
}
func urlSession(_ session: URLSession, didReceive challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {
guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust,
let trust = challenge.protectionSpace.serverTrust else {
+27 -3
View File
@@ -4,6 +4,11 @@ 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
@@ -42,22 +47,41 @@ enum TokenError: LocalizedError {
final class TokenClient {
let config: ServerConfig
private let session: URLSession
var onUnauthorized: (() async -> Void)?
let bearerToken: String
var conversationID: String?
init(config: ServerConfig) {
init(config: ServerConfig, bearerToken: String = "", session: URLSession? = nil) {
self.config = config
self.session = TLSPinning.session()
self.session = session ?? TLSPinning.session()
self.bearerToken = bearerToken
}
func cancel() { session.getAllTasks { $0.forEach { $0.cancel() } } }
func fetchToken() async throws -> TokenResponse {
#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 {
let detail = String(data: data, encoding: .utf8) ?? ""
if http.statusCode == 401 || http.statusCode == 403 { await onUnauthorized?() }
let detail = ""
throw TokenError.httpStatus(http.statusCode, detail)
}
do {
+182
View File
@@ -0,0 +1,182 @@
import Foundation
import Testing
@testable import Voice
private final class AuthURLProtocol: URLProtocol {
static var handler: ((URLRequest) throws -> (Int, Data))?
override class func canInit(with request: URLRequest) -> Bool { true }
override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
override func startLoading() {
do {
let (status, data) = try Self.handler!(request)
client?.urlProtocol(self, didReceive: HTTPURLResponse(url: request.url!, statusCode: status, httpVersion: nil, headerFields: nil)!, cacheStoragePolicy: .notAllowed)
client?.urlProtocol(self, didLoad: data)
client?.urlProtocolDidFinishLoading(self)
} catch { client?.urlProtocol(self, didFailWithError: error) }
}
override func stopLoading() {}
}
@Suite(.serialized)
@MainActor
struct AuthTests {
private func config(_ value: String = "https://example.com") throws -> ServerConfig {
try ServerConfig.parse(value).get()
}
private func session() -> URLSession {
let config = URLSessionConfiguration.ephemeral
config.protocolClasses = [AuthURLProtocol.self]
return URLSession(configuration: config)
}
@Test func englishVoiceCatalogAndPreferenceMigration() async throws {
AuthURLProtocol.handler = { request in
#expect(request.url?.path == "/voices")
#expect(request.httpMethod == "GET")
#expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer test-session")
return (200, Data(#"{"default_voice":"en-US-Ava:DragonHDOmniLatestNeural","catalog_available":true,"voices":[{"id":"en-GB-Sonia:DragonHDOmniLatestNeural","name":"Sonia","locale":"en-GB","gender":"Female","tag":"Omni"}]}"#.utf8))
}
let catalog = try await AuthClient(config: config(), session: session()).voices(token: "test-session")
#expect(catalog.catalogAvailable)
#expect(catalog.voices.first?.details == "en-GB · Female · Omni")
#expect(Voices.restoredID(catalog.voices.first?.id) == "en-GB-Sonia:DragonHDOmniLatestNeural")
// A valid server-catalogued choice must survive SettingsStore's next launch,
// even if it is not among the offline fallback voices.
#expect(Voices.restoredID("en-GB-Sonia:DragonHDOmniLatestNeural") == "en-GB-Sonia:DragonHDOmniLatestNeural")
#expect(Voices.restoredID("en-US-Ava:DragonHDLatestNeural") == Voices.defaultID)
for legacy in ["Jenny", "Guy", "Andrew", "Aria", "Emma", "Eric", "Brian", "Ashley", "Richard", "Tina", "Steffan"] {
let migrated = Voices.restoredID("en-US-\(legacy)Neural")
#expect(migrated.contains(":DragonHDOmniLatestNeural"))
#expect(Voices.display(for: migrated) != nil)
}
#expect(Voices.restoredID("invalid <voice>") == Voices.defaultID)
#expect(Voices.restoredID(nil) == Voices.defaultID)
}
@Test func certificateFailureHasSafeRecovery() throws {
let url = URL(string: "https://192.168.86.2:9178")!
let message = ConnectionFailure.message(URLError(.serverCertificateUntrusted), server: url)
#expect(message.contains("192.168.86.2"))
#expect(message.contains("Certificate Trust Settings"))
#expect(!message.contains("disable"))
#expect(ConnectionFailure.message(AuthError.status(401), server: url).contains("username and password"))
}
@Test func canonicalOriginsAndSecureIPv6() throws {
let parsed = try config(" HTTPS://EXAMPLE.COM:443/path ")
#expect(parsed.baseURL.absoluteString == "https://example.com")
#expect(parsed.livekitURL.absoluteString == "wss://example.com/livekit")
#expect(try config("https://[fd00::1]:9178").livekitURL.absoluteString == "wss://[fd00::1]:9178/livekit")
for invalid in ["https://u:[email protected]", "https://example.com?token=x", "https://example.com#fragment", "http://192.168.1.1"] {
if case .success = ServerConfig.parse(invalid) { Issue.record("Accepted unsafe origin") }
}
}
@Test func bearerBootstrapAndMissingAuth() async throws {
let c = try config()
AuthURLProtocol.handler = { request in
#expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer test-session")
#expect(request.url?.path == "/token")
return (200, Data(#"{"token":"room-jwt","room":"room","user":{"id":"one","display_name":"One"}}"#.utf8))
}
let response = try await TokenClient(config: c, bearerToken: "test-session", session: session()).fetchToken()
#expect(response.user.id == "one")
do {
_ = try await TokenClient(config: c, session: session()).fetchToken()
Issue.record("Unauthenticated bootstrap accepted")
} catch { #expect(error is AuthError) }
}
@Test func loginMeLogoutWireContract() async throws {
let c = try config()
let user = #"{"id":"one","username":"alice","display_name":"Alice","role":"user","status":"ready"}"#
AuthURLProtocol.handler = { request in
switch request.url!.path {
case "/api/auth/login":
#expect(request.httpMethod == "POST")
#expect(request.value(forHTTPHeaderField: "Authorization") == nil)
return (200, Data("{\"token\":\"test-session\",\"user\":\(user),\"expires_at\":4102444800}".utf8))
case "/api/auth/me":
#expect(request.httpMethod == "GET")
#expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer test-session")
return (200, Data("{\"user\":\(user)}".utf8))
default:
#expect(request.url!.path == "/api/auth/logout")
#expect(request.httpMethod == "POST")
#expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer test-session")
return (204, Data())
}
}
let memory = MemoryTokenStore()
let client = AuthClient(config: c, session: session())
let store = AuthStore(config: c, client: client, tokens: memory)
await store.login(username: "alice", password: "never-persist-this")
#expect(store.user?.id == "one")
#expect(try memory.token(origin: c.baseURL.absoluteString) == "test-session")
#expect(try memory.token(origin: "https://another.example") == nil)
let restored = AuthStore(config: c, client: client, tokens: memory)
await restored.restore()
#expect(restored.user?.username == "alice")
await store.logout {
#expect(store.token == nil)
#expect(store.user?.id == "one") // old UI survives only until transport disconnect
}
#expect(store.user == nil)
#expect(try memory.token(origin: c.baseURL.absoluteString) == nil)
}
@Test func transientRestoreFailureKeepsTokenButUnauthorizedDeletesIt() async throws {
let c = try config()
let memory = MemoryTokenStore()
try memory.save("test-session", origin: c.baseURL.absoluteString)
AuthURLProtocol.handler = { _ in throw URLError(.notConnectedToInternet) }
let offline = AuthStore(config: c, client: AuthClient(config: c, session: session()), tokens: memory)
await offline.restore()
#expect(offline.user == nil)
#expect(try memory.token(origin: c.baseURL.absoluteString) == "test-session")
AuthURLProtocol.handler = { _ in (401, Data()) }
let revoked = AuthStore(config: c, client: AuthClient(config: c, session: session()), tokens: memory)
await revoked.restore()
#expect(revoked.user == nil)
#expect(try memory.token(origin: c.baseURL.absoluteString) == nil)
}
@Test func logoutInvalidatesSuspendedRestore() async throws {
let c = try config()
let memory = MemoryTokenStore()
try memory.save("test-session", origin: c.baseURL.absoluteString)
AuthURLProtocol.handler = { _ in
return (200, Data(#"{"user":{"id":"one","username":"alice","role":"user","status":"ready"}}"#.utf8))
}
let store = AuthStore(config: c, client: AuthClient(config: c, session: session()), tokens: memory)
let restore = Task { await store.restore() }
await Task.yield()
await store.logout {}
await restore.value
#expect(store.user == nil)
#expect(store.token == nil)
}
@Test func historyNeverImportsGlobalLegacyAndSeparatesAccounts() throws {
let origin = "https://history-test.example"
let legacy = AppPreferences.store.object(forKey: "voice_history")
defer {
if let legacy { AppPreferences.store.set(legacy, forKey: "voice_history") }
else { AppPreferences.store.removeObject(forKey: "voice_history") }
}
AppPreferences.store.set("[{\"role\":\"user\",\"text\":\"legacy secret\",\"timestamp\":0}]", forKey: "voice_history")
let first = TranscriptHistory(origin: origin, userID: "one")
#expect(!first.entries.contains { $0.text == "legacy secret" })
first.clear()
first.sync(finals: [("user", "private", Date())])
#expect(TranscriptHistory(origin: origin, userID: "one").entries.last?.text == "private")
let other = TranscriptHistory(origin: origin, userID: "two")
#expect(other.entries.isEmpty)
#expect(TranscriptHistory(origin: "https://other.example", userID: "one").entries.isEmpty)
first.clear()
}
@Test func progressCompletesWithoutTranscriptPollution() throws {
let settings = SettingsStore()
let c = try config()
let voice = VoiceSession(config: c, tokenClient: TokenClient(config: c), settings: settings)
let before = voice.transcriptHandler.events
voice.receiveAssistantProgress(Data(#"{"type":"assistant_progress","request_id":"a","status":"working","message":"Checking your calendar…"}"#.utf8))
#expect(voice.assistantActivity == "Checking your calendar…")
voice.receiveAssistantProgress(Data(#"{"type":"assistant_progress","request_id":"a","status":"completed","message":"Done"}"#.utf8))
#expect(voice.assistantActivity == nil)
#expect(voice.transcriptHandler.events == before)
}
}
+67
View File
@@ -0,0 +1,67 @@
import XCTest
@testable import Voice
/// Deliberate physical/native transport qualification. No stored credentials,
/// microphones, account memory edits, preference mutation or payload logging.
final class ChatLiveTests: XCTestCase {
func testAuthenticatedNativeDurableChat() async throws {
let env = ProcessInfo.processInfo.environment
guard env["HOPE_CHAT_LIVE"] == "1" else { throw XCTSkip("Native live chat requires explicit HOPE_CHAT_LIVE=1") }
guard let username = env["HOPE_TEST_USERNAME"], let password = env["HOPE_TEST_PASSWORD"], !password.isEmpty else { throw XCTSkip("Protected live credentials were not supplied") }
let config = try ServerConfig.parse(env["HOPE_TEST_ORIGIN"] ?? "https://192.168.86.2:9178").get()
let auth = AuthClient(config: config)
print("HOPE_CHAT_PROBE native authenticated HTTP started; microphone remains off")
let login = try await auth.login(username: username, password: password)
let api = ChatAPI(config: config, token: login.token)
var createdID: String?
do {
let verified = try await auth.me(token: login.token)
XCTAssertEqual(verified.id, login.user.id)
let creation = try await api.request("api/conversations", method: "POST", body: ["title": "Native qualification · disposable"])
let conversation = try ChatCoding.decoder.decode(ConversationEnvelope.self, from: creation).conversation
createdID = conversation.id
let sent = try await api.request("api/conversations/\(conversation.id)/messages", method: "POST", body: ["content": "Reply exactly: native chat transport verified", "idempotency_key": UUID().uuidString, "mode": "normal"])
let operation = try ChatCoding.decoder.decode(SendEnvelope.self, from: sent)
var completed: RunDetail?
for _ in 0..<90 {
let bytes = try await api.request("api/runs/\(operation.run.id)")
let detail = try ChatCoding.decoder.decode(RunDetail.self, from: bytes)
if !detail.run.active { completed = detail; break }
try await Task.sleep(for: .seconds(2))
}
let result = try XCTUnwrap(completed, "Run did not terminate within qualification deadline")
XCTAssertEqual(result.run.status, "completed", "Server run failed; body intentionally not logged")
XCTAssertTrue(result.messages.contains { $0.role == "assistant" && !$0.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty })
let recorder = await MainActor.run { LiveEventRecorder() }
try await api.events(run: operation.run.id, after: 0) { event in recorder.accept(event) }
let replay = await MainActor.run { recorder.ids }
XCTAssertFalse(replay.isEmpty)
XCTAssertEqual(replay, replay.sorted())
XCTAssertEqual(Set(replay).count, replay.count)
let detailBytes = try await api.request("api/conversations/\(conversation.id)")
let detail = try ChatCoding.decoder.decode(ConversationDetail.self, from: detailBytes)
XCTAssertTrue(detail.messages.contains { $0.role == "user" })
XCTAssertTrue(detail.messages.contains { $0.role == "assistant" })
let pinnedBytes = try await api.request("api/conversations/\(conversation.id)", method: "PATCH", body: ["revision": detail.conversation.revision, "pinned": true])
let pinned = try ChatCoding.decoder.decode(ConversationEnvelope.self, from: pinnedBytes).conversation
XCTAssertTrue(pinned.pinned)
_ = try await api.request("api/conversations/\(conversation.id)", method: "PATCH", body: ["revision": pinned.revision, "archived": true])
_ = try await api.request("api/conversations/\(conversation.id)", method: "DELETE")
createdID = nil
try await auth.logout(token: login.token)
api.cancel()
print("HOPE_CHAT_PROBE native login/me/create/send/completion/replay/pin/archive/delete/logout completed; no audio captured")
} catch {
if let createdID { _ = try? await api.request("api/conversations/\(createdID)", method: "DELETE") }
try? await auth.logout(token: login.token)
api.cancel()
print("HOPE_CHAT_PROBE cleanup attempted after failure; bodies/credentials withheld")
throw error
}
}
}
@MainActor
private final class LiveEventRecorder {
var ids: [Int] = []
func accept(_ event: ChatEvent) { ids.append(event.id) }
}
+179
View File
@@ -0,0 +1,179 @@
import XCTest
@testable import Voice
@MainActor
final class ChatTests: XCTestCase {
func testOfflineCRUDSearchPinsArchiveMoveAndPreferences() async throws {
let api = OfflineChatAPI()
let store = ChatStore(api: api)
await store.load()
XCTAssertTrue(store.conversations.isEmpty)
await store.saveWorkspace(nil, name: "Writing", instructions: "Be concise")
let space = try XCTUnwrap(store.workspaces.first)
await store.create(workspace: space.id)
let original = try XCTUnwrap(store.selected)
store.draft = "A durable offline message"
await store.send()
XCTAssertEqual(store.messages.count, 2)
XCTAssertEqual(store.draft, "")
await store.patch(original, changes: ["title": "Weekend", "pinned": true])
XCTAssertEqual(store.selected?.title, "Weekend")
XCTAssertTrue(store.selected?.pinned == true)
await store.load(query: "durable")
XCTAssertEqual(store.conversations.count, 1)
await store.savePreferences(palette: "ocean", appearance: "dark")
XCTAssertEqual(store.preferences.palette, "ocean")
await store.deleteWorkspace(space)
XCTAssertNil(store.conversations.first?.workspaceId)
let renamed = try XCTUnwrap(store.selected)
await store.patch(renamed, changes: ["archived": true])
await store.load()
XCTAssertTrue(store.conversations.isEmpty)
await store.load(archived: true)
XCTAssertEqual(store.conversations.count, 1)
await store.delete(try XCTUnwrap(store.conversations.first))
XCTAssertNil(store.selected)
XCTAssertTrue(store.messages.isEmpty)
}
func testWorkspaceSaveReportsFailureAndPreservesExistingWorkspaces() async throws {
let store = ChatStore(api: OfflineChatAPI())
let rejected = await store.saveWorkspace(nil, name: " ", instructions: "Ignored")
XCTAssertFalse(rejected)
XCTAssertEqual(store.error, "Enter a workspace name.")
XCTAssertTrue(store.workspaces.isEmpty)
let saved = await store.saveWorkspace(nil, name: "Writing", instructions: "Be concise")
XCTAssertTrue(saved)
XCTAssertEqual(store.workspaces.map(\.name), ["Writing"])
}
func testPreviewClearsOldContentBeforeNewRequestAndOnTeardown() async throws {
let api = OfflineChatAPI()
let store = ChatStore(api: api)
await store.features.load()
let data = Data("Private preview".utf8)
_ = await store.features.upload(data, filename: "preview.txt", mime: "text/plain")
let attachment = try XCTUnwrap(store.features.attachments.first)
store.features.previewText = "stale"
await store.features.preview(attachment)
XCTAssertEqual(store.features.previewAttachment?.id, attachment.id)
XCTAssertNotEqual(store.features.previewText, "stale")
store.features.clearPreview()
XCTAssertNil(store.features.previewAttachment)
XCTAssertNil(store.features.previewText)
}
func testOfflineResearchTruthfulExportAndTeardown() async throws {
let store = ChatStore(api: OfflineChatAPI())
await store.create()
store.draft = "Export this"; await store.send()
await store.loadResearch()
XCTAssertFalse(store.researchAvailable)
await store.exportConversation(format: "markdown")
XCTAssertTrue(store.exportText?.contains("Export this") == true)
XCTAssertTrue(store.exportFile?.filename.hasSuffix(".md") == true)
XCTAssertEqual(store.exportFile?.data, Data(store.exportText!.utf8))
store.foreground(true)
store.foreground(false)
store.close()
XCTAssertNil(store.exportText)
XCTAssertNil(store.exportFile)
XCTAssertFalse(store.research)
}
func testBranchAndRegeneratePreserveOriginal() async throws {
let store = ChatStore(api: OfflineChatAPI())
await store.create()
let original = try XCTUnwrap(store.selected)
store.draft = "Original question"; await store.send()
let user = try XCTUnwrap(store.messages.first)
await store.branch(user, edited: "Edited question")
XCTAssertNotEqual(store.selected?.id, original.id)
XCTAssertEqual(store.messages.first?.content, "Edited question")
await store.open(original)
XCTAssertEqual(store.messages.first?.content, "Original question")
let answer = try XCTUnwrap(store.messages.last)
await store.branch(answer, regenerate: true)
XCTAssertNotEqual(store.selected?.id, original.id)
XCTAssertEqual(store.messages.first?.content, "Original question")
}
func testReplayFullReplacementAndDuplicateCursor() async throws {
let store = ChatStore(api: OfflineChatAPI())
await store.create()
let id = try XCTUnwrap(store.selected?.id)
func event(_ seq: Int, _ text: String) -> ChatEvent {
ChatEvent(id: seq, name: "message", data: Data("{\"message\":{\"id\":\"m\",\"conversation_id\":\"\(id)\",\"role\":\"assistant\",\"content\":\"\(text)\",\"status\":\"streaming\",\"run_id\":\"r\",\"created_at\":1}}".utf8))
}
try store.apply(event(1, "A"), run: "r")
try store.apply(event(2, "Answer"), run: "r")
try store.apply(event(1, "Duplicate"), run: "r")
XCTAssertEqual(store.messages.count, 1)
XCTAssertEqual(store.messages.first?.content, "Answer")
store.close()
XCTAssertTrue(store.messages.isEmpty)
XCTAssertNil(store.selected)
XCTAssertEqual(store.draft, "")
}
func testSSECommentsMultilineAndFraming() throws {
var parser = SSEParser()
XCTAssertNil(parser.consume(": keepalive"))
XCTAssertNil(parser.consume("id: 12"))
XCTAssertNil(parser.consume("event: message"))
XCTAssertNil(parser.consume("data: {"))
XCTAssertNil(parser.consume("data: }"))
let result = try XCTUnwrap(parser.consume(""))
XCTAssertEqual(result.id, 12)
XCTAssertEqual(result.name, "message")
XCTAssertEqual(String(data: result.data, encoding: .utf8), "{\n}")
XCTAssertNil(parser.consume(""))
}
func testRevisionConflictRetainsDraft() async throws {
let store = ChatStore(api: OfflineChatAPI())
await store.create(); let old = try XCTUnwrap(store.selected)
await store.patch(old, changes: ["title": "First"])
store.draft = "Keep me"
await store.patch(old, changes: ["title": "Stale"])
XCTAssertEqual(store.selected?.title, "First")
XCTAssertEqual(store.draft, "Keep me")
XCTAssertNotNil(store.error)
}
func testAdvancedOfflineUploadArtifactTaskMemoryAndPrivacy() async throws {
let api = OfflineChatAPI()
let features = ChatFeatureStore(api: api)
await features.load()
XCTAssertTrue(features.enabled.contains("attachments"))
let uploaded = await features.upload(Data("Private notes".utf8), filename: "notes.txt", mime: "text/plain")
let upload = try XCTUnwrap(uploaded)
await features.preview(upload)
XCTAssertEqual(features.previewText, "Private notes")
await features.saveArtifact(nil, title: "Draft", kind: "markdown", content: "# Draft")
let item = try XCTUnwrap(features.artifacts.first)
await features.saveArtifact(item, title: "Revised", kind: "markdown", content: "# Revised")
XCTAssertEqual(features.artifacts.first?.revision, 2)
await features.saveTask(nil, name: "Reminder", schedule: "in 1 hour", prompt: "Check notes")
let task = try XCTUnwrap(features.tasks.first)
await features.taskAction(task, action: "pause")
XCTAssertEqual(features.tasks.first?.enabled, false)
let memory = try XCTUnwrap(features.memory.first)
await features.saveMemory(memory, content: "New memory")
XCTAssertEqual(features.memory.first?.content, "New memory")
await features.saveMemory(memory, content: "Stale overwrite")
XCTAssertNotNil(features.error)
XCTAssertEqual(features.memory.first?.content, "New memory")
features.close()
XCTAssertTrue(features.attachments.isEmpty)
XCTAssertTrue(features.artifacts.isEmpty)
XCTAssertTrue(features.tasks.isEmpty)
XCTAssertTrue(features.memory.isEmpty)
XCTAssertNil(features.previewText)
}
func testSharedThemeContrastAcrossPalettes() {
func luminance(_ rgb: HopeRGB) -> Double {
func channel(_ x: Double) -> Double { x <= 0.04045 ? x / 12.92 : pow((x + 0.055) / 1.055, 2.4) }
return 0.2126 * channel(rgb.red) + 0.7152 * channel(rgb.green) + 0.0722 * channel(rgb.blue)
}
for palette in HopePalette.allCases { for dark in [false, true] {
let ground = luminance(HopeTheme.rgb(.ground, palette: palette, dark: dark))
for role in [HopeThemeRole.ink, .quiet, .accent] {
let text = luminance(HopeTheme.rgb(role, palette: palette, dark: dark))
XCTAssertGreaterThanOrEqual((max(text, ground) + 0.05) / (min(text, ground) + 0.05), 4.5, "\(palette) \(role) \(dark)")
}
} }
}
}
+52
View File
@@ -0,0 +1,52 @@
import XCTest
@testable import Voice
private final class ChatURLProtocol: URLProtocol {
static var handler: ((URLRequest) throws -> (Int, Data))?
override class func canInit(with request: URLRequest) -> Bool { true }
override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
override func startLoading() {
do {
let (status, body) = try Self.handler!(request)
client?.urlProtocol(self, didReceive: HTTPURLResponse(url: request.url!, statusCode: status, httpVersion: nil, headerFields: nil)!, cacheStoragePolicy: .notAllowed)
client?.urlProtocol(self, didLoad: body); client?.urlProtocolDidFinishLoading(self)
} catch { client?.urlProtocol(self, didFailWithError: error) }
}
override func stopLoading() {}
}
final class ChatTransportTests: XCTestCase {
func testBearerSameOriginQueryAndRawUpload() async throws {
guard case .success(let config) = ServerConfig.parse("https://hope.example:9178") else { XCTFail(); return }
let configuration = URLSessionConfiguration.ephemeral
configuration.protocolClasses = [ChatURLProtocol.self]
let api = ChatAPI(config: config, token: "offline-test-bearer", session: URLSession(configuration: configuration))
ChatURLProtocol.handler = { request in
XCTAssertEqual(request.url?.host, "hope.example")
XCTAssertEqual(request.url?.scheme, "https")
XCTAssertEqual(request.value(forHTTPHeaderField: "Authorization"), "Bearer offline-test-bearer")
XCTAssertEqual(URLComponents(url: request.url!, resolvingAgainstBaseURL: false)?.queryItems?.first?.value, "a & b")
return (200, Data("{\"conversations\":[],\"next_cursor\":null}".utf8))
}
_ = try await api.request("api/conversations", query: [URLQueryItem(name: "q", value: "a & b")])
ChatURLProtocol.handler = { request in
XCTAssertEqual(request.httpMethod, "POST")
XCTAssertEqual(request.url?.path, "/api/attachments")
XCTAssertEqual(request.value(forHTTPHeaderField: "X-Filename"), "notes.txt")
XCTAssertEqual(request.value(forHTTPHeaderField: "Content-Type"), "text/plain")
return (201, Data("{}".utf8))
}
_ = try await api.upload(data: Data("test".utf8), filename: "notes.txt", mime: "text/plain")
api.cancel(); ChatURLProtocol.handler = nil
}
func testUnauthorizedAndRedirectStatusCannotBecomeSuccess() async throws {
guard case .success(let config) = ServerConfig.parse("https://hope.example") else { XCTFail(); return }
let configuration = URLSessionConfiguration.ephemeral; configuration.protocolClasses = [ChatURLProtocol.self]
let api = ChatAPI(config: config, token: "offline", session: URLSession(configuration: configuration))
for status in [401, 403, 302, 409] {
ChatURLProtocol.handler = { _ in (status, Data()) }
do { _ = try await api.request("api/conversations"); XCTFail("Expected rejection") }
catch ChatFailure.status(let actual) { XCTAssertEqual(actual, status) }
}
api.cancel(); ChatURLProtocol.handler = nil
}
}
+33
View File
@@ -85,6 +85,39 @@ struct ConversationStateTests {
#expect(!model.isThinkingChipVisible)
}
@Test("square LED presence responds to input and emphasizes assistant output")
func ledPresenceAudioResponse() {
let quiet = LightwellView.dot(row: 10, column: 10, phase: .listening(level: 0), time: 0, reduceMotion: false)
let listening = LightwellView.dot(row: 10, column: 10, phase: .listening(level: 0.8), time: 0, reduceMotion: false)
let speaking = LightwellView.dot(row: 10, column: 10, phase: .speaking(level: 0.8), time: 0, reduceMotion: false)
#expect(listening.opacity > quiet.opacity)
#expect(listening.scale > quiet.scale)
#expect(speaking.opacity > listening.opacity)
#expect(speaking.scale > listening.scale)
}
@Test("LED reduce motion freezes audio geometry and pending waves")
func ledReduceMotion() {
for phase: LightwellView.Phase in [.connecting, .thinking, .speaking(level: 0.8)] {
#expect(LightwellView.dot(row: 8, column: 9, phase: phase, time: 0, reduceMotion: true)
== LightwellView.dot(row: 8, column: 9, phase: phase, time: 7, reduceMotion: true))
}
#expect(LightwellView.dot(row: 10, column: 10, phase: .listening(level: 0), time: 0, reduceMotion: true)
== LightwellView.dot(row: 10, column: 10, phase: .listening(level: 1), time: 0, reduceMotion: true))
}
@Test("LED state patterns are distinct and invalid signal levels remain bounded")
func ledStatePatterns() {
let dormant = LightwellView.dot(row: 10, column: 10, phase: .idle, time: 0, reduceMotion: false)
let thinking = LightwellView.dot(row: 10, column: 10, phase: .thinking, time: 0, reduceMotion: true)
#expect(thinking.opacity > dormant.opacity)
for level in [Double.nan, Double.infinity, -1, 10] {
let dot = LightwellView.dot(row: 10, column: 10, phase: .speaking(level: level), time: 0, reduceMotion: false)
#expect(dot.opacity.isFinite && (0...1).contains(dot.opacity))
#expect(dot.scale.isFinite && dot.scale >= 1)
}
}
// MARK: - Interruption begin mute decision (Iteration 08 Item 1)
/// Pure decision seam test: verifies that the interruption-begin mute
+46
View File
@@ -0,0 +1,46 @@
import Foundation
import LiveKit
import Testing
@testable import Voice
/// Explicit opt-in native CFNetwork probe; no account credentials or bodies.
struct TLSLiveTests {
@Test func pinnedLANHTTPS() async throws {
guard ProcessInfo.processInfo.environment["HOPE_TLS_LIVE"] == "1" else {
print("HOPE_TLS_PROBE skipped: explicit live opt-in absent")
return
}
print("HOPE_TLS_PROBE native HTTPS validation started")
let (data, response) = try await TLSPinning.session().data(from: URL(string: "https://192.168.86.2:9178/api/auth/me")!)
#expect((response as? HTTPURLResponse)?.statusCode == 401)
#expect(!data.isEmpty)
}
@Test func systemTrustedLANHTTPS() async throws {
guard ProcessInfo.processInfo.environment["HOPE_TLS_LIVE"] == "1" else { return }
print("HOPE_TLS_PROBE system trust validation started")
let (_, response) = try await URLSession.shared.data(from: URL(string: "https://192.168.86.2:9178/api/auth/me")!)
#expect((response as? HTTPURLResponse)?.statusCode == 401)
print("HOPE_TLS_PROBE system trust validation completed")
}
@Test func nativeAccountLogin() async throws {
let env = ProcessInfo.processInfo.environment
guard env["HOPE_TLS_LIVE"] == "1", let username = env["HOPE_TEST_USERNAME"],
let password = env["HOPE_TEST_PASSWORD"] else { return }
print("HOPE_TLS_PROBE native account login started")
let config = try ServerConfig.parse("https://192.168.86.2:9178").get()
let client = AuthClient(config: config)
let login = try await client.login(username: username, password: password)
#expect(login.user.username == username)
#expect(try await client.me(token: login.token).id == login.user.id)
let bootstrap = try await TokenClient(config: config, bearerToken: login.token).fetchToken()
let room = Room()
try await room.connect(url: config.livekitURL.absoluteString, token: bootstrap.token)
#expect(room.connectionState == .connected)
await room.disconnect()
print("HOPE_TLS_PROBE authenticated LiveKit WSS connected and disconnected; microphone not enabled")
try await client.logout(token: login.token)
print("HOPE_TLS_PROBE native login/me/logout completed")
}
}
+94
View File
@@ -0,0 +1,94 @@
import XCTest
final class ChatUITests: XCTestCase {
func testNativeTextChatWithoutVoiceAndAccountPrivacy() {
let app = XCUIApplication()
app.launchArguments = ["--ui-testing", "--reset-ui-testing", "--ui-chat", "-UIPreferredContentSizeCategoryName", "UICTContentSizeCategoryL"]
app.launch()
XCTAssertTrue(app.navigationBars["Chats"].waitForExistence(timeout: 10))
app.buttons["New chat"].tap()
let field = app.textFields["chat-draft"]
XCTAssertTrue(field.waitForExistence(timeout: 10))
field.tap(); field.typeText("Native offline text")
app.buttons["Send message"].tap()
XCTAssertTrue(app.staticTexts["Native offline text"].waitForExistence(timeout: 10))
XCTAssertTrue(app.staticTexts["Offline reply"].exists)
app.swipeDown()
capture(app, "Native chat standard Opal")
XCTAssertFalse(app.buttons["End conversation"].exists)
app.navigationBars.buttons.element(boundBy: 0).tap()
app.buttons["Chat options"].tap()
app.buttons["Sign out"].tap()
XCTAssertTrue(app.secureTextFields["Password"].waitForExistence(timeout: 10))
XCTAssertFalse(app.staticTexts["Native offline text"].exists)
}
func testOfflineAdvancedLibraryAndArtifactEditor() {
let app = XCUIApplication()
app.launchArguments = ["--ui-testing", "--reset-ui-testing", "--ui-chat", "-UIPreferredContentSizeCategoryName", "UICTContentSizeCategoryL"]
app.launch()
XCTAssertTrue(app.buttons["Chat options"].waitForExistence(timeout: 10))
app.buttons["Chat options"].tap(); app.buttons["Library and tools"].tap()
XCTAssertTrue(app.buttons["New artifact"].waitForExistence(timeout: 10))
app.buttons["New artifact"].tap()
XCTAssertTrue(app.textFields["Title"].waitForExistence(timeout: 10))
app.textFields["Title"].tap(); app.textFields["Title"].typeText("Local artifact")
app.textViews.firstMatch.tap(); app.textViews.firstMatch.typeText("Native fixture content")
app.buttons["Save"].tap()
let artifact = app.buttons.matching(NSPredicate(format: "label CONTAINS 'Local artifact'")).firstMatch
XCTAssertTrue(artifact.waitForExistence(timeout: 10))
artifact.tap()
XCTAssertTrue(app.textViews.firstMatch.waitForExistence(timeout: 10))
XCTAssertEqual(app.textViews.firstMatch.value as? String, "Native fixture content")
capture(app, "Native artifact")
app.buttons["Cancel"].tap()
app.buttons["Done"].tap()
}
private func capture(_ app: XCUIApplication, _ name: String) {
let attachment = XCTAttachment(screenshot: app.screenshot()); attachment.name = name; attachment.lifetime = .keepAlways; add(attachment)
}
func testWorkspaceNativeFixture() {
let app = XCUIApplication()
app.launchArguments = ["--ui-testing", "--reset-ui-testing", "--ui-chat", "-UIPreferredContentSizeCategoryName", "UICTContentSizeCategoryL"]
app.launch()
XCTAssertTrue(app.buttons["Chat options"].waitForExistence(timeout: 10))
app.buttons["Chat options"].tap(); app.buttons["Manage workspaces"].tap()
let field = app.textFields["Workspace name"]
XCTAssertTrue(field.waitForExistence(timeout: 10)); field.tap(); field.typeText("Writing")
app.buttons["Save workspace"].tap()
XCTAssertTrue(app.buttons["Writing"].waitForExistence(timeout: 10))
capture(app, "Native workspaces")
}
func testStandardDarkChatScreenshot() {
let app = XCUIApplication()
app.launchArguments = ["--ui-testing", "--reset-ui-testing", "--ui-chat", "-UIPreferredContentSizeCategoryName", "UICTContentSizeCategoryL"]
app.launch()
XCTAssertTrue(app.buttons["Chat options"].waitForExistence(timeout: 10))
app.buttons["Chat options"].tap(); app.buttons["Appearance and model"].tap()
app.buttons.matching(NSPredicate(format: "label CONTAINS 'Appearance' AND label CONTAINS 'System'")).firstMatch.tap(); app.buttons["Dark"].tap()
app.buttons["Save appearance"].tap(); app.buttons["Done"].tap()
capture(app, "Native chat list dark standard")
app.buttons["New chat"].tap()
let field = app.textFields["chat-draft"]
XCTAssertTrue(field.waitForExistence(timeout: 10)); field.tap(); field.typeText("Dark mode fixture")
app.buttons["Send message"].tap()
XCTAssertTrue(app.staticTexts["Offline reply"].waitForExistence(timeout: 10))
app.swipeDown()
capture(app, "Native chat dark standard")
}
func testChatLargeTextAndPreferences() {
let app = XCUIApplication()
app.launchArguments = ["--ui-testing", "--reset-ui-testing", "--ui-chat", "-UIPreferredContentSizeCategoryName", "UICTContentSizeCategoryAccessibilityXXXL"]
app.launch()
XCTAssertTrue(app.buttons["New chat"].waitForExistence(timeout: 10))
app.buttons["Chat options"].tap()
app.buttons["Appearance and model"].tap()
XCTAssertTrue(app.navigationBars["Chat preferences"].waitForExistence(timeout: 10))
XCTAssertTrue(app.buttons["Save appearance"].exists)
capture(app, "Appearance accessibility size")
app.buttons["Done"].tap()
app.tabBars.buttons["Talk"].tap()
XCTAssertTrue(app.buttons["Start chatting"].waitForExistence(timeout: 10))
app.tabBars.buttons["Activity"].tap()
XCTAssertTrue(app.staticTexts["No tasks yet"].waitForExistence(timeout: 15))
}
}
+45 -20
View File
@@ -11,6 +11,25 @@ final class VoiceUITests: XCTestCase {
app.launchArguments = ["--ui-testing", "--reset-ui-testing"]
}
func testLogoutDoesNotLeakPriorAccountTranscriptOrTasks() {
app.launchArguments.append("--ui-fixtures")
app.launch()
XCTAssertTrue(app.staticTexts["Help me plan a quiet weekend."].waitForExistence(timeout: 10))
app.buttons["Account"].tap()
app.buttons["Sign out"].tap()
XCTAssertTrue(app.secureTextFields["Password"].waitForExistence(timeout: 10))
let username = app.textFields["Username"]
username.tap()
username.typeText("second-user")
app.secureTextFields["Password"].tap()
app.secureTextFields["Password"].typeText("offline-only")
app.buttons["Sign in"].tap()
XCTAssertTrue(app.buttons["Start chatting"].waitForExistence(timeout: 10))
XCTAssertFalse(app.staticTexts["Help me plan a quiet weekend."].exists)
app.tabBars.buttons["Activity"].tap()
XCTAssertFalse(app.staticTexts["Search backend timed out after 30s"].exists)
}
func testWelcomeNavigationAndOfflineRecovery() {
app.launch()
XCTAssertTrue(app.buttons["Start chatting"].waitForExistence(timeout: 10))
@@ -38,31 +57,20 @@ final class VoiceUITests: XCTestCase {
server.tap()
let original = server.value as? String ?? ""
server.typeText(String(repeating: XCUIKeyboardKey.delete.rawValue, count: original.count) + "http://example.com")
app.buttons["Save server address"].tap()
app.buttons["Change server"].tap()
XCTAssertTrue(app.staticTexts.matching(NSPredicate(format: "label CONTAINS 'must use https'")).firstMatch.waitForExistence(timeout: 5))
let invalid = server.value as? String ?? ""
server.tap()
server.typeText(String(repeating: XCUIKeyboardKey.delete.rawValue, count: invalid.count) + " https://127.0.0.1:1 ")
app.buttons["Save server address"].tap()
XCTAssertEqual(server.value as? String, "https://127.0.0.1:1")
XCTAssertTrue(app.staticTexts["Server address saved."].waitForExistence(timeout: 5))
app.buttons["Change server"].tap()
// A validated address presents an explicit account-switch action rather
// than silently replacing the active authenticated server.
let confirm = app.buttons["Change server and sign out"]
XCTAssertTrue(confirm.waitForExistence(timeout: 5))
// Do not apply the destructive action in this fixture: the assertion
// above proves a validated change cannot bypass the confirmation gate.
let unchanged = server.value as? String ?? ""
server.tap()
server.typeText(String(repeating: XCUIKeyboardKey.delete.rawValue, count: unchanged.count) + " https://127.0.0.1:2 ")
app.buttons["Save server address"].tap()
Thread.sleep(forTimeInterval: 2)
XCTAssertTrue(app.navigationBars["Settings"].exists)
XCTAssertTrue(app.staticTexts["Server address saved."].exists)
XCTAssertEqual(server.value as? String, "https://127.0.0.1:2")
app.terminate()
app.launchArguments = ["--ui-testing"]
app.launch()
app.buttons["Settings"].tap()
XCTAssertTrue(app.buttons.matching(NSPredicate(format: "label CONTAINS 'Speaking voice' AND label CONTAINS 'Jenny'")).firstMatch.waitForExistence(timeout: 5))
XCTAssertEqual(app.textFields["Server URL"].value as? String, "https://127.0.0.1:2")
}
@@ -108,12 +116,29 @@ final class VoiceUITests: XCTestCase {
XCTAssertTrue(app.buttons["Start chatting"].waitForExistence(timeout: 10))
XCTAssertTrue(app.buttons["Start chatting"].isHittable)
app.tabBars.buttons["Activity"].tap()
XCTAssertTrue(app.staticTexts["No tasks yet"].waitForExistence(timeout: 5))
XCTAssertTrue(app.staticTexts["No tasks yet"].waitForExistence(timeout: 15))
app.buttons["Settings"].tap()
XCTAssertTrue(app.navigationBars["Settings"].exists)
capture("Accessibility text size")
}
func testOpalPromptPreparesEditableDraftWithoutConnecting() {
app.launch()
XCTAssertTrue(app.staticTexts["A little room for you,"].waitForExistence(timeout: 10))
app.buttons["Explore an idea"].tap()
let draft = app.textFields["Message Hope"]
XCTAssertTrue(draft.waitForExistence(timeout: 5))
XCTAssertEqual(draft.value as? String, "Help me explore an idea.")
draft.tap()
draft.typeText(String(repeating: XCUIKeyboardKey.delete.rawValue, count: "Help me explore an idea.".count))
XCTAssertTrue(draft.exists)
draft.typeText("My own thought")
XCTAssertEqual(draft.value as? String, "My own thought")
XCTAssertTrue(app.buttons["Start chatting"].exists)
XCTAssertTrue(app.staticTexts["Your microphone stays off."].exists)
capture("Opal editable prompt")
}
private func capture(_ name: String) {
let attachment = XCTAttachment(screenshot: app.screenshot())
attachment.name = name
+72
View File
@@ -7,32 +7,48 @@
objects = {
/* Begin PBXBuildFile section */
11BB49F87376061BDAFB9C5D /* ChatUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6B2A30D6F45A8E79CF1C73A /* ChatUITests.swift */; };
1813A1CB0921D04FCAEC92D0 /* TranscriptDedupeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAEF89B2DEEE68F7168A8014 /* TranscriptDedupeTests.swift */; };
1ECD63F10A8EAF33FD2A5B62 /* VoiceApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FED8976B60FBC13BC980FCB /* VoiceApp.swift */; };
22EA06AD769759D2223D515F /* TLSLiveTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA2918A7B29AB6EB033E707C /* TLSLiveTests.swift */; };
28659D009399CA9C4FE57B74 /* ControlChannel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61C71C48230C683D199D6F4B /* ControlChannel.swift */; };
29EF2C8C93F9A4270816D22D /* ChatFeatures.swift in Sources */ = {isa = PBXBuildFile; fileRef = 41286AD7E2621F77B4F7CC85 /* ChatFeatures.swift */; };
2D1633BB6B8C3E7938A46A42 /* AuthTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 262B5A712708421C35ED6AC0 /* AuthTests.swift */; };
302BEC55CA9A10E49C307977 /* ServerConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5683993A1DE677121FFA1F3 /* ServerConfig.swift */; };
36DF44D9A7896F18A064847D /* ConversationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F3FA9F420065D2127B0AEC0 /* ConversationView.swift */; };
4AA89EFF10F261A3FCF8C366 /* ChatExport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14505F4DA9DB1610B2EEB271 /* ChatExport.swift */; };
4B9A3D956FB6CB7F6A231955 /* AuthClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC751BFB00B04C73D19C714 /* AuthClient.swift */; };
57E6476135CE30495E02D723 /* VoiceUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8309AC6885ED7EB0047AE211 /* VoiceUITests.swift */; };
5D37D5933765F80A283DC47B /* ChatStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CBEDBE8C5065D716DAECDE2 /* ChatStore.swift */; };
5E4FBAAB530CA2BEEA013A57 /* ChatModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96CFD3A353D5B5E4A97D263F /* ChatModels.swift */; };
64291BBC8D0DAD47E07E94E0 /* ConversationState.swift in Sources */ = {isa = PBXBuildFile; fileRef = A0C23305A6C55A83F8617D9D /* ConversationState.swift */; };
6488E54A6D93278C04D870E2 /* AuthStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDDA0509C2DF2504AA50CBA9 /* AuthStore.swift */; };
70E4B860147494D9657E8C01 /* VoiceLiveTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F6A697985B36B3A1A3F2AD5 /* VoiceLiveTests.swift */; };
720C26EC163AE46713B02DD8 /* TLSPinning.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9B40076934C28BA4B92D808 /* TLSPinning.swift */; };
7A8CC686530A5920338EDF5A /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 26922A8D6467BD51ED45B63B /* Assets.xcassets */; };
89B47754E5C39418BFD839E6 /* TranscriptHistory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68A45228EF59186CFC219F2E /* TranscriptHistory.swift */; };
8DE1363CC0119B5AF9E4E117 /* ConnectionTestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC4B9073EE8EAB51F8C55760 /* ConnectionTestView.swift */; };
93F179C1E302E4868B9AFC71 /* LoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96278769DD369C629F18E475 /* LoginView.swift */; };
943BCE971A01F220FAE9E250 /* TaskPanelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E85BACA267ADD462306C9920 /* TaskPanelView.swift */; };
9461C0886FA294FDD9742E51 /* TranscriptListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD31F76E1880AC76ED31DD4D /* TranscriptListView.swift */; };
98BA1A0F92DB7B98A8BA7814 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 48F76881B4AE51DEE74511CC /* SettingsView.swift */; };
A4A97EAE381B922B213128F3 /* ChatLiveTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2623CA741424CA9B1B6BDA0 /* ChatLiveTests.swift */; };
A5868238CF4958D6D0C5908E /* TaskStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0B60FBBB9C47BB9ADACA7CA /* TaskStore.swift */; };
A66BC894BEEC870D879F991F /* LightwellView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 87DD77AA383E1ECCEEEFB399 /* LightwellView.swift */; };
A6F527DF9D7F76FF5B4D71D7 /* HopeTheme.generated.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7C22C8FB52C3963186CC108 /* HopeTheme.generated.swift */; };
A94F6C509E3FE215115B2C58 /* SettingsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D0D153557BB1C756ED04C61 /* SettingsStore.swift */; };
B0266A37FF81F7DF7F288B37 /* ChatTransportTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8F93A6F4B4354976953F69F /* ChatTransportTests.swift */; };
BCAB602C722D74F6C96C5E36 /* ServerReplacementCoordinatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20A82F8996DEA166C94A7956 /* ServerReplacementCoordinatorTests.swift */; };
C00D1878C2AB3D8B6F9562B6 /* AgentMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FC167F6658CFEC9546A7E9F /* AgentMonitor.swift */; };
C28D28376A22BE3981908AC2 /* ChatTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5B373E4A57214C0D289536B /* ChatTests.swift */; };
CDE38FE9679472D770C601E6 /* VoiceSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB58EEF9490F81FB2E1CE31C /* VoiceSession.swift */; };
D18AD347DFF72AB6361165BA /* TokenClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9681E7410B684D2D38752A70 /* TokenClient.swift */; };
D2E343C8B8657316A993698E /* TranscriptHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = D82D665165A7BC1A4CBCCC6F /* TranscriptHandler.swift */; };
D8F6FDBA1D4C62B62A81885B /* ChatViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CF5F1830927F8D44C4CC89A /* ChatViews.swift */; };
D958BE8EE0FF9568F8FABB11 /* Nearlight.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA6FA2A93116B237CEA712E6 /* Nearlight.swift */; };
D9A0059E342955A06A021D6C /* LiveKit in Frameworks */ = {isa = PBXBuildFile; productRef = A6BAD55715F383C0DB5F306F /* LiveKit */; };
EE0C5762EC97B123A1880FFB /* Voices.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E5DF207EDB787F63CB3A64D /* Voices.swift */; };
F09DD789D07F49C46A659A4F /* KeychainTokenStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 536E3E89F4BB09D631744F3F /* KeychainTokenStore.swift */; };
FC696C6E74057D4C93E6DA67 /* ConversationStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDD0AA8B0E9B45A325D1E5E7 /* ConversationStateTests.swift */; };
/* End PBXBuildFile section */
@@ -55,33 +71,49 @@
/* Begin PBXFileReference section */
0FC167F6658CFEC9546A7E9F /* AgentMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentMonitor.swift; sourceTree = "<group>"; };
14505F4DA9DB1610B2EEB271 /* ChatExport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatExport.swift; sourceTree = "<group>"; };
1CF5F1830927F8D44C4CC89A /* ChatViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatViews.swift; sourceTree = "<group>"; };
20A82F8996DEA166C94A7956 /* ServerReplacementCoordinatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerReplacementCoordinatorTests.swift; sourceTree = "<group>"; };
262B5A712708421C35ED6AC0 /* AuthTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthTests.swift; sourceTree = "<group>"; };
26922A8D6467BD51ED45B63B /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
2A4C3CE2CA6FF828452803D1 /* Voice.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Voice.app; sourceTree = BUILT_PRODUCTS_DIR; };
2E5DF207EDB787F63CB3A64D /* Voices.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Voices.swift; sourceTree = "<group>"; };
2FED8976B60FBC13BC980FCB /* VoiceApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceApp.swift; sourceTree = "<group>"; };
3F3FA9F420065D2127B0AEC0 /* ConversationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationView.swift; sourceTree = "<group>"; };
41286AD7E2621F77B4F7CC85 /* ChatFeatures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatFeatures.swift; sourceTree = "<group>"; };
48F76881B4AE51DEE74511CC /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
536E3E89F4BB09D631744F3F /* KeychainTokenStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainTokenStore.swift; sourceTree = "<group>"; };
5759EB570DB87BBC121D946D /* VoiceUITests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = VoiceUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
5F6A697985B36B3A1A3F2AD5 /* VoiceLiveTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceLiveTests.swift; sourceTree = "<group>"; };
61C71C48230C683D199D6F4B /* ControlChannel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ControlChannel.swift; sourceTree = "<group>"; };
68A45228EF59186CFC219F2E /* TranscriptHistory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranscriptHistory.swift; sourceTree = "<group>"; };
6CBEDBE8C5065D716DAECDE2 /* ChatStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatStore.swift; sourceTree = "<group>"; };
6D0D153557BB1C756ED04C61 /* SettingsStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsStore.swift; sourceTree = "<group>"; };
8309AC6885ED7EB0047AE211 /* VoiceUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceUITests.swift; sourceTree = "<group>"; };
87DD77AA383E1ECCEEEFB399 /* LightwellView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LightwellView.swift; sourceTree = "<group>"; };
96278769DD369C629F18E475 /* LoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginView.swift; sourceTree = "<group>"; };
9681E7410B684D2D38752A70 /* TokenClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TokenClient.swift; sourceTree = "<group>"; };
96CFD3A353D5B5E4A97D263F /* ChatModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatModels.swift; sourceTree = "<group>"; };
A0C23305A6C55A83F8617D9D /* ConversationState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationState.swift; sourceTree = "<group>"; };
AC4B9073EE8EAB51F8C55760 /* ConnectionTestView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectionTestView.swift; sourceTree = "<group>"; };
B5B373E4A57214C0D289536B /* ChatTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatTests.swift; sourceTree = "<group>"; };
BD31F76E1880AC76ED31DD4D /* TranscriptListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranscriptListView.swift; sourceTree = "<group>"; };
BDD0AA8B0E9B45A325D1E5E7 /* ConversationStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationStateTests.swift; sourceTree = "<group>"; };
C0B60FBBB9C47BB9ADACA7CA /* TaskStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskStore.swift; sourceTree = "<group>"; };
C22975C69240AACD3418091F /* VoiceTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = VoiceTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
C8F93A6F4B4354976953F69F /* ChatTransportTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatTransportTests.swift; sourceTree = "<group>"; };
CA6FA2A93116B237CEA712E6 /* Nearlight.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Nearlight.swift; sourceTree = "<group>"; };
CDDA0509C2DF2504AA50CBA9 /* AuthStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthStore.swift; sourceTree = "<group>"; };
D82D665165A7BC1A4CBCCC6F /* TranscriptHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranscriptHandler.swift; sourceTree = "<group>"; };
D9B40076934C28BA4B92D808 /* TLSPinning.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TLSPinning.swift; sourceTree = "<group>"; };
DAC751BFB00B04C73D19C714 /* AuthClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthClient.swift; sourceTree = "<group>"; };
E6B2A30D6F45A8E79CF1C73A /* ChatUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatUITests.swift; sourceTree = "<group>"; };
E85BACA267ADD462306C9920 /* TaskPanelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskPanelView.swift; sourceTree = "<group>"; };
EA2918A7B29AB6EB033E707C /* TLSLiveTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TLSLiveTests.swift; sourceTree = "<group>"; };
EB58EEF9490F81FB2E1CE31C /* VoiceSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceSession.swift; sourceTree = "<group>"; };
F2623CA741424CA9B1B6BDA0 /* ChatLiveTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatLiveTests.swift; sourceTree = "<group>"; };
F5683993A1DE677121FFA1F3 /* ServerConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerConfig.swift; sourceTree = "<group>"; };
F7C22C8FB52C3963186CC108 /* HopeTheme.generated.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HopeTheme.generated.swift; sourceTree = "<group>"; };
FAEF89B2DEEE68F7168A8014 /* TranscriptDedupeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranscriptDedupeTests.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
@@ -123,6 +155,7 @@
6735DFBC89D9A939C44D763A /* UITests */ = {
isa = PBXGroup;
children = (
E6B2A30D6F45A8E79CF1C73A /* ChatUITests.swift */,
5F6A697985B36B3A1A3F2AD5 /* VoiceLiveTests.swift */,
8309AC6885ED7EB0047AE211 /* VoiceUITests.swift */,
);
@@ -132,6 +165,8 @@
6B2AB34274D9B37E77178C6C /* Networking */ = {
isa = PBXGroup;
children = (
DAC751BFB00B04C73D19C714 /* AuthClient.swift */,
536E3E89F4BB09D631744F3F /* KeychainTokenStore.swift */,
F5683993A1DE677121FFA1F3 /* ServerConfig.swift */,
D9B40076934C28BA4B92D808 /* TLSPinning.swift */,
9681E7410B684D2D38752A70 /* TokenClient.swift */,
@@ -154,6 +189,7 @@
children = (
3F3FA9F420065D2127B0AEC0 /* ConversationView.swift */,
87DD77AA383E1ECCEEEFB399 /* LightwellView.swift */,
96278769DD369C629F18E475 /* LoginView.swift */,
BD31F76E1880AC76ED31DD4D /* TranscriptListView.swift */,
);
path = Conversation;
@@ -169,6 +205,19 @@
path = Features;
sourceTree = "<group>";
};
94F63FF6C1F0CD224010E2F5 /* Chat */ = {
isa = PBXGroup;
children = (
14505F4DA9DB1610B2EEB271 /* ChatExport.swift */,
41286AD7E2621F77B4F7CC85 /* ChatFeatures.swift */,
96CFD3A353D5B5E4A97D263F /* ChatModels.swift */,
6CBEDBE8C5065D716DAECDE2 /* ChatStore.swift */,
1CF5F1830927F8D44C4CC89A /* ChatViews.swift */,
F7C22C8FB52C3963186CC108 /* HopeTheme.generated.swift */,
);
path = Chat;
sourceTree = "<group>";
};
B3D0379BEBE1E5F4AA58DA2D = {
isa = PBXGroup;
children = (
@@ -183,6 +232,7 @@
CE49A38E40AAF659A9281B85 /* App */ = {
isa = PBXGroup;
children = (
CDDA0509C2DF2504AA50CBA9 /* AuthStore.swift */,
CA6FA2A93116B237CEA712E6 /* Nearlight.swift */,
6D0D153557BB1C756ED04C61 /* SettingsStore.swift */,
2FED8976B60FBC13BC980FCB /* VoiceApp.swift */,
@@ -194,8 +244,13 @@
DC8495F2B27E1770E6620A05 /* Tests */ = {
isa = PBXGroup;
children = (
262B5A712708421C35ED6AC0 /* AuthTests.swift */,
F2623CA741424CA9B1B6BDA0 /* ChatLiveTests.swift */,
B5B373E4A57214C0D289536B /* ChatTests.swift */,
C8F93A6F4B4354976953F69F /* ChatTransportTests.swift */,
BDD0AA8B0E9B45A325D1E5E7 /* ConversationStateTests.swift */,
20A82F8996DEA166C94A7956 /* ServerReplacementCoordinatorTests.swift */,
EA2918A7B29AB6EB033E707C /* TLSLiveTests.swift */,
FAEF89B2DEEE68F7168A8014 /* TranscriptDedupeTests.swift */,
);
path = Tests;
@@ -221,6 +276,7 @@
isa = PBXGroup;
children = (
CE49A38E40AAF659A9281B85 /* App */,
94F63FF6C1F0CD224010E2F5 /* Chat */,
8DA8BEF2EE3E0F4181A94EB9 /* Features */,
2820A418F921E18A2FA7E4FB /* LiveKit */,
6B2AB34274D9B37E77178C6C /* Networking */,
@@ -346,11 +402,21 @@
buildActionMask = 2147483647;
files = (
C00D1878C2AB3D8B6F9562B6 /* AgentMonitor.swift in Sources */,
4B9A3D956FB6CB7F6A231955 /* AuthClient.swift in Sources */,
6488E54A6D93278C04D870E2 /* AuthStore.swift in Sources */,
4AA89EFF10F261A3FCF8C366 /* ChatExport.swift in Sources */,
29EF2C8C93F9A4270816D22D /* ChatFeatures.swift in Sources */,
5E4FBAAB530CA2BEEA013A57 /* ChatModels.swift in Sources */,
5D37D5933765F80A283DC47B /* ChatStore.swift in Sources */,
D8F6FDBA1D4C62B62A81885B /* ChatViews.swift in Sources */,
8DE1363CC0119B5AF9E4E117 /* ConnectionTestView.swift in Sources */,
28659D009399CA9C4FE57B74 /* ControlChannel.swift in Sources */,
64291BBC8D0DAD47E07E94E0 /* ConversationState.swift in Sources */,
36DF44D9A7896F18A064847D /* ConversationView.swift in Sources */,
A6F527DF9D7F76FF5B4D71D7 /* HopeTheme.generated.swift in Sources */,
F09DD789D07F49C46A659A4F /* KeychainTokenStore.swift in Sources */,
A66BC894BEEC870D879F991F /* LightwellView.swift in Sources */,
93F179C1E302E4868B9AFC71 /* LoginView.swift in Sources */,
D958BE8EE0FF9568F8FABB11 /* Nearlight.swift in Sources */,
302BEC55CA9A10E49C307977 /* ServerConfig.swift in Sources */,
A94F6C509E3FE215115B2C58 /* SettingsStore.swift in Sources */,
@@ -372,6 +438,7 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
11BB49F87376061BDAFB9C5D /* ChatUITests.swift in Sources */,
70E4B860147494D9657E8C01 /* VoiceLiveTests.swift in Sources */,
57E6476135CE30495E02D723 /* VoiceUITests.swift in Sources */,
);
@@ -381,8 +448,13 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
2D1633BB6B8C3E7938A46A42 /* AuthTests.swift in Sources */,
A4A97EAE381B922B213128F3 /* ChatLiveTests.swift in Sources */,
C28D28376A22BE3981908AC2 /* ChatTests.swift in Sources */,
B0266A37FF81F7DF7F288B37 /* ChatTransportTests.swift in Sources */,
FC696C6E74057D4C93E6DA67 /* ConversationStateTests.swift in Sources */,
BCAB602C722D74F6C96C5E36 /* ServerReplacementCoordinatorTests.swift in Sources */,
22EA06AD769759D2223D515F /* TLSLiveTests.swift in Sources */,
1813A1CB0921D04FCAEC92D0 /* TranscriptDedupeTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
@@ -0,0 +1,141 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1430"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
runPostActionsOnFailure = "NO">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "NO"
buildForProfiling = "NO"
buildForArchiving = "NO"
buildForAnalyzing = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "1385EEF29189F331F2634382"
BuildableName = "VoiceTests.xctest"
BlueprintName = "VoiceTests_iOS"
ReferencedContainer = "container:Voice.xcodeproj">
</BuildableReference>
</BuildActionEntry>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "NO"
buildForProfiling = "NO"
buildForArchiving = "NO"
buildForAnalyzing = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "1C8CC2859E99A4AC83FC6374"
BuildableName = "VoiceUITests.xctest"
BlueprintName = "VoiceUITests"
ReferencedContainer = "container:Voice.xcodeproj">
</BuildableReference>
</BuildActionEntry>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "57E3A292BDE525A22EB7ADE4"
BuildableName = "Voice.app"
BlueprintName = "Voice_iOS"
ReferencedContainer = "container:Voice.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
onlyGenerateCoverageForSpecifiedTargets = "NO">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "57E3A292BDE525A22EB7ADE4"
BuildableName = "Voice.app"
BlueprintName = "Voice_iOS"
ReferencedContainer = "container:Voice.xcodeproj">
</BuildableReference>
</MacroExpansion>
<Testables>
<TestableReference
skipped = "NO"
parallelizable = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "1385EEF29189F331F2634382"
BuildableName = "VoiceTests.xctest"
BlueprintName = "VoiceTests_iOS"
ReferencedContainer = "container:Voice.xcodeproj">
</BuildableReference>
</TestableReference>
<TestableReference
skipped = "NO"
parallelizable = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "1C8CC2859E99A4AC83FC6374"
BuildableName = "VoiceUITests.xctest"
BlueprintName = "VoiceUITests"
ReferencedContainer = "container:Voice.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
<CommandLineArguments>
</CommandLineArguments>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "57E3A292BDE525A22EB7ADE4"
BuildableName = "Voice.app"
BlueprintName = "Voice_iOS"
ReferencedContainer = "container:Voice.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "57E3A292BDE525A22EB7ADE4"
BuildableName = "Voice.app"
BlueprintName = "Voice_iOS"
ReferencedContainer = "container:Voice.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
+46
View File
@@ -4,6 +4,52 @@ Updated 2026-09-19 from repository source, recorded test evidence, and prior deb
This is guidance, not a claim that hardware or server checks were rerun today.
Read [PROJECT_STATUS.md](PROJECT_STATUS.md) for the current product scope.
## Authorized accounts and transport — 2026-10-01
- The latest direct request makes Hermes server-managed; do not restore model
selectors or `set_model` from old handoffs. Server account auth is distinct from
the previously canceled ChatGPT subscription/device login.
- Scope credentials to canonical HTTPS origin, and transcripts to origin + immutable
user id. Never import the global legacy transcript into an authenticated account.
- Disable bootstrap immediately on logout, invalidate/cancel pending authentication,
then disconnect before clearing account UI. New account identity resets drafts and
task stores; reject stale room transcription/control callbacks during teardown.
- Check generation and cancellation immediately after token fetch and before
`room.connect`. Serialize normal mic actions with interruption actions and recheck
Type mode after awaits; pending Voice requests cannot enable Type-mode capture.
- Private IP does not justify plaintext signaling. Use same-origin URLComponents WSS
(IPv6 safe). Exact HTTP certificate pinning does not establish the SDK's separate
WSS trust. Installed trusted CA and real hardware qualification may be required;
never introduce trust-all or plaintext fallback to bypass it.
- A transient `/me` outage is not revocation: retain its Keychain bearer but show
no unverified account UI. Only 401/403 clears saved authentication. Authentication
URLSession is ephemeral with cookies/cache/credential persistence disabled and
redirects rejected to protect password/bearer origin confinement.
- Never log data-channel payloads or bootstrap response bodies. Keep release SDK
logging at error. Offline auth fixtures use memory tokens, isolated preferences
and deterministic no-network bootstrap errors.
## Native certificate diagnosis and square presence — 2026-10-01
- Match actual leaf and SPKI before changing a pin. Matching source pins do not
prove every SDK session uses the delegate; LiveKit owns a private system-trust
URLSession for WSS. App HTTPS probe passed physically; do not conflate a401 from
a deleted account with TLS failure or recreate unwanted test accounts.
- Gate live TLS tests with explicit opt-in and print started/skipped scope; a test
returning early is not runtime proof. Regenerate XcodeGen for added test membership.
Physical test bundles require a development team override, not just app signing.
- Proven device ATS trap: `NSPinnedCAIdentities` can still reject a manually
full-trusted self-signed server with -1202/-9807. Same-phone A/B removal of this
redundant rule made system HTTPS and actual LiveKit WSS pass. Retain exact app
HTTP pin + normal SDK system trust; do not infer validity-length cause from dates.
- Certificate profiles require public-only verified data, user installation/full
trust, and persistent hosting. Copying a file into an active container is erased
at redeploy. Exact nginx alias may expose a public profile from cert mount; never
expose the directory containing the private key. Use apple profile MIME/no-store.
- Canvas dot matrix is one accessible avatar, not441 focusable LEDs. Pause animation
when inactive and freeze audio geometry with Reduce Motion. Primary square remains
centered while active size/dynamic type leave space for transcript and controls.
## Preserve behavior across UI changes
- Observe nested observable objects directly where their data is rendered. Watching
+256 -1
View File
@@ -1,11 +1,258 @@
# Project status and handoff
Last updated: 2026-09-19 (documentation review; no new runtime qualification).
Last updated: 2026-10-03 (native chat offline qualification; no live chat/deployment qualification).
Reusable implementation, testing, device and deployment lessons are in
[ENGINEERING_LEARNINGS.md](ENGINEERING_LEARNINGS.md). September 12 test/install
results below are historical evidence, not a September 19 health check.
## Unified native Hope chat — 2026-10-03
The explicit unified-parity request supersedes historical no-model-picker notes.
Native **Chats** is now the default authenticated shell, alongside unchanged Talk
and Activity access. HTTP text chat uses the same verified account/bearer and
pinned, ephemeral, redirect-denying networking; it requires neither LiveKit nor
microphone capture. Durable server conversations, search, paging, workspaces,
pins, archive, move, rename and confirmed delete are supported. Branch/edit/
regenerate preserve original history. SSE full-message replacements deduplicate
monotonic replay IDs, expose progress/approval/stop and explicit resume after
interruption. Foreground metadata sync is account-scoped, five-second, cancels on
background/logout, and preserves drafts/read position. Text selection, fenced-code
copy, headings/lists/links use native rendering (no remote WebView).
The backend-generated semantic Opal/Graphite/Ocean/Sage/Lavender artifact is
consumed by native chat; appearance and palette persist through authenticated
preferences. Model/thinking controls use only `/api/settings/model`, never a
direct upstream connection. Chat → Talk binds `/token` to the selected durable
conversation after existing room teardown; nil legacy bootstrap stays supported.
Keychain origin scope, auth verification/logout sequencing, exact TLS pins and
existing voice lifecycle invariants were not weakened.
Advanced UI follows the new `CHAT_FEATURES_CONTRACT`: capability-gated private
Files/Photos uploads (8 MiB client cap; up to eight selected IDs), in-memory
text/image/PDF preview, artifacts with revision edit/history/text share, scheduled
task create/edit/pause/resume/run/delete and revision-aware memory controls.
Uploads do not automatically send. Task execution is server-owned; there is no
APNs/push or task output history claim. Images/research show only after explicit
server qualification. Memory changes may require a later Hermes session.
Connectors and fabricated integration switches are not shown. Advanced data,
previews/drafts, replay tasks and exports clear during account teardown.
Offline verification: initial 66 unit + 2 chat UI passed; expanded 67 unit + 10 UI
(7 existing voice + 3 chat) passed at `/tmp/hope-chat-final-tests.xcresult`.
Extra HTTP transport coverage then passed 69 unit + 3 chat UI at
`/tmp/hope-chat-qualified-tests.xcresult`. An earlier artifact UI run failed an
exact row label assertion (native label includes revision/kind); predicate fixed,
failed evidence retained at `/tmp/hope-chat-advanced-tests.xcresult`.
Final current-source run passed **70 unit + 4 chat UI tests**, zero failures,
`/tmp/hope-chat-final-qualified.xcresult` and `/tmp/hope-chat-final-qualified.log`.
Retained Chat, Workspaces, Appearance (accessibility XXXL), and Artifact screenshots
were exported to `/tmp/hope-chat-screenshots/` and visually inspected. Screens use
native navigation/forms and readable Opal tint; appearance title truncates at XXXL
as standard large-title behavior, controls remain reachable/scrollable.
Signed generic iPhone build passed at `/tmp/hope-chat-phone-build.log`, artifact
`/tmp/hope-chat-phone-build/Build/Products/Debug-iphoneos/Voice.app`. Final current
source signed build passed again at `/tmp/hope-chat-phone-final-build.log`. No installation,
production requests, backend deployment or physical speech qualification occurred.
Opt-in live TLS cases return without probes in offline suite and are not TLS proof.
Native follow-up: named `.md`/`.json` conversation and `.md`/`.txt` artifact
Transferable file exports preserve bytes and keep no persistent private cache;
exports clear on conversation switch/logout. Basic markdown tables render selectable
native rows. Polling uses weak account lifetime, teardown/deinit cancellation, and
background stream cancellation with foreground replay. Explicit standard-size UI
fixtures now distinguish default screenshots from XXXL accessibility fixtures.
Follow-up **70 unit + 5 chat UI passed** at `/tmp/hope-chat-polish-final.xcresult`;
signed current build passed `/tmp/hope-chat-phone-export-final.log`. Standard
light/dark chat and dark list screenshots exported to
`/tmp/hope-chat-standard-screenshots/` and inspected. Conversation captures retain
the active keyboard; dark list capture shows full shell. Initial new dark UI test
failed the combined native picker label and was corrected; failed bundle retained
`/tmp/hope-chat-polish-tests.xcresult`. No hardware installation or live request.
Physical follow-up — 2026-10-03: parent reported installation and ordinary launch
of the signed app succeeded around 12:04–12:05. Added `ChatLiveTests`, deliberately
opt-in with protected environment credentials; offline simulator compile/test
explicitly **skipped one live test**, zero failures, at
`/tmp/hope-chat-live-optin-compile.log`. One authorized physical HTTP attempt used
native pinned login and a disposable chat (no memory/preferences mutations).
It **failed**, not passed: `/tmp/hope-chat-physical-qualified.xcresult` reports
one failed test, zero passed, "test runner exited with code 0 before finishing";
outer xcodebuild exited 65. Evidence `/tmp/hope-chat-physical-qualified.log` and
`/tmp/hope-chat-physical-summary.json`. App-host logs also show interactive voice
connections and microphone enable while the HTTP probe was running; the probe
itself calls no voice API. Concurrent user interaction is possible, not proven
causal. Do not claim no audio captured or successful physical chat qualification.
Cleanup after host exit is unconfirmed; parent should inspect only admin-owned
chat titled **Native qualification · disposable**, never unrelated user chats.
Further native live attempts stopped at parent's request while user uses voice.
No test-host UI guard was retained and no subsequent device action performed.
## English Dragon HD Omni voice picker — 2026-10-03
User reported missing new voices on iOS. Root cause: native picker used a fixed
12-voice list, and SettingsStore rejected persisted IDs outside that list.
Picker now loads authenticated same-origin `GET /voices` through existing pinned,
redirect-rejecting networking, supports name/locale/gender/model search, and shows
retry/failure state. Ava Omni remains default; fallback personas now use Omni.
Dynamic catalog IDs persist across relaunch; legacy Dragon HD IDs migrate to Omni.
Offline UI fixtures do not call the NAS. No login, TLS, microphone or account
storage behavior changed. Requires backend `/voices` route from voice-api commit
`6b8d55f`; real regional synthesis remains unqualified.
Final simulator unit run passed (60 tests including opt-in TLS cases returning
without live probes), `/tmp/hope-omni-ios-final-test.log`. Signed iPhone build passed,
`/tmp/hope-omni-phone-final.log`. Initial installation failed with CoreDevice
connection reset; fresh device check showed paired/available, but the single retry
was denied because the iPhone was locked (MobileImageMounterDeviceLocked). After
user unlocked it, installation and launch succeeded on the paired iPhone 14 Pro Max
at 08:00 local (dev.shane.voice). No live catalog/audio verification implied.
No credentials/preferences reset. No live voice/audio qualification claim.
## Approved Opal pink redesign — 2026-10-02
User approved the Opal HTML direction with the existing square reactive persona.
Native implementation now uses luminous pink-white surfaces, readable plum text,
rose actions (#A64270), an account-derived personalized greeting, 184pt welcome /
112pt active Canvas presence, pastel starter prompts, and an inset floating dock.
The approved Opal overlapping-speech icon replaces the old glowing capsule.
Light/dark semantic roles, Dynamic Type and Reduce Motion remain supported.
Sign out moved into the Account menu; authentication, TLS, session lifetime,
account-scoped history, draft retention, transcript follow-latest behavior and
voice/text readiness gates are unchanged. Starter prompts prepare editable text
only: they never send automatically or enable microphone capture.
Verification: simulator and signed generic-iPhone builds passed. Full offline run
passed **59 unit + 7 UI tests**, `/tmp/hope-opal-offline.xcresult`; three explicitly
opt-in TLS unit cases returned without live probes, not runtime TLS evidence.
Independent review identified editor disappearance on clearing a prompt; corrected
by keeping the Type draft editor mounted even when empty. Final focused draft-edit /
offline recovery run passed **2 UI tests**, `/tmp/hope-opal-draft-final.xcresult`.
Final pink idle-persona tint change rebuilt and **59 unit tests passed** again,
`/tmp/hope-opal-final-unit.log`. Native light/dark simulator screenshots inspected;
light screenshot `/Users/shane/dev/hope-voice-api/design-options/opal-native-light.png`.
Initial physical install reset its transport, then retry was explicitly denied
because the iPhone was locked. After the user unlocked it, final signed build,
installation and launch **succeeded** on the paired iPhone 14 Pro Max at 13:27 local.
Artifact `/tmp/hope-opal-phone-build/Build/Products/Debug-iphoneos/Voice.app`, final
build log `/tmp/hope-opal-phone-install.log`. App data/credentials were preserved.
No backend, account data, saved credentials or production preferences were reset.
Real microphone capture, audible replies and interruptions were not requalified
by this UI work; successful installation is not a live voice-session claim.
## Authorized Hermes migration — 2026-10-01
The direct user request supersedes the historical Qwen/client-model direction below.
This repository now implements server-account login (`/api/auth/login`), bearer
verification (`/api/auth/me`), logout (`/api/auth/logout`), and authorized `/token`
bootstrap. Hermes is server-managed: model settings, pickers and control messages
were removed. No backend source, deployment, live endpoint or secrets were touched
in this iOS implementation.
- Bearers live in device-only, unlocked Keychain items scoped to canonical HTTPS
origin; passwords are never persisted. Login accepts numeric Unix expiry and
ISO8601 expiry, and ready/provisioning accounts.
- Logout and origin replacement invalidate auth work, cancel bootstrap and
disconnect transport before removing the account UI. Each account/root gets
fresh drafts/tasks and history scoped to origin + immutable user id; the global
legacy `voice_history` is never imported.
- Signaling is same-origin WSS `/livekit` for all hosts, including IPv6/private
addresses. Userinfo/query/fragment origins are rejected. There is no plaintext
fallback and no trust-all delegate. HTTP retains the existing exact LAN pin;
**the LiveKit SDK's separate WSS trust may require an installed trusted CA**.
Self-signed WSS, actual login, multi-user server isolation, microphone capture,
audible speech and interruptions require subsequent user hardware qualification.
- `assistant-progress` working/completed/failed events display request activity
without polluting eventual normal transcripts. Payload logging is removed; SDK
log level is error rather than release debug.
- Offline UI uses fake auth and memory token storage plus isolated preferences;
bootstrap fixtures cannot contact a remote server. Live suite is excluded from
this validation. `project.yml` generates sources and an explicit test scheme.
Verification: simulator build passed. Initial offline unit run: **50 passed, 0
failed** (5 new focused authentication tests), `/tmp/hermes-ios-unit2.xcresult`.
A scheme-less first test attempt failed before running tests; adding the generated
scheme fixed it. Combined offline run: **51 unit + 5 UI passed, 0 failures** at
`/tmp/hermes-ios-offline-final.xcresult`. Expanded run added the logout/account-switch
privacy UI test, which passed; one existing URL normalization assertion raced the
asynchronous save render and failed. It now waits for the saved confirmation before
asserting the normalized value. A subsequent accessibility-size Activity empty-state
assertion timed out after 5 seconds; its wait now allows 15 seconds. Both failed-run
bundles are retained (`/tmp/hermes-ios-offline-qualified.xcresult` and
`/tmp/hermes-ios-offline-release.xcresult`). Final focused re-verification:
**51 unit + 3 UI passed, 0 failures**, including accessibility-size navigation,
settings persistence/normalization and logout/account-switch transcript/task privacy,
`/tmp/hermes-ios-focused-final.xcresult`. Remaining three offline UI cases passed in
both expanded runs. Parent security review identified transient `/me` outages clearing
Keychain prematurely; fixed to clear only 401/403 and added a regression test.
Final source: **52 unit passed, 0 failures**, `/tmp/hermes-ios-unit-final.xcresult`.
All six offline UI cases have passed across the runs described above; the final
three focused cases passed together. No claim of one fully green expanded six-UI
run is made. These are client-only simulator claims, not live/deployment/hardware
qualification.
## Physical iPhone deployment — 2026-10-01
At the user's request, compiled the current Hermes account app with Xcode 26.4
using automatic development signing (`Voice_iOS`, Debug, generic iOS destination).
Build succeeded; artifact `/tmp/hope-voice-phone-build/Build/Products/Debug-iphoneos/Voice.app`,
build log `/tmp/hope-voice-phone-build.log`. Installed bundle `dev.shane.voice`
on the connected, paired iPhone 14 Pro Max (iOS 26.7.1, Developer Mode enabled).
Installation reported success. The first automatic launch was denied because the
iPhone was locked (`FBSOpenApplicationErrorDomain: Locked`). After the user unlocked
it, `devicectl device process launch` succeeded and launched `dev.shane.voice`.
No credentials/preferences were reset, no backend changes, and no physical speech
or authenticated WSS qualification is implied by successful installation.
## Square presence and certificate onboarding — 2026-10-01
User reported invalid server certificate at sign-in and requested the webapp square
presence as the primary native avatar. Replaced Lightwell rendering with an efficient
21×21 Canvas LED matrix, centered 220pt on welcome and 136pt above active transcript;
smaller accessibility sizes preserve controls/readability. Mic/listening and agent
speech levels drive separate patterns; Reduce Motion freezes motion/audio geometry.
Existing semantic theme and single accessible status are preserved.
Actual deployed leaf and SPKI pins match the app. Explicit live native HTTPS probe
passed on both simulator and physical iPhone against `/api/auth/me` (expected401,
no credentials). Physical native password POST reached server and returned401 for
John Doe; user confirmed deleting this test account, so no account was recreated.
No TLS bypass, plaintext fallback, or pin change was made. The reported original
login failure was not reproduced by this test; installed-version or transient cause
is not conclusively established. Admin full-auth test was interrupted by lock/lost
testmanager connection, not claimed successful.
LiveKit's SDK uses an independent system-trust WebSocket session. User approved a
public certificate-only installation profile, exact leaf SHA256
`07907abde3bb9c685265909927627d9ea4705d7e7b27056eefe06e10b192af46`.
Profile is served at `https://192.168.86.2:9178/hope-server.mobileconfig`, requires
manual installation and full trust in iPhone Settings. App login links it only
for the exact configured LAN host/port, and certificate errors include safe recovery.
An initial container-copy download disappeared after another backend deployment;
fixed by persistent cert mount and exact nginx alias; verified200/apple profile MIME,
private-key path404. Nothing installs trust automatically or weakens TLS checks.
Validation: 19 conversation tests passed (3 new LED tests), simulator screenshots
`/tmp/hope-led-welcome-ready.png` and `/tmp/hope-led-history-dark.png`. Complete
unit suite:58 passed, including two explicit-opt-in live TLS cases skipped in offline
run. Actual opted-in physical HTTPS test passed separately. New live tests use
environment-only credentials, never fixtures/source/argv. Updated signed phone
build passed (`/tmp/hope-square-phone-build.log`) and app installed/launch succeeded
on the iPhone14ProMax (`dev.shane.voice`, 18:00 local). Physical speech remains user qualification.
Follow-up: user installed profile/full trust but SDK still reported validation cert
invalid. Reproduced physically: exact pinned HTTPS passed; system URLSession HTTPS
failed -1202/-9807. Controlled A/B removed redundant `NSPinnedCAIdentities` in
XcodeGen (normal iOS trust, HTTPS/WSS and exact HTTP leaf pin remain unchanged).
System trust then passed on same phone, establishing ATS CA-only rule conflict,
not proven certificate validity-length rejection. Final physical native live test
**3 passed**: pinned HTTPS, system-trusted HTTPS, and admin login/me/bootstrap →
actual LiveKit SDK authenticated WSS connect/disconnect → logout; no microphone
enabled. Evidence `/tmp/hope-phone-wss-qualified.log`. John Doe remains deleted.
Signed corrected build passed `/tmp/hope-square-tls-fixed-build.log`.
## Current direction
Keep **Qwen 2.5**. The user explicitly canceled the earlier request to switch to
@@ -143,6 +390,14 @@ Project `Voice.xcodeproj`, scheme `Voice_iOS`, iPhone 17 Pro simulator
Exact historical qualifications: [VERIFICATION.md](VERIFICATION.md). Artifacts
live on this Mac under the XcodeBuildMCP workspace, not in Git.
## UX reliability refinement — 2026-10-03
Implemented the approved native UX refinement without changing backend contracts: Chats now shows accurate search/workspace/archive empty states and scope, clearer author hierarchy, named selected attachments, and a workspace editor that preserves its draft and reports an in-place failure. Private file preview clears stale data before a new request, identifies the file, and renders loading/error/retry states; unsupported Library accounts no longer appear to load indefinitely. Voice selection now reports a failed live application without overwriting the previous selection. Login preserves an in-memory password only until successful authentication, makes server save feedback explicit, and settings requires a confirmed account-switch warning before changing server. Connection diagnostics now identify token/session setup scope rather than claiming voice qualification.
Voice preference migration normalizes every legacy fallback persona to Dragon HD Omni while preserving safe dynamic English catalog IDs across relaunch. Added offline regressions for workspace result semantics, stale preview cleanup, static and dynamic voice migration. Sol code review initially found confirmation wiring, async-test compilation, fallback migration, and dynamic catalog persistence blockers; all were fixed before final test/build evidence below. Native iPhone build is signed but not installed in this refinement.
Verification scoped to current source: **60 unit tests passed** with `/tmp/hope-ios-redesign-derived/Logs/Test/Test-Voice_iOS-2026.10.03_18-46-50--0400.xcresult`; final confirmation-flow UI regression passed at `/tmp/hope-ios-redesign-ui-derived/Logs/Test/Test-Voice_iOS-2026.10.03_18-54-51--0400.xcresult`. An earlier complete offline run executed all existing UI tests but predated the final confirmation-test stabilization. A signed generic iPhone Debug build passed at `/tmp/hope-ios-redesign-phone/Build/Products/Debug-iphoneos/Voice.app`, log `/tmp/hope-ios-redesign-phone-build.log`. No production request, live voice test, device installation, or physical audio claim is made by this refinement.
## What remains, in order
1. **Qualify real audio:** microphone capture, TTS audibility, speaker/headphones,
+12 -6
View File
@@ -1,4 +1,15 @@
name: Voice
schemes:
Voice_iOS:
build:
targets:
Voice_iOS: all
VoiceTests_iOS: [test]
VoiceUITests: [test]
test:
targets:
- VoiceTests_iOS
- VoiceUITests
options:
bundleIdPrefix: dev.shane
deploymentTarget:
@@ -22,16 +33,11 @@ targets:
info:
path: Resources/Info.plist
properties:
CFBundleDisplayName: Hope Voice
CFBundleDisplayName: Hope
UILaunchScreen: {}
NSMicrophoneUsageDescription: "Hope Voice uses the microphone for live voice conversation."
NSAppTransportSecurity:
NSAllowsLocalNetworking: true
NSPinnedDomains:
192.168.86.2:
NSIncludesSubdomains: true
NSPinnedCAIdentities:
- SPKI-SHA256-BASE64: vmrfRuHv1oHBBiEmJP4WX3C7mtVy3C4+UJ1Yk/Q5hMU=
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: dev.shane.voice