Compare commits

..
25 Commits
Author SHA1 Message Date
snowspeeder 7bd893ae5c feat: refine native iOS chat experience 2026-10-03 18:55:28 -04:00
snowspeeder 9c5e9bce5b test: add opt-in native chat transport qualification 2026-10-03 12:17:35 -04:00
snowspeeder ec510b938b fix: share private chat files and harden native lifecycle 2026-10-03 09:16:56 -04:00
snowspeeder 7dcd06c911 feat: add authenticated durable native Hope chat 2026-10-03 09:06:06 -04:00
snowspeeder 74d2b63614 feat: apply pink Opal design with reactive square persona 2026-10-02 13:28:10 -04:00
snowspeeder 5b41b3924a fix: remove conflicting ATS CA pin and verify physical LiveKit trust 2026-10-01 18:09:25 -04:00
snowspeeder 15c6389ffb feat: center square Hope presence and guide secure certificate setup 2026-10-01 18:02:14 -04:00
snowspeeder 6f2659421c docs: record signed iPhone installation and successful launch 2026-10-01 15:22:00 -04:00
snowspeeder bebaf59466 feat: authorize iOS Hermes accounts and secure session isolation 2026-10-01 12:57:57 -04:00
snowspeeder 9bdaea2408 Document engineering learnings and refresh handoff guidance 2026-09-19 14:40:24 -04:00
snowspeeder 85169d651c project: regenerate to include asset catalog resource phase 2026-09-12 17:27:00 -04:00
snowspeeder 7ac8a1c2fa brand: add Hope app icon (Lightwell aperture)
- 1024px icon from web/brand/hope-icon.svg (forced-dark, grid-tuned)
- fix xcodegen: xcassets was never in the generated project (no
  resources phase) — move it into sources with buildPhase: resources
- fix iconset manifest: idiom universal (universal-platform rejected
  by actool), single 1024 universal/ios entry
2026-09-12 17:26:57 -04:00
snowspeeder 59d8182b6c docs: record final device install 2026-09-12 17:13:27 -04:00
snowspeeder b7cec80a99 fix: close Nearlight release blockers 2026-09-12 10:13:14 -04:00
snowspeeder 5577162594 Reapply N2 fix: disable Send until transport ready (lost in prior commit) 2026-09-12 08:38:08 -04:00
snowspeeder f6722a223f Fix regressions N1-N3 from re-verification
- Transcript: selection suspension clears when press ends; Latest
  re-enables follow fully (clears stale selection state)
- Send disabled until transport ready in pending Type composer
- Server URL: programmatic success-trim no longer erases its own
  saved-confirmation; only user edits clear it
2026-09-12 08:36:01 -04:00
snowspeeder afe37ad5f4 Fix reading-safety and accessibility majors from review
- Transcript: drag suspends auto-follow; long-press selection suspends
  following; Latest is a trailing outlined capsule that re-enables follow
- Lightwell: pulse via isolated TimelineView (2.4s triangle wave), pauses
  when offscreen; Reduce Motion uses static geometry (no level resizing)
- Increase Contrast: solid Rosewood lightwell fill, 2pt Rosewood selected
  outlines on mode buttons
- Haptics: success only on entering ready, error only on entering failure
2026-09-12 08:27:48 -04:00
snowspeeder f043d6b078 Fix major Nearlight deviations from design review
- Expose End conversation + readable draft during connecting/waiting
- Stop masking assistant speaking/thinking state in Type/muted modes
- Restrict failure diagnosis to real causes (no invented network text)
- Replace old blue tint with Rosewood everywhere
- Fix transcript short-content bottom-anchor (top-align short histories)
- Settings: visible Server URL label, focus on rejected save, edit clears
  saved confirmation, trim-aware unchanged detection
- Task detail: live store lookup with missing-task state
- Task rows: status text label restored
2026-09-12 08:20:43 -04:00
snowspeeder fa21c81787 wip: Nearlight redesign implementation 2026-09-12 08:14:06 -04:00
snowspeeder c76afbd11e docs: record verified device pairing, signing, install 2026-09-12 07:17:55 -04:00
snowspeeder ea9ec75631 Configure development team for device signing 2026-09-12 07:10:41 -04:00
snowspeeder c5db94658d Restore same-URL guard in replaceServer
Accidentally dropped during the Music tab removal edit; without it,
reapplying the current URL recreates the session and loses history.
2026-09-12 06:57:32 -04:00
snowspeeder ed329213de Remove embedded music player
The music feature is retired: delete the Media tab, MediaAPI,
MediaPlayerController, PinnedMediaResourceLoader, MediaPanelView, the
media_state control publish, media scope handling in VoiceSession, the
volume setting, and all media tests. App is now Talk + Activity tabs.

Backend media API in hope-voice-api is untouched (still serves the web UI).
2026-09-12 06:26:58 -04:00
snowspeeder 3620a2387b docs: update repo URL after rename to hope-voice-ios 2026-09-12 06:11:39 -04:00
snowspeeder 24f95639dc Redesign Hope iOS UI, harden playback, and document project handoff 2026-09-09 20:23:44 -04:00
59 changed files with 5591 additions and 2862 deletions

No files matched your search

+1
View File
@@ -4,3 +4,4 @@ xcuserdata/
*.xcresult
build/
.swiftpm/
.runtime/
+8
View File
@@ -1,3 +1,11 @@
# AGENTS.md
- On startup, read `docs/PROJECT_STATUS.md` before planning or changing this project. It is the current handoff report and records decisions, completed work, verification, deployment dependencies, and remaining tasks.
- Keep `docs/PROJECT_STATUS.md` updated as work progresses, especially after implementation, testing, deployment, or changes to blockers and user decisions. Distinguish verified results from assumptions, and update it before handing off or committing material changes.
- If using XcodeBuildMCP, use the installed XcodeBuildMCP skill before calling XcodeBuildMCP tools.
- Read `docs/ENGINEERING_LEARNINGS.md` before changing session lifecycle, transcripts, Settings, tests, signing, or deployment. Preserve its behavioral invariants unless the user explicitly changes the requirement.
- Follow the latest explicit user decisions: keep Qwen 2.5; the embedded music player was removed. Do not restore canceled features from historical goals, plans, or test reports.
- Treat `project.yml` as the project-generation source. Update it and regenerate with XcodeGen when adding/removing sources, tests, or assets; verify resources are in the generated build phase.
- Keep offline UI tests isolated from normal preferences and the NAS. Run live tests only deliberately, and distinguish unit/simulator evidence from physical audio qualification.
- Date verification claims and record their scope/artifacts. A healthy container, successful token request, review PASS, or installed app does not prove a working voice conversation.
- Before deploying or pushing backend changes, inspect the sibling repository and its automation. An iOS commit does not publish backend patches; preserve existing NAS data, certificates, dirty checkouts, and rollback options. Never put credentials in logs, reports, or commits.
+18 -22
View File
@@ -1,19 +1,19 @@
# Voice — iOS client for Hope Voice
Native SwiftUI iOS app (iOS 17+) that talks to the [Hope Voice](https://github.com/) agent:
voice/text conversation, transcript history, voice & model pickers, media player, and a
background-tasks panel. Mirrors the core web UI of `hope-voice-api`; no server-side changes.
voice/text conversation, transcript history, voice & model pickers, and a background-tasks
panel. Uses `hope-voice-api`.
> **Status:** Core functionality is implemented and verified in simulator to the extent the
> environment allows. Full device qualification (mic capture, audible media playback,
> interruption behavior) and the Hope visual redesign (`ui-ux-spec.md`) remain pending —
> environment allows. Full device qualification (mic capture, interruption behavior)
> remains pending. The native Talk/Activity redesign is implemented —
> see [PLAN.md](PLAN.md) and [docs/DEVICE_TESTING.md](docs/DEVICE_TESTING.md).
## Prerequisites
- Xcode 16+ (Swift 6), iOS 17 simulator or a real iPhone on the LAN.
- [xcodegen](https://github.com/yonaskolb/XcodeGen) (`brew install xcodegen`).
- The `voice` container running on Unraid (LiveKit + token server + media API).
- The `voice` container running on Unraid (LiveKit + token server).
- Mic: the simulator mic is unreliable in this environment (CoreAudio `-4010`, 0 input
channels) — voice mode needs a real device. If the simulator was wiped, grant permission
first: `xcrun simctl privacy booted grant microphone dev.shane.voice`.
@@ -33,52 +33,48 @@ To run in the simulator, use Xcode or `xcrun simctl` with the built app.
Default base URL is `https://192.168.86.2:9178` (see `Sources/App/SettingsStore.swift`).
Change it at runtime in **Settings → Server URL** (the app disconnects and recreates the
session when idle). The app derives LiveKit signaling and media endpoints from this base URL.
session when idle). The app derives the LiveKit signaling endpoint from this base URL.
### TLS cert pinning
The deployment uses a self-signed cert that is **regenerated at container start**, so the
pinned SHA-256 in `Sources/Networking/TLSPinning.swift` breaks on every cert rotation.
Re-export after a server restart:
The deployment uses a persistent self-signed cert. The entrypoint regenerates it if
missing or issued for a different IP. The pinned SHA-256 in
`Sources/Networking/TLSPinning.swift` must match after any actual cert rotation:
```sh
openssl s_client -connect 192.168.86.2:9178 </dev/null 2>/dev/null \
| openssl x509 -outform der | openssl dgst -sha256
```
Paste the new hash into `TLSPinning.pinnedCertSHA256Hex`. Until you do, token/media calls
Paste the new hash into `TLSPinning.pinnedCertSHA256Hex`. Until you do, token calls
fail with a pin mismatch (not a network error).
## Features
- **Conversation** — LiveKit room with the Hope agent; voice mode (mic + TTS) and text mode
(composer), toggleable and persisted. State machine (idle/connecting/listening/thinking/
speaking) driven by audio levels, orb animation, mic meter, barge-in hint.
speaking) driven by audio levels, with a compact status indicator and mic meter.
- **Transcripts** — partials updated in place, finals with timestamps, delivery-marker
stripping, last-50 history persisted to UserDefaults, clear button.
- **Voice & model pickers** — 13 Azure TTS voices; LLM models from the agent's `models`
message. Both persist and sync on join (`set_voice`/`set_model`).
- **Media player** — now-playing card (art/title/channel/status), transport (play/pause,
±10 s, stop), seek + volume sliders, search with "in library" tags, agent-offered choices,
library list (play/remove), add-stream form. AVPlayer-backed with a pinned-TLS Range proxy;
1.5 s `/state` polling and full web-parity reconciliation/reporting.
- **Background tasks panel** — live `task_event` cards (steps, result, error) with an active
count badge in the toolbar.
- **Activity tab** — live `task_event` updates (steps, result, error) with an active
count badge on the tab.
## Known limitations
- **Simulator mic**: CoreAudio returns `-4010` (no usable input source) in this environment;
voice capture must be verified on a real device. Text mode works in the simulator.
- **webm media**: iOS AVPlayer cannot decode webm/opus (`-11828`). Library tracks from yt-dlp
are webm, so audible playback requires the server to serve m4a/aac/mp3. Everything up to
decode (pinning, Range proxy, reconciliation, UI) is verified; no client change needed once
a supported format exists.
- **Cert pin rotation**: see above — re-export the pin after each server cert regeneration.
- **LiveKit signaling** uses plain `ws://192.168.86.2:7880` (not wss) because the Swift SDK
cannot trust the self-signed cert on its WebSocket; token + media still go over pinned TLS.
cannot trust the self-signed cert on its WebSocket; token calls still go over pinned TLS.
## Docs
- [docs/PROJECT_STATUS.md](docs/PROJECT_STATUS.md) — current handoff, decisions, and remaining work; read first.
- [docs/ENGINEERING_LEARNINGS.md](docs/ENGINEERING_LEARNINGS.md) — regression invariants, diagnostic lessons, and deployment safeguards.
- [docs/VERIFICATION.md](docs/VERIFICATION.md) — test evidence and remaining qualification.
- [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md) — live NAS configuration and rollback.
- [PLAN.md](PLAN.md) — phase-by-phase progress, verified facts, open items.
- [docs/DEVICE_TESTING.md](docs/DEVICE_TESTING.md) — real-device qualification checklist.
- [UPDATE_PATH.md](UPDATE_PATH.md) — original plan & protocol reference (read-only).
Binary file not shown.

After

Width:  |  Height:  |  Size: 183 KiB

@@ -1,7 +1,8 @@
{
"images" : [
{
"idiom" : "universal-platform",
"filename" : "AppIcon-1024.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
+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
}
}
+121
View File
@@ -0,0 +1,121 @@
import SwiftUI
import UIKit
/// Nearlight design tokens (design spec: local://ui-design-spec.md).
///
/// Views reference semantic roles; the primitive hex values live only here.
/// All custom surfaces are opaque; native system materials are untouched.
enum Nearlight {
// MARK: Palette (light / dark variants)
/// Opal screen background: luminous pink-white #FFFDFD / deep plum #201820.
static let ground = Color(light: UIColor(red: 255 / 255, green: 253 / 255, blue: 253 / 255, alpha: 1),
dark: UIColor(red: 32 / 255, green: 24 / 255, blue: 32 / 255, alpha: 1))
/// Controls, grouped rows, dock: #FFF7FA / #302330.
static let surface = Color(light: UIColor(red: 255 / 255, green: 247 / 255, blue: 250 / 255, alpha: 1),
dark: UIColor(red: 48 / 255, green: 35 / 255, blue: 48 / 255, alpha: 1))
/// The square persona's pale pink stage: #FFF2F7 / #3B2836.
static let personaSurface = Color(light: UIColor(red: 255 / 255, green: 242 / 255, blue: 247 / 255, alpha: 1),
dark: UIColor(red: 59 / 255, green: 40 / 255, blue: 54 / 255, alpha: 1))
/// Primary text: readable dark plum #302B40 / #FFF2F7.
static let ink = Color(light: UIColor(red: 48 / 255, green: 43 / 255, blue: 64 / 255, alpha: 1),
dark: UIColor(red: 255 / 255, green: 242 / 255, blue: 247 / 255, alpha: 1))
/// Supporting text, inactive icons: plum #706074 / #CBB6C7.
static let quiet = Color(light: UIColor(red: 112 / 255, green: 96 / 255, blue: 116 / 255, alpha: 1),
dark: UIColor(red: 203 / 255, green: 182 / 255, blue: 199 / 255, alpha: 1))
/// Opal action rose #A64270 / #F0A8C8; retains the existing semantic API.
static let rosewood = Color(light: UIColor(red: 166 / 255, green: 66 / 255, blue: 112 / 255, alpha: 1),
dark: UIColor(red: 240 / 255, green: 168 / 255, blue: 200 / 255, alpha: 1))
/// Errors and destructive actions.
static let alert = Color(light: UIColor(red: 0.675, green: 0.208, blue: 0.282, alpha: 1),
dark: UIColor(red: 1.0, green: 0.604, blue: 0.675, alpha: 1))
// MARK: Derived roles
/// Decorative separator: Ink at 14% over its parent.
static var separator: Color { ink.opacity(0.14) }
/// Selected wash: Rosewood at 10% over Surface.
static var selectedWash: Color { rosewood.opacity(0.10) }
/// Error wash: Alert at 8% over Surface.
static var errorWash: Color { alert.opacity(0.08) }
/// Primary-action label: white on strong rose, deep plum on the dark-mode pink.
static let onRosewood = Color(light: .white,
dark: UIColor(red: 32 / 255, green: 24 / 255, blue: 32 / 255, alpha: 1))
// MARK: Spacing scale
static let s4: CGFloat = 4
static let s8: CGFloat = 8
static let s12: CGFloat = 12
static let s16: CGFloat = 16
static let s20: CGFloat = 20
static let s24: CGFloat = 24
static let s32: CGFloat = 32
static let s48: CGFloat = 48
// MARK: Radius scale
static let radiusSegment: CGFloat = 8
static let radiusControl: CGFloat = 12
static let radiusDock: CGFloat = 16
// MARK: Motion tokens
/// quick = 0.12s easeOut.
static let quick: Animation = .easeOut(duration: 0.12)
/// standard = 0.22s timingCurve(0.22, 1, 0.36, 1).
static let standard: Animation = .timingCurve(0.22, 1, 0.36, 1, duration: 0.22)
/// settle = 0.32s timingCurve(0.22, 1, 0.36, 1).
static let settle: Animation = .timingCurve(0.22, 1, 0.36, 1, duration: 0.32)
/// cycle = 2.4s easeInOut (Lightwell pulses).
static let cycle: Animation = .easeInOut(duration: 2.4)
}
extension Color {
/// Builds an adaptive color from explicit light/dark UIColors without
/// requiring an asset catalog.
init(light: UIColor, dark: UIColor) {
self.init(uiColor: UIColor { traits in
traits.userInterfaceStyle == .dark ? dark : light
})
}
}
/// Shared typography roles from the spec. SF Pro default design except the
/// rounded welcome heading and wordmark.
enum NearlightFont {
/// Welcome heading: 32 medium, rounded, largeTitle metrics.
static func welcome(_ sizeCategory: DynamicTypeSize) -> Font {
scaled(.largeTitle, base: 32, weight: .medium, design: .rounded, sizeCategory: sizeCategory)
}
/// "Hope" wordmark: 20 semibold, rounded, title3 metrics.
static func wordmark(_ sizeCategory: DynamicTypeSize) -> Font {
scaled(.title3, base: 20, weight: .semibold, design: .rounded, sizeCategory: sizeCategory)
}
/// Section/detail heading: 22 semibold, title2 metrics.
static func heading(_ sizeCategory: DynamicTypeSize) -> Font {
scaled(.title2, base: 22, weight: .semibold, sizeCategory: sizeCategory)
}
/// Control/row label: 17 semibold, body metrics.
static func control(_ sizeCategory: DynamicTypeSize) -> Font {
scaled(.body, base: 17, weight: .semibold, sizeCategory: sizeCategory)
}
/// Status/support: 15 regular, subheadline metrics.
static func support(_ sizeCategory: DynamicTypeSize) -> Font {
scaled(.subheadline, base: 15, weight: .regular, sizeCategory: sizeCategory)
}
/// Speaker/timestamp: 13 medium, footnote metrics.
static func metadata(_ sizeCategory: DynamicTypeSize) -> Font {
scaled(.footnote, base: 13, weight: .medium, sizeCategory: sizeCategory)
}
/// Scales a base size with the given text style via UIFontMetrics and
/// returns a SwiftUI font with the requested weight/design.
private static func scaled(_ textStyle: UIFont.TextStyle, base: CGFloat, weight: Font.Weight,
design: Font.Design = .default, sizeCategory: DynamicTypeSize) -> Font {
// UIFontMetrics scales the base size against the CURRENT Dynamic Type
// setting at render time (no explicit traits: inherits environment).
let scaledSize = UIFontMetrics(forTextStyle: textStyle).scaledValue(for: base)
return .system(size: scaledSize, weight: weight, design: design)
}
}
+28 -15
View File
@@ -3,33 +3,25 @@ import Combine
final class SettingsStore: ObservableObject {
@Published var serverURL: String {
didSet { UserDefaults.standard.set(serverURL, forKey: Keys.serverURL) }
didSet { AppPreferences.store.set(serverURL, forKey: Keys.serverURL) }
}
@Published var voiceID: String {
didSet { UserDefaults.standard.set(voiceID, forKey: Keys.voiceID) }
}
@Published var modelID: String? {
didSet { UserDefaults.standard.set(modelID, forKey: Keys.modelID) }
didSet { AppPreferences.store.set(voiceID, forKey: Keys.voiceID) }
}
@Published var textMode: Bool {
didSet { UserDefaults.standard.set(textMode, forKey: Keys.textMode) }
}
@Published var volume: Double {
didSet { UserDefaults.standard.set(volume, forKey: Keys.volume) }
didSet { AppPreferences.store.set(textMode, forKey: Keys.textMode) }
}
private enum Keys {
static let serverURL = "serverURL"
static let voiceID = "voiceID"
static let modelID = "modelID"
static let textMode = "textMode"
static let volume = "volume"
}
static let defaultServerURL = "https://192.168.86.2:9178"
init() {
let d = UserDefaults.standard
let d = AppPreferences.store
// Sanitize legacy values: earlier versions wrote the field to storage on
// every keystroke, so an invalid half-typed URL could be persisted and
// shipped to the network layer. Fall back to the documented default and
@@ -44,13 +36,34 @@ 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)
volume = d.object(forKey: Keys.volume) as? Double ?? 0.8
d.set(voiceID, forKey: Keys.voiceID)
}
var trimmedServerURL: String {
serverURL.trimmingCharacters(in: .whitespacesAndNewlines)
}
}
enum AppPreferences {
static let store: UserDefaults = {
#if DEBUG
if ProcessInfo.processInfo.arguments.contains("--ui-testing") {
let suite = "dev.shane.voice.ui-testing"
let defaults = UserDefaults(suiteName: suite)!
if ProcessInfo.processInfo.arguments.contains("--reset-ui-testing") {
defaults.removePersistentDomain(forName: suite)
// Deterministic offline errors; UI tests must not contact the NAS.
defaults.set("https://127.0.0.1:1", forKey: "serverURL")
}
if ProcessInfo.processInfo.arguments.contains("--live-testing") {
defaults.set(SettingsStore.defaultServerURL, forKey: "serverURL")
defaults.set(true, forKey: "textMode")
}
return defaults
}
#endif
return .standard
}()
}
+3 -3
View File
@@ -6,13 +6,13 @@ struct VoiceApp: App {
@StateObject private var settings = SettingsStore()
init() {
LiveKitSDK.setLogLevel(.debug)
LiveKitSDK.setLogLevel(.error)
}
var body: some Scene {
WindowGroup {
ConversationView(settings: settings)
.preferredColorScheme(.dark)
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
})
}
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,125 @@
import SwiftUI
/// Hope's square LED presence, matching the web's 21 × 21 dot matrix.
/// One Canvas replaces hundreds of subviews. The adjacent status text is the
/// single accessible description; individual LEDs are never focusable.
struct LightwellView: View {
enum Phase: Equatable {
case idle, connecting, waiting, thinking, tearingDown, failed, readyNoInput
case listening(level: Double)
case speaking(level: Double)
}
let phase: Phase
var compact = false
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@Environment(\.colorSchemeContrast) private var contrast
@Environment(\.scenePhase) private var scenePhase
@Environment(\.dynamicTypeSize) private var typeSize
private var side: CGFloat {
if compact { return typeSize.isAccessibilitySize ? 80 : 112 }
return typeSize.isAccessibilitySize ? 136 : 184
}
private var animated: Bool {
guard !reduceMotion, scenePhase == .active else { return false }
switch phase {
case .connecting, .waiting, .thinking, .listening, .speaking: return true
default: return false
}
}
var body: some View {
Group {
if animated {
TimelineView(.animation(minimumInterval: 1.0 / 20.0)) { timeline in
matrix(time: timeline.date.timeIntervalSinceReferenceDate)
}
} else {
matrix(time: 0)
}
}
.frame(width: side, height: side)
.background(Nearlight.personaSurface, in: RoundedRectangle(cornerRadius: Nearlight.radiusDock))
.overlay {
RoundedRectangle(cornerRadius: Nearlight.radiusDock)
.strokeBorder(contrast == .increased ? Nearlight.quiet : Nearlight.separator, lineWidth: 1)
}
.accessibilityHidden(true)
}
private var tint: Color {
switch phase {
case .failed: return Nearlight.alert
case .tearingDown: return Nearlight.quiet
default: return Nearlight.rosewood
}
}
private func matrix(time: Double) -> some View {
Canvas { context, size in
let spacing = (min(size.width, size.height) - 28) / 20
for row in 0..<21 {
for column in 0..<21 {
let dot = Self.dot(row: row, column: column, phase: phase,
time: time, reduceMotion: reduceMotion)
let diameter = max(1.6, spacing * 0.28) * dot.scale
let center = CGPoint(x: 14 + CGFloat(column) * spacing,
y: 14 + CGFloat(row) * spacing)
let rect = CGRect(x: center.x - diameter / 2, y: center.y - diameter / 2,
width: diameter, height: diameter)
// Increased contrast brightens the whole lattice; state is
// encoded in its pattern and adjacent text, never color alone.
let opacity = contrast == .increased ? min(1, dot.opacity * 1.25 + 0.16) : dot.opacity
context.fill(Path(ellipseIn: rect), with: .color(tint.opacity(opacity)))
}
}
}
}
struct Dot: Equatable {
let opacity: Double
let scale: Double
}
/// Deterministic sampling is shared with offline tests. Reduce Motion freezes
/// waves and signal-driven geometry, while keeping each state's pattern clear.
static func dot(row: Int, column: Int, phase: Phase, time: Double, reduceMotion: Bool) -> Dot {
let x = Double(column - 10) / 10.5
let y = Double(row - 10) / 10.5
let radius = sqrt(x * x + y * y)
let t = reduceMotion ? 0 : time
let baseline = max(0.08, 0.20 - radius * 0.075)
let offset = sin(Double(row) * 12.9898 + Double(column) * 78.233)
var brightness = baseline
var scale = 1.0
switch phase {
case .idle, .tearingDown:
break
case .readyNoInput:
brightness += 0.28 * exp(-radius * radius * 3)
case .failed:
// A stable diagonal cross rather than an animated alarm.
if abs(row - column) <= 1 || abs(row + column - 20) <= 1 { brightness = 0.65 }
case .connecting, .waiting:
let ring = exp(-pow((radius - 0.53) * 7, 2))
let sweep = reduceMotion ? 0.75 : 0.55 + 0.45 * cos(atan2(y, x) - t * 2)
brightness += ring * sweep * 0.65
case .thinking:
let bandCenter = reduceMotion ? 0 : sin(t * 1.8) * 0.5
let band = exp(-pow((y - bandCenter) * 4, 2)) * exp(-x * x * 2)
brightness += band * 0.65
case .listening(let raw), .speaking(let raw):
let level = reduceMotion ? 0.35 : (raw.isFinite ? min(max(raw, 0), 1) : 0)
let speaking: Bool
if case .speaking = phase { speaking = true } else { speaking = false }
let signal = min(1, level * 1.3)
let bloom = exp(-radius * radius * (3.0 - signal * 1.5))
let ripple = reduceMotion ? 1 : 0.85 + 0.15 * sin(t * 4 + offset)
brightness += bloom * (0.25 + signal * 0.7) * ripple * (speaking ? 1 : 0.65)
scale += max(0, 1 - radius) * (speaking ? 1.4 : 0.85) * signal
}
return Dot(opacity: min(max(brightness, 0.02), 1), scale: scale)
}
}
@@ -0,0 +1,153 @@
import SwiftUI
struct LoginView: View {
@ObservedObject var auth: AuthStore
@ObservedObject var settings: SettingsStore
@State private var username = ""
@State private var password = ""
@State private var server = ""
@State private var serverError: String?
@State private var savedServer: String?
@State private var passwordVisible = false
@Environment(\.dynamicTypeSize) private var typeSize
var body: some View {
NavigationStack {
Form {
Section {
VStack(alignment: .leading, spacing: Nearlight.s12) {
Text("Sign in to Hope")
.font(NearlightFont.welcome(typeSize))
.foregroundStyle(Nearlight.ink)
.accessibilityAddTraits(.isHeader)
Text("Use the account provided by your server administrator. Hermes is managed by your server.")
.font(.body)
.foregroundStyle(Nearlight.quiet)
.fixedSize(horizontal: false, vertical: true)
}
.padding(.vertical, Nearlight.s16)
.listRowBackground(Nearlight.ground)
.listRowSeparator(.hidden)
}
Section("Account") {
TextField("Username", text: $username).textContentType(.username)
.textInputAutocapitalization(.never).autocorrectionDisabled()
HStack {
Group {
if passwordVisible { TextField("Password", text: $password) }
else { SecureField("Password", text: $password) }
}
.textContentType(.password)
Button(passwordVisible ? "Hide" : "Show") { passwordVisible.toggle() }
.accessibilityLabel(passwordVisible ? "Hide password" : "Show password")
}
Button("Sign in") {
let suppliedPassword = password
Task {
await auth.login(username: username, password: suppliedPassword)
if auth.user != nil { password = "" }
}
}
.font(NearlightFont.control(typeSize))
.buttonStyle(.borderedProminent)
.tint(Nearlight.rosewood)
.foregroundStyle(Nearlight.onRosewood)
.frame(minHeight: 44)
.padding(.vertical, Nearlight.s4)
.disabled(auth.isBusy || username.isEmpty || password.isEmpty)
if auth.isBusy { ProgressView("Signing in…").tint(Nearlight.rosewood) }
if let error = auth.error {
Text(error).foregroundStyle(Nearlight.alert)
.fixedSize(horizontal: false, vertical: true)
}
}
.listRowBackground(Nearlight.surface)
Section("Server") {
TextField("HTTPS server address", text: $server).keyboardType(.URL)
.textInputAutocapitalization(.never).autocorrectionDisabled()
Button("Save server address") {
switch ServerConfig.parse(server) {
case .success(let config):
server = config.baseURL.absoluteString
settings.serverURL = config.baseURL.absoluteString
serverError = nil
savedServer = config.baseURL.absoluteString
case .failure(let error): serverError = error.localizedDescription; savedServer = nil
}
}.disabled(auth.isBusy || server.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || server == settings.serverURL)
if let serverError {
Label(serverError, systemImage: "exclamationmark.circle").foregroundStyle(Nearlight.alert)
.fixedSize(horizontal: false, vertical: true)
} else if savedServer != nil {
Label("Server address saved. Sign in uses this server.", systemImage: "checkmark.circle").font(.footnote).foregroundStyle(Nearlight.quiet)
}
Text("Connections use HTTPS and secure WebSockets. A private server certificate must be trusted by this iPhone for conversations.")
.font(.footnote).foregroundStyle(Nearlight.quiet)
.fixedSize(horizontal: false, vertical: true)
if auth.config.baseURL.host == TLSPinning.host, auth.config.baseURL.port == 9178 {
Link("Install Hope server certificate in Safari", destination: auth.config.baseURL.appendingPathComponent("hope-server.mobileconfig"))
Text("Install Hope LAN Server Trust in Settings → General → VPN & Device Management. Then enable 192.168.86.2 in Settings → General → About → Certificate Trust Settings. Only trust this certificate for your own server.")
.font(.footnote).foregroundStyle(Nearlight.quiet)
.fixedSize(horizontal: false, vertical: true)
}
}
.listRowBackground(Nearlight.surface)
}
.foregroundStyle(Nearlight.ink)
.tint(Nearlight.rosewood)
.scrollContentBackground(.hidden)
.background(Nearlight.ground)
.navigationTitle("Hope")
.toolbarBackground(Nearlight.ground, for: .navigationBar)
.onAppear { server = settings.serverURL }
.onChange(of: server) { _, _ in serverError = nil; savedServer = nil }
}
}
}
struct AuthenticationRoot: View {
@ObservedObject var settings: SettingsStore
@State private var auth: AuthStore
@State private var replacement: Task<Void, Never>?
init(settings: SettingsStore) {
self.settings = settings
_auth = State(initialValue: Self.makeAuth(settings))
}
private static func makeAuth(_ settings: SettingsStore) -> AuthStore {
guard case .success(let config) = ServerConfig.parse(settings.serverURL) else { preconditionFailure("Invalid configured origin") }
#if DEBUG
if ProcessInfo.processInfo.arguments.contains("--ui-testing"), !ProcessInfo.processInfo.arguments.contains("--live-testing") {
return AuthStore(config: config, tokens: MemoryTokenStore(), fixture: true)
}
#endif
return AuthStore(config: config)
}
var body: some View {
RootContent(settings: settings, auth: auth)
.task(id: auth.config.baseURL) { await auth.restore() }
.onChange(of: settings.serverURL) { _, _ in
guard !auth.fixture else { return }
guard replacement == nil else { return }
replacement = Task { @MainActor in
await auth.logout { await auth.disconnectAccount?() }
guard !Task.isCancelled else { return }
// Read latest origin after teardown; intermediate edits never
// restore credentials or connect an intermediate account.
auth = Self.makeAuth(settings)
replacement = nil
}
}
}
}
private struct RootContent: View {
@ObservedObject var settings: SettingsStore
@ObservedObject var auth: AuthStore
var body: some View {
if let user = auth.user {
ConversationView(settings: settings, auth: auth, user: user, bearerToken: auth.token ?? "")
.id(auth.config.baseURL.absoluteString + ":" + user.id)
} else {
LoginView(auth: auth, settings: settings)
}
}
}
-150
View File
@@ -1,150 +0,0 @@
import SwiftUI
struct OrbView: View {
enum OrbState {
case idle
case connecting
case listening
case speaking
case thinking
case failed
}
let state: OrbState
@State private var animating = false
private var baseColor: Color {
switch state {
case .idle, .connecting: .blue
case .listening: .green
case .speaking, .thinking: .orange
case .failed: .red
}
}
private var scale: CGFloat {
switch state {
case .connecting, .listening, .speaking, .thinking: 1.1
default: 1.0
}
}
private var label: String {
switch state {
case .idle: "Idle"
case .connecting: "Connecting"
case .listening: "Listening"
case .speaking: "Speaking"
case .thinking: "Thinking"
case .failed: "Connection failed"
}
}
var body: some View {
let shape = Circle()
.fill(baseColor.gradient)
.frame(width: 120, height: 120)
let animated = shape
.scaleEffect(animating ? scale : 0.95)
.opacity(state == .idle || state == .failed ? 0.7 : 1.0)
let anim: Animation? = state == .idle || state == .failed
? nil
: .easeInOut(duration: 0.8).repeatForever(autoreverses: true)
animated
.animation(anim, value: animating)
.onAppear { animating = true }
.onChange(of: state) { _, _ in animating = true }
.accessibilityElement()
.accessibilityLabel("Conversation status")
.accessibilityValue(label)
}
}
struct TranscriptListView: View {
/// The single canonical visible transcript (restored persisted finals plus
/// current live events). The view renders exactly this collection — there is
/// no second "restored" store, so the UI and persistence can never diverge.
let events: [TranscriptEvent]
var onClear: () -> Void
private static let deliveryMarkers = #"\[(?:appreciative|calm|concerned|curious|encouraging|excited|happy|joking|quiet|reassuring|reflective|sad|serious|surprised|sympathetic|urgent|whispering|laughter|sighing|breathing|neutral)\]\s*"#
private static let timestampFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateStyle = .none
formatter.timeStyle = .short
return formatter
}()
private var visibleRows: [TranscriptEvent] { events }
var body: some View {
ScrollViewReader { proxy in
VStack(alignment: .leading, spacing: 0) {
HStack {
Text("Transcript")
.font(.caption.bold())
.foregroundStyle(.secondary)
Spacer()
Button(role: .destructive, action: onClear) {
Label("Clear", systemImage: "trash")
.font(.caption)
}
}
.padding(.horizontal)
.padding(.bottom, 4)
ScrollView {
LazyVStack(alignment: .leading, spacing: 8) {
ForEach(visibleRows) { event in
transcriptRow(event)
}
}
.padding(.horizontal)
}
.onChange(of: visibleRows.count) { _, _ in
if let last = visibleRows.last {
withAnimation { proxy.scrollTo(last.id, anchor: .bottom) }
}
}
}
}
}
private func transcriptRow(_ event: TranscriptEvent) -> some View {
let roleName = event.role == .user ? "You" : "Hope"
let nameColor: Color = event.role == .user ? .secondary : .blue
let rowBackground: Color = event.role == .user
? Color(.secondarySystemBackground)
: Color.blue.opacity(0.08)
let textOpacity: Double = event.isFinal ? 1.0 : 0.55
return VStack(alignment: .leading, spacing: 2) {
HStack {
Text(roleName)
.font(.caption2.bold())
.foregroundStyle(nameColor)
Spacer()
Text(Self.timestampFormatter.string(from: event.timestamp))
.font(.caption2)
.foregroundStyle(.tertiary)
}
Text(stripped(event.text))
.font(.body)
.opacity(textOpacity)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(8)
.background(rowBackground)
.cornerRadius(10)
.id(event.id)
}
private func stripped(_ text: String) -> String {
text.replacingOccurrences(
of: Self.deliveryMarkers,
with: "",
options: .regularExpression
).trimmingCharacters(in: .whitespaces)
}
}
@@ -0,0 +1,236 @@
import SwiftUI
/// Nearlight transcript: a legible shared record. Full-width rows, no
/// bubbles; metadata row per event; "Live" placeholder on partials.
struct TranscriptListView: View {
let events: [TranscriptEvent]
var onClear: () -> Void
@State private var confirmClear = false
@State private var followingLatest = true
@State private var selectionActive = false
@State private var viewportHeight: CGFloat = 0
@State private var latestBottom: CGFloat = 0
@AccessibilityFocusState private var focusedEventID: String?
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@Environment(\.dynamicTypeSize) private var typeSize
init(events: [TranscriptEvent], onClear: @escaping () -> Void) {
self.events = events
self.onClear = onClear
_followingLatest = State(initialValue: events.isEmpty)
}
/// Auto-follow eligibility: never scroll while the user is selecting
/// text or reading history.
private var isFollowingEligible: Bool {
followingLatest && !selectionActive && focusedEventID == nil
}
private static let deliveryMarkers = #"\[(?:appreciative|calm|concerned|curious|encouraging|excited|happy|joking|quiet|reassuring|reflective|sad|serious|surprised|sympathetic|urgent|whispering|laughter|sighing|breathing|neutral)\]\s*"#
var body: some View {
ScrollViewReader { proxy in
VStack(spacing: 0) {
HStack {
Text("Conversation")
.font(NearlightFont.support(typeSize).weight(.semibold))
.foregroundStyle(Nearlight.quiet)
Spacer()
Menu {
Button("Clear conversation", systemImage: "trash", role: .destructive) { confirmClear = true }
} label: {
Image(systemName: "ellipsis").frame(width: 44, height: 44)
}
.accessibilityLabel("Conversation options")
.disabled(events.isEmpty)
}
.padding(.horizontal, Nearlight.s20)
ScrollView {
LazyVStack(alignment: .leading, spacing: Nearlight.s24) {
if events.isEmpty {
Text("Your conversation will appear here.")
.font(.body)
.foregroundStyle(Nearlight.quiet)
.padding(.top, Nearlight.s24)
}
ForEach(events) { event in transcriptRow(event) }
GeometryReader { geometry in
Color.clear.preference(
key: TranscriptLatestBottomKey.self,
value: geometry.frame(in: .named("transcriptScroll")).maxY
)
}
.frame(height: 1)
.id("latest")
}
.padding(.horizontal, Nearlight.s20)
.padding(.top, Nearlight.s16)
.padding(.bottom, Nearlight.s20)
.frame(minHeight: 200, alignment: .topLeading)
}
.coordinateSpace(name: "transcriptScroll")
.background {
GeometryReader { geometry in
Color.clear.preference(
key: TranscriptViewportHeightKey.self,
value: geometry.size.height
)
}
}
.scrollDismissesKeyboard(.interactively)
.defaultScrollAnchor(.top)
.simultaneousGesture(DragGesture().onChanged { _ in
// Any manual drag suspends automatic following.
followingLatest = false
})
.onChange(of: events.last?.text) { _, _ in
guard isFollowingEligible else { return }
proxy.scrollTo("latest", anchor: .bottom)
}
.onChange(of: events.count) { _, count in
if count == 0 {
selectionActive = false
followingLatest = true
} else if isFollowingEligible {
proxy.scrollTo("latest", anchor: .bottom)
}
}
.onChange(of: selectionActive) { _, active in
// Beginning text selection suspends auto-follow so the
// user's reading position is never disturbed.
if active { followingLatest = false }
}
.onChange(of: focusedEventID) { _, eventID in
// VoiceOver focus on any transcript row means the user is
// reading history. Only Latest resumes automatic following.
if eventID != nil { followingLatest = false }
}
.onPreferenceChange(TranscriptViewportHeightKey.self) { height in
viewportHeight = height
updateFollowingEligibility(bottom: latestBottom, viewport: height)
}
.onPreferenceChange(TranscriptLatestBottomKey.self) { bottom in
latestBottom = bottom
updateFollowingEligibility(bottom: bottom, viewport: viewportHeight)
}
if !followingLatest && !events.isEmpty {
HStack {
Spacer()
Button {
// Explicit Latest restores full eligibility,
// clearing any stale selection suspension.
selectionActive = false
focusedEventID = nil
followingLatest = true
withAnimation(reduceMotion ? nil : Nearlight.standard) {
proxy.scrollTo("latest", anchor: .bottom)
}
} label: {
Label("Latest", systemImage: "arrow.down")
.font(NearlightFont.support(typeSize))
.padding(.horizontal, Nearlight.s12)
.frame(minHeight: 44)
.background(Nearlight.surface, in: Capsule())
.overlay(Capsule().strokeBorder(Nearlight.quiet, lineWidth: 1))
}
.accessibilityIdentifier("talk.latest")
}
.padding(.horizontal, Nearlight.s12)
.padding(.bottom, Nearlight.s12)
}
}
}
.alert("Clear this conversation?", isPresented: $confirmClear) {
Button("Clear conversation", role: .destructive, action: onClear)
Button("Cancel", role: .cancel) {}
} message: {
Text("This removes the transcript saved on this iPhone. It doesn’t reset Hope’s memory or end your current conversation.")
}
}
private func updateFollowingEligibility(bottom: CGFloat, viewport: CGFloat) {
guard bottom > 0,
viewport > 0,
!selectionActive,
focusedEventID == nil else { return }
let shouldFollow = bottom <= viewport + 48
if followingLatest != shouldFollow {
followingLatest = shouldFollow
}
}
private func transcriptRow(_ event: TranscriptEvent) -> some View {
let isUser = event.role == .user
let text = event.text.replacingOccurrences(of: Self.deliveryMarkers, with: "", options: .regularExpression)
.trimmingCharacters(in: .whitespaces)
return VStack(alignment: .leading, spacing: Nearlight.s8) {
// Metadata row: speaker, timestamp trailing. Stacks at
// accessibility sizes per spec.
Group {
if typeSize.isAccessibilitySize {
VStack(alignment: .leading, spacing: Nearlight.s4) {
speakerLabel(isUser)
timeLabel(event)
}
} else {
HStack(spacing: Nearlight.s8) {
speakerLabel(isUser)
timeLabel(event)
}
}
}
Text(text)
.font(.body)
.foregroundStyle(Nearlight.ink)
.textSelection(.enabled)
.fixedSize(horizontal: false, vertical: true)
}
.frame(maxWidth: .infinity, alignment: .leading)
.onLongPressGesture(minimumDuration: 0.05, pressing: { pressing in
// Text-selection interaction begins with long-press on the row;
// suspend auto-follow while pressing/held and restore when the
// press ends so following can resume.
selectionActive = pressing
}, perform: {})
.contextMenu {
Button("Copy message", systemImage: "doc.on.doc") { UIPasteboard.general.string = text }
}
.accessibilityElement(children: .combine)
.accessibilityFocused($focusedEventID, equals: event.id)
.id(event.id)
}
private func speakerLabel(_ isUser: Bool) -> some View {
Text(isUser ? "You" : "Hope")
.font(NearlightFont.metadata(typeSize).weight(.semibold))
.foregroundStyle(Nearlight.ink)
}
private func timeLabel(_ event: TranscriptEvent) -> some View {
Group {
if event.isFinal {
Text(event.timestamp, style: .time)
} else {
Text("Live")
}
}
.font(NearlightFont.metadata(typeSize))
.foregroundStyle(Nearlight.quiet)
.monospacedDigit()
}
}
private struct TranscriptViewportHeightKey: PreferenceKey {
static var defaultValue: CGFloat = 0
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = nextValue()
}
}
private struct TranscriptLatestBottomKey: PreferenceKey {
static var defaultValue: CGFloat = 0
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = nextValue()
}
}
-516
View File
@@ -1,516 +0,0 @@
import SwiftUI
struct MediaPanelView: View {
@ObservedObject var playerController: MediaPlayerController
@ObservedObject var settings: SettingsStore
@State private var searchQuery = ""
@State private var searchResults: [MediaSearchResult]?
@State private var isSearching = false
@State private var searchError: String?
@State private var libraryTracks: [MediaTrack]?
@State private var libraryError: String?
@State private var streamName = ""
@State private var streamURL = ""
@State private var streamMessage: String?
@State private var trackToDelete: MediaTrack?
var body: some View {
ScrollView {
VStack(spacing: 20) {
if let stateError = playerController.stateError {
Label(stateError, systemImage: "wifi.exclamationmark")
.font(.subheadline)
.foregroundStyle(.red)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(12)
.background(Color.red.opacity(0.1), in: RoundedRectangle(cornerRadius: 14))
}
nowPlayingCard
transportControls
seekSection
volumeSection
pendingSection
choicesSection
searchSection
librarySection
addStreamSection
}
.padding()
}
.navigationTitle("Media")
.onAppear {
playerController.startDefaultPolling()
refreshLibrary()
}
.alert("Remove from library?", isPresented: Binding(
get: { trackToDelete != nil },
set: { if !$0 { trackToDelete = nil } }
)) {
Button("Remove", role: .destructive) {
guard let track = trackToDelete else { return }
Task { await deleteTrack(track) }
}
Button("Cancel", role: .cancel) { trackToDelete = nil }
} message: {
Text(trackToDelete?.title ?? "")
}
}
private var nowPlayingCard: some View {
HStack(spacing: 12) {
if let artURL = playerController.track?.artURL, let url = URL(string: absolute(artURL)) {
AsyncImage(url: url) { phase in
switch phase {
case .success(let image):
image.resizable().scaledToFill()
default:
Image(systemName: "music.note")
.font(.title2)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color(.systemGray6))
}
}
.frame(width: 72, height: 72)
.clipShape(RoundedRectangle(cornerRadius: 10))
} else {
RoundedRectangle(cornerRadius: 10)
.fill(Color(.systemGray6))
.frame(width: 72, height: 72)
.overlay(
Image(systemName: "music.note")
.font(.title2)
.foregroundStyle(.secondary)
)
}
VStack(alignment: .leading, spacing: 4) {
Text(title)
.font(.headline)
.lineLimit(2)
Text(subtitle)
.font(.subheadline)
.foregroundStyle(.secondary)
.lineLimit(1)
if playerController.track != nil {
Text(statusText)
.font(.caption)
.foregroundStyle(playerController.desired == "playing" ? Color.green : Color.secondary)
}
}
Spacer()
}
.padding(12)
.background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 14))
}
private var transportControls: some View {
HStack(spacing: 24) {
Button(action: seekBackward) {
Image(systemName: "gobackward.10")
.font(.title3)
}
.accessibilityLabel("Seek back 10 seconds")
.disabled(playerController.track == nil)
Button(action: togglePlayPause) {
Image(systemName: playerController.desired == "playing" && playerController.track != nil ? "pause.circle.fill" : "play.circle.fill")
.font(.system(size: 44))
.foregroundStyle(playerController.track == nil ? Color.secondary : Color.accentColor)
}
.accessibilityLabel(playerController.desired == "playing" && playerController.track != nil ? "Pause" : "Play")
.disabled(playerController.track == nil)
Button(action: seekForward) {
Image(systemName: "goforward.10")
.font(.title3)
}
.accessibilityLabel("Seek forward 10 seconds")
.disabled(playerController.track == nil)
Button(role: .destructive, action: stopPlayback) {
Image(systemName: "stop.circle")
.font(.title2)
}
.accessibilityLabel("Stop playback")
.disabled(playerController.track == nil)
}
}
private var seekSection: some View {
VStack(spacing: 4) {
if playerController.track != nil {
Slider(
value: Binding(
get: { playerController.currentTime },
set: { newValue in Task { playerController.seek(to: newValue) } }
),
in: 0...max(playerController.duration, 1)
)
HStack {
Text(fmtTime(playerController.currentTime))
.font(.caption.monospacedDigit())
.foregroundStyle(.secondary)
Spacer()
Text(fmtTime(playerController.duration))
.font(.caption.monospacedDigit())
.foregroundStyle(.secondary)
}
} else {
Text("Nothing loaded")
.font(.subheadline)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .center)
.padding(.vertical, 8)
}
}
}
private var volumeSection: some View {
HStack(spacing: 12) {
Image(systemName: "speaker.fill")
.foregroundStyle(.secondary)
Slider(value: $settings.volume, in: 0...1)
.onChange(of: settings.volume) { _, newValue in
playerController.setVolume(newValue)
}
Image(systemName: "speaker.wave.3.fill")
.foregroundStyle(.secondary)
}
}
@ViewBuilder
private var pendingSection: some View {
if !playerController.pendingJobs.isEmpty {
VStack(alignment: .leading, spacing: 8) {
ForEach(playerController.pendingJobs, id: \.jobID) { job in
HStack {
Text(job.stage == "queued" ? "Queued…" : "Downloading…")
.font(.subheadline)
Spacer()
Text("\(Int((job.progress ?? 0)))%")
.font(.caption.monospacedDigit())
.foregroundStyle(.secondary)
}
}
}
.padding(12)
.background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 14))
}
}
@ViewBuilder
private var choicesSection: some View {
if !playerController.choices.isEmpty {
VStack(alignment: .leading, spacing: 8) {
Text("Hope's suggestions")
.font(.headline)
ForEach(playerController.choices, id: \.videoID) { choice in
mediaRow(
title: choice.title ?? choice.videoID ?? "Unknown",
detail: rowDetail(channel: choice.channel, duration: choice.duration),
playEnabled: true
) {
Task {
await playerController.playVideo(
videoID: choice.videoID,
title: choice.title,
channel: choice.channel
)
}
}
}
}
}
}
private var searchSection: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Search YouTube Music")
.font(.headline)
HStack(spacing: 8) {
TextField("Song, artist, lyrics…", text: $searchQuery)
.textFieldStyle(.roundedBorder)
.submitLabel(.search)
.onSubmit(performSearch)
Button("Find", action: performSearch)
.buttonStyle(.bordered)
.disabled(searchQuery.trimmingCharacters(in: .whitespaces).isEmpty || isSearching)
}
if isSearching {
HStack(spacing: 8) {
ProgressView()
Text("Searching YouTube Music…")
.font(.subheadline)
.foregroundStyle(.secondary)
}
} else if let searchError {
Text(searchError)
.font(.subheadline)
.foregroundStyle(.red)
} else if let results = searchResults {
if results.isEmpty {
Text("No results.")
.font(.subheadline)
.foregroundStyle(.secondary)
} else {
ForEach(results, id: \.videoID) { result in
mediaRow(
title: result.title ?? result.videoID ?? "Unknown",
detail: rowDetail(channel: result.channel, duration: result.duration, inLibrary: result.inLibrary),
playEnabled: true
) {
Task {
if let trackID = result.trackID {
await playerController.playTrack(id: trackID)
} else {
await playerController.playVideo(
videoID: result.videoID,
title: result.title,
channel: result.channel
)
}
}
}
}
}
}
}
}
private var librarySection: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
Text("Library")
.font(.headline)
Spacer()
Button(action: refreshLibrary) {
Image(systemName: "arrow.clockwise")
}
.accessibilityLabel("Refresh library")
}
if let libraryError {
Text(libraryError)
.font(.subheadline)
.foregroundStyle(.red)
} else if let tracks = libraryTracks {
if tracks.isEmpty {
Text("Downloaded songs and streams live here.")
.font(.subheadline)
.foregroundStyle(.secondary)
} else {
ForEach(tracks, id: \.id) { track in
HStack(spacing: 8) {
mediaRow(
title: track.title ?? track.id,
detail: rowDetail(
channel: track.channel ?? (track.kind == "stream" ? "Live stream" : ""),
duration: track.duration,
status: track.status
),
playEnabled: track.status == "ready"
) {
Task { await playerController.playTrack(id: track.id) }
}
Button(role: .destructive) {
trackToDelete = track
} label: {
Image(systemName: "xmark")
.font(.subheadline.weight(.semibold))
.foregroundStyle(.red)
}
.accessibilityLabel("Remove \(track.title ?? track.id)")
}
}
}
}
}
}
private var addStreamSection: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Add live stream")
.font(.headline)
TextField("Station name", text: $streamName)
.textFieldStyle(.roundedBorder)
TextField("https://… audio stream URL", text: $streamURL)
.textFieldStyle(.roundedBorder)
.keyboardType(.URL)
.autocorrectionDisabled()
.textContentType(.URL)
.textInputAutocapitalization(.never)
if let streamMessage {
Text(streamMessage)
.font(.subheadline)
.foregroundStyle(streamMessage.hasPrefix("Added") ? Color.green : Color.red)
}
Button("Add", action: addStream)
.buttonStyle(.bordered)
.disabled(streamURL.trimmingCharacters(in: .whitespaces).isEmpty)
}
}
private func mediaRow(
title: String,
detail: String,
playEnabled: Bool,
onPlay: @escaping () -> Void
) -> some View {
HStack(spacing: 10) {
VStack(alignment: .leading, spacing: 2) {
Text(title)
.font(.subheadline.weight(.semibold))
.lineLimit(1)
if !detail.isEmpty {
Text(detail)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
}
Spacer()
Button("Play", action: onPlay)
.buttonStyle(.bordered)
.controlSize(.small)
.disabled(!playEnabled)
}
.padding(10)
.background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 12))
}
private func rowDetail(channel: String?, duration: Double?, status: String? = nil, inLibrary: Bool? = nil) -> String {
var parts: [String] = []
if let channel, !channel.isEmpty { parts.append(channel) }
if let duration, duration > 0 { parts.append(fmtTime(duration)) }
if let status, status != "ready" { parts.append(status) }
if inLibrary == true { parts.append("in library") }
return parts.joined(separator: " · ")
}
private func performSearch() {
let query = searchQuery.trimmingCharacters(in: .whitespaces)
guard !query.isEmpty else { return }
isSearching = true
searchError = nil
Task {
do {
let response = try await MediaAPI(config: serverConfig).search(room: "default", query: query, limit: 6)
searchResults = response.results
} catch {
searchError = error.localizedDescription
}
isSearching = false
}
}
private func refreshLibrary() {
Task {
do {
let library = try await MediaAPI(config: serverConfig).library()
libraryTracks = library.tracks
libraryError = nil
} catch {
libraryError = error.localizedDescription
}
}
}
private func addStream() {
let url = streamURL.trimmingCharacters(in: .whitespaces)
guard !url.isEmpty else { return }
let name = streamName.trimmingCharacters(in: .whitespaces).isEmpty ? url : streamName.trimmingCharacters(in: .whitespaces)
Task {
do {
try await MediaAPI(config: serverConfig).addStream(room: "default", name: name, url: url)
streamMessage = "Added \(name)"
streamName = ""
streamURL = ""
refreshLibrary()
} catch {
streamMessage = "Could not add stream: \(error.localizedDescription)"
}
}
}
private func deleteTrack(_ track: MediaTrack) async {
do {
try await MediaAPI(config: serverConfig).deleteTrack(id: track.id)
if playerController.track?.id == track.id {
Task { await playerController.stop() }
}
refreshLibrary()
} catch {
libraryError = error.localizedDescription
}
}
private var serverConfig: ServerConfig {
// Use the configured URL directly. If it's invalid, fall back to the
// documented default (media operations are less critical than the main
// session, and the Settings view will show a validation error).
if case .success(let config) = ServerConfig.parse(settings.trimmedServerURL) {
return config
}
let fallback = ServerConfig.parse("https://192.168.86.2:9178")
if case .success(let config) = fallback {
return config
}
fatalError("Unreachable: default server URL is valid")
}
private var title: String {
playerController.track?.title ?? "Nothing loaded"
}
private var subtitle: String {
guard let track = playerController.track else { return "" }
if track.status == "failed" {
return "Download failed — pick another result or try again."
}
if track.status == "downloading" {
return track.channel.map { "\($0) · downloading" } ?? "Downloading…"
}
return track.channel ?? (track.kind == "stream" ? "Live stream" : "")
}
private var statusText: String {
switch playerController.desired {
case "playing": return "Playing"
case "paused": return "Paused"
default: return "Stopped"
}
}
private func absolute(_ path: String) -> String {
if path.hasPrefix("http") { return path }
let base = serverConfig.baseURL.absoluteString
let suffix = path.hasPrefix("/") ? path : "/" + path
return base + suffix
}
private func togglePlayPause() {
Task { await playerController.togglePlayPause() }
}
private func seekBackward() {
Task { await playerController.seek(by: -10) }
}
private func seekForward() {
Task { await playerController.seek(by: 10) }
}
private func stopPlayback() {
Task { await playerController.stop() }
}
private func fmtTime(_ sec: Double) -> String {
guard sec.isFinite, sec >= 0 else { return "0:00" }
let total = Int(sec)
let s = total % 60
let m = (total / 60) % 60
let h = total / 3600
return h > 0 ? "\(h):\(String(format: "%02d", m)):\(String(format: "%02d", s))" : "\(m):\(String(format: "%02d", s))"
}
}
@@ -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?
@@ -26,7 +27,7 @@ struct ConnectionTestView: View {
if isBusy {
ProgressView()
} else {
Text("Get token")
Text("Test connection")
}
Spacer()
}
@@ -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("Token endpoint and TLS are working.", 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") {
@@ -57,6 +59,7 @@ struct ConnectionTestView: View {
}
}
.navigationTitle("Connection test")
.navigationBarTitleDisplayMode(.inline)
}
private func runTest() {
@@ -66,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)
+157 -100
View File
@@ -12,8 +12,9 @@ struct SettingsView: View {
@State private var urlText: String = ""
@State private var urlError: String?
@State private var showVoicePicker = false
@State private var showModelPicker = false
@State private var savedURLConfirmation: String?
@State private var pendingServerChange: String?
@FocusState private var urlFocused: Bool
init(settings: SettingsStore, session: VoiceSession) {
self.settings = settings
self.session = session
@@ -30,159 +31,215 @@ struct SettingsView: View {
var body: some View {
Form {
Section("Server") {
Section {
Button {
showVoicePicker = true
} label: {
LabeledContent("Speaking voice", value: Voices.display(for: settings.voiceID) ?? settings.voiceID)
}
Picker("Conversation mode", selection: $settings.textMode) {
Text("Voice").tag(false)
Text("Type · Microphone off").tag(true)
}
} header: {
Text("Make it yours")
} footer: {
Text("Voice and typing preferences are saved on this iPhone. You can switch modes from the Talk tab.")
}
Section {
Text("Server URL")
.font(.footnote.weight(.semibold))
.foregroundStyle(Nearlight.quiet)
TextField(SettingsStore.defaultServerURL, text: $urlText)
.keyboardType(.URL)
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
.accessibilityLabel("Server URL")
.focused($urlFocused)
.onAppear { urlText = settings.serverURL }
.onChange(of: urlText) { _, newValue in
// Keep confirmation across the asynchronous onChange
// caused by successful whitespace normalization.
guard newValue != savedURLConfirmation else { return }
savedURLConfirmation = nil
urlError = nil
}
.overlay(alignment: .trailing) {
if savedURLConfirmation != nil {
Image(systemName: "checkmark.circle")
.foregroundStyle(Nearlight.quiet)
.padding(.trailing, 8)
.accessibilityHidden(true)
}
}
if let urlError {
Text(urlError)
Label(urlError, systemImage: "exclamationmark.circle")
.font(.footnote)
.foregroundStyle(.red)
.foregroundStyle(Nearlight.alert)
.accessibilityIdentifier("settings.server.error")
} else if savedURLConfirmation != nil {
Text("Server address saved.")
.font(.footnote)
.foregroundStyle(Nearlight.quiet)
.accessibilityIdentifier("settings.server.saved")
}
Button("Apply server URL") { applyServerURL() }
.disabled(!canApplyServerURL || urlText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
Button("Change server") { requestServerURLChange() }
.disabled(!canApplyServerURL || urlText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || urlText == settings.serverURL)
if !canApplyServerURL {
Text(session.state == .tearingDown
? "Waiting for failure cleanup to finish…"
: "End the conversation before changing servers.")
.font(.footnote)
.foregroundStyle(.secondary)
.foregroundStyle(Nearlight.quiet)
}
}
Section("Voice") {
Button {
showVoicePicker = true
} label: {
LabeledContent("Voice", value: Voices.display(for: settings.voiceID) ?? settings.voiceID)
}
}
Section("Model") {
if session.isReady, !session.models.isEmpty {
Button {
showModelPicker = true
} label: {
LabeledContent("Model", value: session.selectedModel ?? settings.modelID ?? "—")
}
} else {
Text("Start a conversation to load the model list.")
.font(.footnote)
.foregroundStyle(.secondary)
}
}
Section("Interaction") {
Toggle("Text mode", isOn: $settings.textMode)
}
Section {
NavigationLink("Connection test") {
ConnectionTestView(settings: settings)
ConnectionTestView(settings: settings, bearerToken: session.bootstrapBearerToken)
}
} header: {
Text("Connection")
} footer: {
Text("Hope runs on your own server. Your iPhone needs to be on the same network or a network that can reach it.")
}
}
.navigationTitle("Settings")
.navigationBarTitleDisplayMode(.inline)
.scrollContentBackground(.hidden)
.background(Nearlight.ground)
.tint(Nearlight.rosewood)
.sheet(isPresented: $showVoicePicker) {
VoicePickerView(settings: settings, session: session)
}
.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() {
guard canApplyServerURL else {
urlError = "End the conversation before changing servers."
return
}
private func requestServerURLChange() {
savedURLConfirmation = nil
guard canApplyServerURL else { urlError = "End the conversation before changing servers."; return }
let trimmed = urlText.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else {
urlError = "Enter a server URL."
return
}
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
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
var session: VoiceSession
@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")
.foregroundStyle(.blue)
.foregroundStyle(Nearlight.rosewood)
}
}
}
.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
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(.blue)
}
}
}
}
}
.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() }
+218 -90
View File
@@ -1,137 +1,265 @@
import SwiftUI
/// Nearlight Activity: a working log. Plain rows on Ground, no cards;
/// live updates land in place without reordering while reading.
struct TaskPanelView: View {
@ObservedObject var taskStore: TaskStore
@Environment(\.dynamicTypeSize) private var typeSize
var body: some View {
Group {
if taskStore.sortedTasks.isEmpty {
emptyState
} else {
taskList
}
}
.navigationTitle("Activity")
.background(Nearlight.ground)
}
private var taskList: some View {
ScrollView {
VStack(spacing: 12) {
if taskStore.sortedTasks.isEmpty {
emptyState
} else {
ForEach(taskStore.sortedTasks) { task in
TaskCardView(task: task)
LazyVStack(alignment: .leading, spacing: 0) {
ForEach(taskStore.sortedTasks) { task in
NavigationLink {
TaskDetailView(taskStore: taskStore, taskID: task.id)
} label: {
TaskRowView(task: task)
}
.buttonStyle(.plain)
Divider()
.overlay(Nearlight.separator)
}
}
.padding()
.padding(.horizontal, Nearlight.s20)
}
.navigationTitle("Background tasks")
#if DEBUG
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("Sample", action: { taskStore.injectSampleEvents() })
.font(.caption)
}
}
#endif
}
private var emptyState: some View {
VStack(spacing: 8) {
Text("Nothing running")
.font(.headline)
Text("Ask Hope to research or compare something.")
.font(.subheadline)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
VStack(alignment: .leading, spacing: Nearlight.s8) {
Image(systemName: "checklist")
.font(.system(size: 28))
.foregroundStyle(Nearlight.quiet)
Text("No tasks yet")
.font(NearlightFont.heading(typeSize))
Text("Tasks from your conversations will appear here.")
.font(.body)
.foregroundStyle(Nearlight.quiet)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 48)
.padding(.top, Nearlight.s32)
.padding(.horizontal, Nearlight.s20)
.frame(maxWidth: .infinity, alignment: .leading)
.accessibilityIdentifier("activity.empty")
}
}
struct TaskCardView: View {
/// One activity row: status symbol, title, current step/status, chevron.
private struct TaskRowView: View {
let task: TaskItem
@Environment(\.dynamicTypeSize) private var typeSize
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 8) {
statusDot
HStack(spacing: Nearlight.s12) {
Image(systemName: statusSymbol)
.font(.system(size: 20))
.foregroundStyle(statusColor)
.accessibilityHidden(true)
VStack(alignment: .leading, spacing: Nearlight.s4) {
Text(statusTitle)
.font(NearlightFont.support(typeSize))
.foregroundStyle(statusColor)
.fixedSize(horizontal: false, vertical: true)
Text(task.description)
.font(.subheadline.weight(.semibold))
.lineLimit(2)
Spacer()
.font(NearlightFont.control(typeSize))
.foregroundStyle(Nearlight.ink)
.fixedSize(horizontal: false, vertical: true)
if let latest = latestStep {
Text(latest)
.font(NearlightFont.support(typeSize))
.foregroundStyle(Nearlight.quiet)
.fixedSize(horizontal: false, vertical: true)
}
Text(task.createdAt, style: .time)
.font(NearlightFont.metadata(typeSize))
.foregroundStyle(Nearlight.quiet)
.monospacedDigit()
}
if !task.steps.isEmpty {
VStack(alignment: .leading, spacing: 4) {
ForEach(task.steps) { step in
HStack(alignment: .top, spacing: 6) {
Text(stepLabel(step.role))
.font(.caption2.weight(.semibold))
.foregroundStyle(stepColor(step.role))
Text(step.content ?? "")
.font(.caption)
.foregroundStyle(stepColor(step.role))
.lineLimit(3)
Spacer(minLength: 0)
Image(systemName: "chevron.right")
.font(.system(size: 13, weight: .medium))
.foregroundStyle(Nearlight.quiet)
.accessibilityHidden(true)
}
.padding(.vertical, Nearlight.s16)
.contentShape(Rectangle())
}
private var statusTitle: String {
switch task.status {
case .pending: return "Queued"
case .running: return "In progress"
case .completed: return "Complete"
case .failed: return "Needs attention"
}
}
private var latestStep: String? {
if let error = task.error { return error }
if let result = task.result { return result }
return task.steps.last?.content
}
private var statusSymbol: String {
switch task.status {
case .pending: return "circle.dotted"
case .running: return "arrow.triangle.2.circlepath"
case .completed: return "checkmark.circle"
case .failed: return "exclamationmark.circle"
}
}
private var statusColor: Color {
switch task.status {
case .pending: return Nearlight.quiet
case .running: return Nearlight.rosewood
case .completed: return Nearlight.quiet
case .failed: return Nearlight.alert
}
}
}
/// Native push detail for one task: title, status, steps, result/error.
/// Looks the task up live in the store so removal shows the missing state.
struct TaskDetailView: View {
@ObservedObject var taskStore: TaskStore
let taskID: String
@Environment(\.dynamicTypeSize) private var typeSize
private var task: TaskItem? {
taskStore.tasks[taskID]
}
var body: some View {
Group {
if let task {
TaskDetailContent(task: task)
} else {
Text("This task is no longer available.")
.font(.body)
.foregroundStyle(Nearlight.quiet)
.padding(Nearlight.s20)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
.background(Nearlight.ground)
.navigationTitle("Task")
.navigationBarTitleDisplayMode(.inline)
}
}
private struct TaskDetailContent: View {
let task: TaskItem
@Environment(\.dynamicTypeSize) private var typeSize
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: Nearlight.s24) {
VStack(alignment: .leading, spacing: Nearlight.s12) {
Text(task.description)
.font(NearlightFont.heading(typeSize))
.fixedSize(horizontal: false, vertical: true)
Label(statusTitle, systemImage: statusSymbol)
.font(NearlightFont.support(typeSize))
.foregroundStyle(statusColor)
Text(task.createdAt, style: .time)
.font(NearlightFont.metadata(typeSize))
.foregroundStyle(Nearlight.quiet)
.monospacedDigit()
}
VStack(alignment: .leading, spacing: 0) {
Text("Steps")
.font(NearlightFont.heading(typeSize))
.padding(.bottom, Nearlight.s8)
if task.steps.isEmpty {
Text("No steps reported yet.")
.font(.body)
.foregroundStyle(Nearlight.quiet)
} else {
ForEach(task.steps) { step in
VStack(alignment: .leading, spacing: Nearlight.s4) {
if let role = step.role {
Text(roleLabel(role))
.font(NearlightFont.support(typeSize).weight(.semibold))
.foregroundStyle(Nearlight.quiet)
}
Text(step.content ?? "")
.font(.body)
.foregroundStyle(Nearlight.ink)
.textSelection(.enabled)
.fixedSize(horizontal: false, vertical: true)
}
.padding(.vertical, Nearlight.s16)
}
}
}
if let result = task.result {
Text(result)
.font(.body)
.foregroundStyle(Nearlight.ink)
.textSelection(.enabled)
}
if let error = task.error {
Text(error)
.font(.body)
.foregroundStyle(Nearlight.alert)
.textSelection(.enabled)
}
}
if let result = task.result {
block(result, color: .green, background: Color.green.opacity(0.12))
}
if let error = task.error {
block(error, color: .red, background: Color.red.opacity(0.12))
}
.padding(.horizontal, Nearlight.s20)
.padding(.top, Nearlight.s24)
.padding(.bottom, Nearlight.s24)
}
.padding(12)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 14))
}
private var statusDot: some View {
Circle()
.fill(dotColor)
.frame(width: 9, height: 9)
}
private var dotColor: Color {
private var statusTitle: String {
switch task.status {
case .pending: return .gray
case .running: return .blue
case .completed: return .green
case .failed: return .red
case .pending: return "Queued"
case .running: return "In progress"
case .completed: return "Complete"
case .failed: return "Needs attention"
}
}
private func block(_ text: String, color: Color, background: Color) -> some View {
Text(text)
.font(.subheadline)
.foregroundStyle(color)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(10)
.background(background, in: RoundedRectangle(cornerRadius: 10))
private var statusSymbol: String {
switch task.status {
case .pending: return "circle.dotted"
case .running: return "arrow.triangle.2.circlepath"
case .completed: return "checkmark.circle"
case .failed: return "exclamationmark.circle"
}
}
private func stepLabel(_ role: String?) -> String {
private var statusColor: Color {
switch task.status {
case .pending: return Nearlight.quiet
case .running: return Nearlight.rosewood
case .completed: return Nearlight.quiet
case .failed: return Nearlight.alert
}
}
private func roleLabel(_ role: String) -> String {
switch role {
case "thinking": return "Thinking"
case "tool_call": return "Tool"
case "tool_result": return "Result"
case "text": return "Note"
default: return role ?? "Step"
}
}
private func stepColor(_ role: String?) -> Color {
switch role {
case "thinking": return .blue
case "tool_call": return .indigo
case "tool_result": return .secondary
default: return .primary
default: return role
}
}
}
#Preview {
NavigationStack {
TaskPanelView(taskStore: {
let store = TaskStore()
#if DEBUG
store.injectSampleEvents()
#endif
return store
}())
TaskPanelView(taskStore: TaskStore())
}
}
+1 -37
View File
@@ -2,19 +2,12 @@ import Foundation
import LiveKit
/// Outbound helpers for agent control topics and inbound parsing of
/// voice-control / conversation-control / media-control data messages.
/// voice-control / conversation-control data messages.
@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,23 +74,10 @@ 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)
}
func sendMediaState(_ state: String, trackID: String?, room: Room) async throws {
var object: [String: Any] = ["type": "media_state", "state": state]
if let trackID { object["track_id"] = trackID }
try await publish(object, topic: "media-control", room: room)
}
}
// MARK: - Minimal JSON value decoder for flexible agent payloads
+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 = UserDefaults.standard.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 }
UserDefaults.standard.set(String(decoding: data, as: UTF8.self), forKey: Self.key)
AppPreferences.store.set(String(decoding: data, as: UTF8.self), forKey: storageKey)
}
}
+104 -67
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,10 +45,9 @@ 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()
let mediaController: MediaPlayerController
private let config: ServerConfig
private let tokenClient: TokenClient
private let settings: SettingsStore
@@ -86,13 +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
let mediaAPI = MediaAPI(config: config)
self.mediaController = MediaPlayerController(api: mediaAPI, settings: settings)
mediaController.attach(session: self)
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)
@@ -111,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:
@@ -137,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
@@ -154,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
@@ -210,8 +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)")
await mediaController.enterMediaScope(roomName: token.room)
guard generation == sessionGeneration, !Task.isCancelled, !isTearingDown else { return }
try await room.connect(url: config.livekitURL.absoluteString, token: token.token, connectOptions: makeConnectOptions())
result = .success(())
} catch {
@@ -263,8 +263,7 @@ final class VoiceSession: ObservableObject {
private func handleAgentTimeout(generation: Int) async {
guard generation == sessionGeneration, state == .waitingAgent else { return }
guard !reconnectAttempted else {
// Final failure: tear down the live room, restore the idle timer,
// and end the media scope so no stale room or wake lock survives.
// Final failure: tear down the live room and restore the idle timer.
logger.log("agent timeout final, tearing down")
await teardownForFailure(message: "Agent never joined. Is the agent worker running?")
return
@@ -281,8 +280,7 @@ final class VoiceSession: ObservableObject {
/// current generation (so stale callbacks become no-ops), clear all
/// per-room/session ephemeral published fields (agent, mute state, model
/// list, error state, interruption ownership), disconnect and recreate the
/// room, stop conversation state, restore the idle timer, and end the media
/// scope. Only after all cleanup completes does the session publish
/// room, stop conversation state, restore the idle timer, and publish
/// `.failed(message)`, making retry safe: the old room is fully torn down
/// before Start becomes available again.
///
@@ -308,7 +306,6 @@ final class VoiceSession: ObservableObject {
interruptionSequence += 1
stateModel.stop()
UIApplication.shared.isIdleTimerDisabled = false
await mediaController.endConversation()
await teardownRoom()
// Final reset immediately before publishing .failed: any old-room
@@ -335,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
}
@@ -359,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)
@@ -369,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 {
@@ -391,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
}
}
@@ -435,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).
@@ -444,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
@@ -469,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.
@@ -480,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
@@ -510,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()
}
}
@@ -526,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
@@ -540,15 +557,13 @@ final class VoiceSession: ObservableObject {
interruptionSequence += 1
stateModel.stop()
UIApplication.shared.isIdleTimerDisabled = false
await mediaController.endConversation()
await teardownRoom()
// Final reset before publishing .idle: any old-room callback delivered
// during the teardownRoom() await may have repopulated ephemeral fields.
// This ensures the idle state is clean for server replacement (Iteration 09).
resetEphemeralFields()
// Publish .idle only after media and room teardown have completed.
// This makes idle a truthful readiness signal for server replacement:
// the old session is fully torn down before the UI shows "ready to start".
// Publish .idle only after room teardown has completed: the old session
// is fully torn down before the UI shows "ready to start".
state = .idle
}
@@ -661,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 {
@@ -676,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)
}
@@ -728,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),
-249
View File
@@ -1,249 +0,0 @@
import Foundation
struct MediaTrack: Decodable, Equatable {
let id: String
let kind: String?
let videoID: String?
let title: String?
let channel: String?
let duration: Double?
let status: String?
let error: String?
let audioURL: String?
let artURL: String?
enum CodingKeys: String, CodingKey {
case id, kind, title, channel, duration, status, error
case videoID = "video_id"
case audioURL = "audio_url"
case artURL = "art_url"
}
}
struct MediaPendingJob: Decodable, Equatable {
let jobID: String?
let trackID: String?
let stage: String?
let progress: Double?
let error: String?
enum CodingKeys: String, CodingKey {
case stage, progress, error
case jobID = "job_id"
case trackID = "track_id"
}
}
struct MediaSeekHint: Decodable, Equatable {
let ts: Double
let position: Double
}
struct MediaState: Decodable, Equatable {
let room: String?
let track: MediaTrack?
let desired: String?
let reported: MediaReported?
let seekHint: MediaSeekHint?
let choices: [MediaChoice]
let pending: [MediaPendingJob]
let version: Int?
enum CodingKeys: String, CodingKey {
case room, track, desired, reported, choices, pending, version
case seekHint = "seek_hint"
}
}
struct MediaReported: Decodable, Equatable {
let trackID: String?
let state: String?
let position: Double?
let duration: Double?
let ts: Double?
enum CodingKeys: String, CodingKey {
case state, position, duration, ts
case trackID = "track_id"
}
}
struct MediaChoice: Decodable, Equatable {
let videoID: String?
let title: String?
let channel: String?
let duration: Double?
enum CodingKeys: String, CodingKey {
case title, channel, duration
case videoID = "video_id"
}
}
struct MediaLibrary: Decodable, Equatable {
let tracks: [MediaTrack]
let version: Int?
}
struct MediaSearchResult: Decodable, Equatable {
let videoID: String?
let trackID: String?
let title: String?
let channel: String?
let duration: Double?
let inLibrary: Bool?
enum CodingKeys: String, CodingKey {
case title, channel, duration
case videoID = "video_id"
case trackID = "track_id"
case inLibrary = "in_library"
}
}
struct MediaSearchResponse: Decodable, Equatable {
let results: [MediaSearchResult]
}
enum MediaAPIError: LocalizedError {
case invalidServerURL(String)
case httpStatus(Int, String)
case decodeFailed
var errorDescription: String? {
switch self {
case .invalidServerURL(let raw):
return "Invalid server URL: \(raw)"
case .httpStatus(let code, let detail):
return "Media API request failed with HTTP \(code). \(detail)"
case .decodeFailed:
return "Unexpected response from the media endpoint."
}
}
}
final class MediaAPI {
let serverBase: URL
private let baseURL: URL
private let session: URLSession
init(config: ServerConfig) {
self.serverBase = config.baseURL
self.baseURL = config.baseURL.appendingPathComponent("media/api")
self.session = TLSPinning.session()
}
func state(room: String) async throws -> MediaState {
var components = URLComponents(url: baseURL.appendingPathComponent("state"), resolvingAgainstBaseURL: false)!
components.queryItems = [URLQueryItem(name: "room", value: room)]
return try await get(MediaState.self, url: components.url!)
}
func report(room: String, state: String, trackID: String?, position: Double, duration: Double?) async throws {
var body: [String: Any] = ["room": room, "state": state, "position": position]
if let trackID { body["track_id"] = trackID }
if let duration { body["duration"] = duration }
try await post("report", body: body)
}
func play(room: String, videoID: String? = nil, title: String? = nil, channel: String? = nil, trackID: String? = nil, startPaused: Bool? = nil) async throws {
var body: [String: Any] = ["room": room]
if let videoID { body["video_id"] = videoID }
if let title { body["title"] = title }
if let channel { body["channel"] = channel }
if let trackID { body["track_id"] = trackID }
if let startPaused { body["start_paused"] = startPaused }
try await post("play", body: body)
}
func control(room: String, action: String) async throws {
try await post("control", body: ["room": room, "action": action])
}
func search(room: String, query: String, limit: Int = 6) async throws -> MediaSearchResponse {
try await postDecoded("search", body: ["room": room, "query": query, "limit": limit], as: MediaSearchResponse.self)
}
func library() async throws -> MediaLibrary {
try await get(MediaLibrary.self, url: baseURL.appendingPathComponent("library"))
}
func deleteTrack(id: String) async throws {
var request = URLRequest(url: baseURL.appendingPathComponent("tracks").appending(path: id))
request.httpMethod = "DELETE"
let (data, response) = try await session.data(for: request)
guard let http = response as? HTTPURLResponse else { throw MediaAPIError.decodeFailed }
guard (200..<300).contains(http.statusCode) else {
throw MediaAPIError.httpStatus(http.statusCode, String(data: data, encoding: .utf8) ?? "")
}
}
func addStream(room: String, name: String, url: String) async throws {
try await post("streams", body: ["room": room, "name": name, "url": url])
}
/// Returns the progressive-media endpoint URL for a track. The `/stream/{id}`
/// path serves raw media bytes (not a text URL), so this constructs the
/// endpoint without downloading or decoding the body.
func streamURL(trackID: String) -> URL {
baseURL.appendingPathComponent("stream").appending(path: trackID)
}
func healthz() async throws -> Bool {
let data = try await getRaw(url: baseURL.appendingPathComponent("healthz"))
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return false }
return json["ok"] as? Bool ?? false
}
private func get<T: Decodable>(_ type: T.Type, url: URL) async throws -> T {
let data = try await getRaw(url: url)
do {
return try JSONDecoder().decode(T.self, from: data)
} catch {
throw MediaAPIError.decodeFailed
}
}
private func getRaw(url: URL) async throws -> Data {
let (data, response) = try await session.data(from: url)
guard let http = response as? HTTPURLResponse else { throw MediaAPIError.decodeFailed }
guard (200..<300).contains(http.statusCode) else {
throw MediaAPIError.httpStatus(http.statusCode, String(data: data, encoding: .utf8) ?? "")
}
return data
}
/// Fire-and-forget POST that only validates HTTP success. The media server
/// returns `{"ok": true, ...snapshot}` for /play, /control, /report and
/// /streams — callers don't need the body, so decoding it (as Data or any
/// typed model) would be wasted work at best and a spurious failure mode.
private func post(_ path: String, body: [String: Any]) async throws {
_ = try await postRaw(path, body: body)
}
private func postDecoded<T: Decodable>(_ path: String, body: [String: Any], as type: T.Type) async throws -> T {
let data = try await postRaw(path, body: body)
do {
return try JSONDecoder().decode(T.self, from: data)
} catch {
throw MediaAPIError.decodeFailed
}
}
private func postRaw(_ path: String, body: [String: Any]) async throws -> Data {
var request = URLRequest(url: baseURL.appendingPathComponent(path))
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try JSONSerialization.data(withJSONObject: body)
let (data, response) = try await session.data(for: request)
guard let http = response as? HTTPURLResponse else { throw MediaAPIError.decodeFailed }
guard (200..<300).contains(http.statusCode) else {
var detail = String(data: data, encoding: .utf8) ?? ""
if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let error = json["error"] as? String {
detail = error
}
throw MediaAPIError.httpStatus(http.statusCode, detail)
}
return data
}
}
-595
View File
@@ -1,595 +0,0 @@
import AVFoundation
import Combine
import Foundation
import os
private let logger = Logger(subsystem: "dev.shane.voice", category: "media")
/// AVPlayer-backed media player. The media server holds per-room desired state;
/// this controller polls /state and reconciles the local player against it,
/// porting the web UI's reconciliation rules (app.js reconcileMedia).
@MainActor
final class MediaPlayerController: ObservableObject {
@Published private(set) var track: MediaTrack?
@Published private(set) var desired: String = "stopped"
@Published private(set) var currentTime: Double = 0
@Published private(set) var duration: Double = 0
@Published private(set) var pendingJobs: [MediaPendingJob] = []
@Published private(set) var choices: [MediaChoice] = []
@Published private(set) var stateError: String?
let player = AVPlayer()
private let api: MediaAPI
private let settings: SettingsStore
private let mediaLoader: PinnedMediaResourceLoader
private weak var session: VoiceSession?
private var room: String?
private var currentTrackID: String?
private var loadedAudioPath: String?
private var currentAsset: AVURLAsset?
private var appliedSeekTs: Double = 0
private var wasPlayingBeforeTalk = false
private var lastReportDate = Date.distantPast
private var pollTask: Task<Void, Never>?
private var timeObserver: Any?
private var statusCancellable: AnyCancellable?
private var rateObservation: NSKeyValueObservation?
/// Bumped on every room-scope change so in-flight /state polls from the
/// previous scope cannot reconcile into the new one (port of the web UI's
/// `=== room` checks).
private var pollGeneration = 0
init(api: MediaAPI, settings: SettingsStore) {
self.api = api
self.settings = settings
self.mediaLoader = PinnedMediaResourceLoader(base: api.serverBase)
player.volume = Float(settings.volume)
NotificationCenter.default.addObserver(
self, selector: #selector(handleInterruption),
name: AVAudioSession.interruptionNotification, object: nil
)
}
deinit {
if let timeObserver {
player.removeTimeObserver(timeObserver)
}
rateObservation?.invalidate()
NotificationCenter.default.removeObserver(self)
}
/// Whether an audio session interruption is currently active. While true,
/// reconciliation must not call player.play() (the next poll would restart
/// audio during a phone call or Siri interaction).
private var isInterruptionActive = false
/// Separate resume-suppression flag: set when an interruption ends without
/// `.shouldResume` and the pause control to the server has not yet been
/// confirmed. While true, reconciliation must not call player.play() even
/// after `isInterruptionActive` is cleared. Cleared only by an explicit user
/// play/resume action or a confirmed coherent paused state from the server.
private var resumeSuppressed = false
/// Whether playback was actually active (rate > 0) when the interruption
/// began. Used to decide whether a `.shouldResume` end should actually
/// resume (a track that was already paused before the interruption must not
/// be force-resumed).
private var wasPlayingBeforeInterruption = false
/// Sequence number for media interruption operations. Incremented on every
/// `.began`; a resume task captures the sequence and rechecks it immediately
/// before `player.play()`. A newer `.began` makes an old resume task
/// incapable of playing (Iteration 07 Item 5).
private var mediaInterruptionSequence = 0
/// The retained operation chain for media interruption control work. Both
/// pause and resume operations are assigned here so a newer begin cancels
/// and supersedes prior control work.
private var mediaInterruptionTask: Task<Void, Never>?
@objc private func handleInterruption(_ notification: Notification) {
guard let info = notification.userInfo else { return }
guard let typeValue = info[AVAudioSessionInterruptionTypeKey] as? UInt,
let type = AVAudioSession.InterruptionType(rawValue: typeValue) else { return }
switch type {
case .began:
// Increment the sequence so any in-flight resume task from a prior
// interruption sees a stale sequence and cannot play.
mediaInterruptionSequence += 1
let seq = mediaInterruptionSequence
// Cancel/supersede prior control work (resume or pause) so it cannot
// complete out of order.
mediaInterruptionTask?.cancel()
isInterruptionActive = true
wasPlayingBeforeInterruption = !player.rate.isZero
let wasPlaying = wasPlayingBeforeInterruption
player.pause()
logger.log("audio interruption began, paused media (wasPlaying=\(wasPlaying), seq=\(seq))")
case .ended:
defer { isInterruptionActive = false }
applyVolume()
let optionsValue = info[AVAudioSessionInterruptionOptionKey] as? UInt ?? 0
let shouldResume = AVAudioSession.InterruptionOptions(rawValue: optionsValue).contains(.shouldResume)
if shouldResume, wasPlayingBeforeInterruption, currentTrackID != nil, desired == "playing", loadedAudioPath != nil {
// Synchronize the server before resuming local playback so the
// two never diverge. Capture generation/room/sequence before the
// async hop. The task is retained in mediaInterruptionTask so a
// newer .began can cancel it.
let gen = pollGeneration
let targetRoom = room
let seq = mediaInterruptionSequence
mediaInterruptionTask = Task { [weak self] in
guard let self else { return }
if let targetRoom {
do {
try await self.api.control(room: targetRoom, action: "resume")
logger.log("interruption resume: server synchronized (seq=\(seq))")
} catch {
logger.log("interruption resume: server sync failed (poll will retry): \(error.localizedDescription)")
}
}
// Recheck the sequence immediately before play: a newer .began
// increments mediaInterruptionSequence, making this stale task
// incapable of restarting audio during the newer interruption.
guard !Task.isCancelled,
self.mediaInterruptionSequence == seq,
!self.isInterruptionActive,
self.pollGeneration == gen,
self.room == targetRoom else { return }
self.player.play()
logger.log("audio interruption ended, resumed media (seq=\(seq))")
}
} else {
// No-resume path: keep suppression even if the pause control
// fails. The flag is cleared only by an explicit user play/resume
// action or a confirmed coherent paused state from the server.
resumeSuppressed = true
let seq = mediaInterruptionSequence
if let room {
mediaInterruptionTask = Task { [weak self] in
guard let self else { return }
do {
try await self.api.control(room: room, action: "pause")
logger.log("audio interruption ended without resume, sent pause to server (seq=\(seq))")
} catch {
// Keep suppression even on failure — the next poll
// will see desired=="paused" and clear it.
logger.log("audio interruption ended without resume, pause control failed (suppression retained): \(error.localizedDescription)")
}
}
}
}
@unknown default:
break
}
}
// MARK: Lifecycle
func attach(session: VoiceSession) {
self.session = session
observePlayer()
observePlaybackState()
applyVolume()
}
/// Called just before joining the LiveKit room. Carries whatever is loaded
/// into the new room-scoped player state, paused (talking pauses music).
func enterMediaScope(roomName: String) async {
pollGeneration += 1
let wasPlaying = currentTrackID != nil && !player.rate.isZero
wasPlayingBeforeTalk = wasPlaying
let carriedTrack = currentTrackID
room = roomName
if let carriedTrack {
do {
try await api.play(room: roomName, trackID: carriedTrack, startPaused: true)
logger.log("media scope carry-over: \(carriedTrack, privacy: .public) start_paused")
} catch {
logger.log("media scope transfer failed: \(String(describing: error), privacy: .public)")
}
player.pause()
}
startPolling()
}
/// Called when the conversation ends. If music was playing before the talk,
/// hand the room back to the player. The previous scope (room name) is kept
/// so the panel can resume polling it via `startDefaultPolling()`; a fresh
/// conversation re-scopes via `enterMediaScope`. Polling restarts here so
/// the resumed state stays visible without the panel being open.
func endConversation() async {
stopPolling()
pollGeneration += 1
if wasPlayingBeforeTalk {
wasPlayingBeforeTalk = false
guard let room else { return }
do {
try await api.control(room: room, action: "resume")
logger.log("resumed playback after talk ended")
} catch {
logger.log("post-talk resume failed (poll will retry): \(String(describing: error), privacy: .public)")
}
}
startPolling()
}
/// Drop the room scope entirely (app-level teardown). After this, polling
/// targets the default room again.
func resetScope() {
stopPolling()
pollGeneration += 1
room = nil
wasPlayingBeforeTalk = false
}
private func startPolling() {
guard pollTask == nil else { return }
pollTask = Task { [weak self] in
while !Task.isCancelled {
await self?.pollState()
try? await Task.sleep(for: .milliseconds(1500))
}
}
}
private func stopPolling() {
pollTask?.cancel()
pollTask = nil
}
/// Resume polling whenever the panel becomes visible and nothing is being
/// polled yet — whether that's the default room (no session) or a previous
/// conversation's room scope (kept after `endConversation`).
func startDefaultPolling() {
guard pollTask == nil else { return }
startPolling()
}
// MARK: Poll + reconcile (ported from app.js pollMediaState/reconcileMedia)
private func pollState() async {
let generation = pollGeneration
let scope = room ?? "default"
do {
let state = try await api.state(room: scope)
guard generation == pollGeneration else { return }
stateError = nil
reconcile(state)
} catch {
guard generation == pollGeneration else { return }
logger.log("poll failed: \(String(describing: error), privacy: .public)")
stateError = "Media server unreachable: \(error.localizedDescription)"
}
}
/// Reconciles the local player against a server state snapshot. Exposed
/// as internal (not private) so tests can drive deterministic state
/// transitions without a live server.
func reconcile(_ s: MediaState) {
choices = s.choices
pendingJobs = s.pending
// Keep the published desired state in sync with the server so the UI's
// play/pause icon and status text reflect reality (the server is the
// source of truth for per-room desired state).
desired = s.desired ?? "stopped"
let track = s.track
if let track, track.id != currentTrackID {
loadTrack(track)
} else if let track, let audioURL = track.audioURL, loadedAudioPath != audioURL {
// Track was loaded before it had a stream (still downloading) and
// now has one — wire it up so playback can start without a reload.
wireSource(audioURL)
}
if track == nil, currentTrackID != nil, s.desired == "stopped" {
clearMedia()
}
applyDesiredState(s, track: track)
if let hint = s.seekHint, hint.ts != appliedSeekTs, duration.isFinite, duration > 0 {
appliedSeekTs = hint.ts
let target = min(hint.position, duration)
if target.isFinite {
seek(to: max(0, target))
}
}
}
private func applyDesiredState(_ s: MediaState, track: MediaTrack?) {
guard let desiredValue = s.desired else { return }
switch desiredValue {
case "playing":
// Suppress play during an active audio interruption (phone call,
// Siri). Without this guard the next 1.5s poll would restart audio
// immediately after .began paused it. The player.rate.isZero check
// makes this idempotent: once playing, subsequent polls are no-ops.
// This also handles the late-audio case where desired=playing
// arrives before audio_url is set; when the source is later wired
// via wireSource(), the next poll retries play because rate is
// still zero.
// Also suppress while resumeSuppressed is true (no-resume
// interruption ended, server not yet confirmed paused).
guard !isInterruptionActive, !resumeSuppressed else { return }
if let track, track.audioURL != nil, loadedAudioPath != nil, player.rate.isZero {
player.play()
}
case "paused", "stopped":
if !player.rate.isZero {
player.pause()
}
// The server has confirmed a coherent paused/stopped state. If we
// were suppressing resume due to a no-resume interruption, the
// pause control succeeded (or the user paused independently), so
// it is safe to clear the suppression flag.
if resumeSuppressed {
resumeSuppressed = false
logger.log("resume suppression cleared: server confirmed \(desiredValue)")
}
default:
break
}
}
private func loadTrack(_ track: MediaTrack) {
currentTrackID = track.id
self.track = track
if let audioURL = track.audioURL {
wireSource(audioURL)
} else {
// Downloading or failed: no stream yet. Reconcile wires the stream
// URL in via polling as soon as one is available.
clearCurrentAsset()
player.replaceCurrentItem(with: nil)
}
}
private func wireSource(_ path: String) {
guard let url = URL(string: path) else { return }
loadedAudioPath = path
let pinned = mediaLoader.rewrite(url)
// Remove the previous asset's loader mapping before wiring a new one so
// the loaderURLs dictionary stays bounded (Finding 11 / Item 7).
clearCurrentAsset()
let asset = mediaLoader.assetWithPinnedURL(pinned)
currentAsset = asset
logger.log("wiring source \(pinned.absoluteString, privacy: .public)")
let item = AVPlayerItem(asset: asset)
observeItem(item)
player.replaceCurrentItem(with: item)
}
/// Removes the loader mapping for the current asset (if any) and clears the
/// reference. Called before wiring a new source or clearing media so the
/// resource loader's internal dictionaries stay bounded.
private func clearCurrentAsset() {
if let asset = currentAsset {
mediaLoader.removeLoader(asset)
currentAsset = nil
}
}
// MARK: Player events
private func observePlayer() {
if timeObserver == nil {
timeObserver = player.addPeriodicTimeObserver(
forInterval: CMTime(seconds: 0.5, preferredTimescale: 600), queue: .main
) { [weak self] time in
Task { @MainActor [weak self] in
guard let self else { return }
self.currentTime = time.seconds
if let itemDuration = self.player.currentItem?.duration {
let seconds = itemDuration.seconds
if seconds.isFinite {
self.duration = seconds
}
}
if !self.player.rate.isZero, Date().timeIntervalSince(self.lastReportDate) >= 5 {
self.lastReportDate = Date()
self.report("playing")
}
}
}
}
}
// MARK: - Playback state observation
/// Observe the player's actual rate so every real play/pause transition is
/// reported promptly, mirroring the web UI's `play`/`pause` listeners. The
/// KVO block runs on an arbitrary queue; hop to MainActor before touching
/// any controller state.
private func observePlaybackState() {
rateObservation = player.observe(\.rate, options: [.initial, .new]) { [weak self] player, _ in
Task { @MainActor [weak self] in
guard let self else { return }
guard self.currentTrackID != nil else { return }
if player.rate > 0 {
// Actual playback began (user play, interruption resume, or
// reconcile-driven play). Report + publish the transition.
self.lastReportDate = Date()
self.report("playing")
self.publishMediaState("playing")
} else if player.currentItem != nil {
// Rate dropped to zero with an item still loaded: a pause
// that didn't originate from a user action (interruption,
// reconcile). User-initiated pauses already report before
// this fires; the lastReportDate guard makes duplicates no-ops.
if Date().timeIntervalSince(self.lastReportDate) >= 1 {
self.lastReportDate = Date()
self.report("paused")
self.publishMediaState("paused")
}
}
}
}
}
private func observeItem(_ item: AVPlayerItem?) {
NotificationCenter.default.removeObserver(
self, name: .AVPlayerItemDidPlayToEndTime, object: nil
)
guard let item else { return }
NotificationCenter.default.addObserver(
self, selector: #selector(playerItemDidEnd(_:)),
name: .AVPlayerItemDidPlayToEndTime, object: item
)
statusCancellable = item.publisher(for: \.status).sink { status in
switch status {
case .readyToPlay:
logger.log("item readyToPlay")
case .failed:
logger.log("item failed: \(String(describing: item.error), privacy: .public)")
default:
break
}
}
}
@objc private func playerItemDidEnd(_ notification: Notification) {
lastReportDate = Date()
report("stopped")
publishMediaState("stopped")
Task { [weak self] in
guard let self, let room = self.room else { return }
try? await self.api.control(room: room, action: "stop")
}
}
// MARK: Reporting + publishing
private func report(_ state: String, trackID: String? = nil) {
guard let room else { return }
let position = currentTime.isFinite ? currentTime : 0
var durationValue: Double?
if duration.isFinite, duration > 0 {
durationValue = duration
}
let resolvedTrackID = trackID ?? currentTrackID
Task { [weak self] in
guard let self else { return }
do {
try await self.api.report(room: room, state: state, trackID: resolvedTrackID, position: position, duration: durationValue)
} catch {
logger.log("report failed: \(String(describing: error), privacy: .public)")
}
}
}
private func publishMediaState(_ state: String, trackID: String? = nil) {
guard let session else { return }
// Capture the exact room and generation before spawning so a delayed
// publish from an old lifecycle cannot escape into a new conversation
// room (Finding 10).
let targetRoom = session.room
let generation = session.sessionGeneration
let pollGen = pollGeneration
let resolvedTrackID = trackID ?? currentTrackID
Task { [weak self] in
guard let self, self.pollGeneration == pollGen else { return }
guard session.sessionGeneration == generation, session.room === targetRoom else { return }
do {
try await session.controlChannel.sendMediaState(state, trackID: resolvedTrackID, room: targetRoom)
} catch {
logger.log("media_state publish failed: \(String(describing: error), privacy: .public)")
}
}
}
// MARK: Play requests (search results, choices, library)
func playVideo(videoID: String?, title: String?, channel: String?) async {
let targetRoom = room ?? "default"
do {
try await api.play(room: targetRoom, videoID: videoID, title: title, channel: channel)
logger.log("play requested video_id=\(videoID ?? "nil", privacy: .public) room=\(targetRoom, privacy: .public)")
} catch {
logger.log("play failed: \(String(describing: error), privacy: .public)")
}
}
func playTrack(id: String) async {
let targetRoom = room ?? "default"
do {
try await api.play(room: targetRoom, trackID: id)
logger.log("play requested track_id=\(id, privacy: .public) room=\(targetRoom, privacy: .public)")
} catch {
logger.log("play failed: \(String(describing: error), privacy: .public)")
}
}
// MARK: Transport controls (parity with web UI play/pause/seek/stop)
func togglePlayPause() async {
guard currentTrackID != nil, loadedAudioPath != nil else { return }
if player.rate.isZero {
// Explicit user play: clear any resume suppression (the user is
// deliberately overriding the no-resume interruption state).
resumeSuppressed = false
// The rate observer reports + publishes the actual transition;
// the control call updates server desired state.
player.play()
if let room { try? await api.control(room: room, action: "resume") }
} else {
lastReportDate = Date()
report("paused")
publishMediaState("paused")
player.pause()
if let room { try? await api.control(room: room, action: "pause") }
}
}
func seek(by delta: Double) async {
guard loadedAudioPath != nil else { return }
let target = max(0, min(duration > 0 ? duration - 0.5 : .infinity, currentTime + delta))
if target.isFinite {
seek(to: target)
}
}
func seek(to position: Double) {
player.seek(to: CMTime(seconds: position, preferredTimescale: 600))
currentTime = position
report(player.rate.isZero ? "paused" : "playing")
}
func stop() async {
// Capture the track ID before clearing so the async report/publish
// tasks can read a non-nil value even after clearMedia() runs.
let trackID = currentTrackID
report("stopped", trackID: trackID)
clearMedia()
if let room { try? await api.control(room: room, action: "stop") }
publishMediaState("stopped", trackID: trackID)
}
private func clearMedia() {
currentTrackID = nil
track = nil
loadedAudioPath = nil
// Remove the loader mapping for the asset being cleared so the
// resource loader's internal dictionaries stay bounded (Item 7).
clearCurrentAsset()
observeItem(nil)
player.replaceCurrentItem(with: nil)
currentTime = 0
duration = 0
stateError = nil
}
// MARK: Volume
func applyVolume() {
player.volume = Float(settings.volume)
}
func setVolume(_ volume: Double) {
settings.volume = volume
player.volume = Float(volume)
}
}
@@ -1,220 +0,0 @@
import AVFoundation
import Foundation
import os
private let logger = Logger(subsystem: "dev.shane.voice", category: "media")
/// Proxies {base}/media/* bytes to AVPlayer over the pinned-TLS session, since
/// AVPlayer cannot trust the self-signed LAN cert. AVPlayer loads https natively
/// (ignoring registered URLProtocol subclasses), so the asset uses a custom
/// `pinned-media` scheme and this delegate answers every data request by
/// fetching the real https URL through TLSPinning.session(), forwarding Range.
///
/// **Lifecycle invariants (Finding 11 / Iteration 06 Item 7):**
/// - Tasks are keyed by `ObjectIdentifier(loadingRequest)` directly, not a
/// derived hash, so two distinct requests can never collide.
/// - All mutable state (`loaderURLs`, `activeTasks`) is accessed only on the
/// main queue: the AVAssetResourceLoader delegate is set with `queue: .main`
/// and the pinned URLSession uses `delegateQueue: .main`, so both the
/// resource-loading callbacks and the data-task completion handlers run on
/// the same serialization domain. This is enforced in code, not by comment.
/// - For HTTP 206, `contentLength` is set only when `Content-Range` provides a
/// known total; it never falls back to the range's `expectedContentLength`.
/// - For HTTP 200, `expectedContentLength` is already the full resource size.
/// - Cleanup (task removal) occurs on success, error, cancellation, and item
/// replacement via `removeLoader`.
final class PinnedMediaResourceLoader: NSObject, AVAssetResourceLoaderDelegate {
static let scheme = "pinned-media"
private let base: URL
/// Maps each asset's resource loader to the real https URL it proxies.
/// Accessed only on the main queue (the delegate queue).
private var loaderURLs: [ObjectIdentifier: URL] = [:]
/// Maps each loading request's identity to its upstream data task so
/// cancellation can cancel the in-flight download. Keyed directly by
/// `ObjectIdentifier(loadingRequest)` — no hash derivation, no collisions.
private var activeTasks: [ObjectIdentifier: URLSessionDataTask] = [:]
/// Shared pinned session, created once and invalidated on deinit.
/// Uses `delegateQueue: .main` so all completion handlers (and therefore
/// all mutations of `activeTasks`) run on the main queue — the same
/// serialization domain as the AVAssetResourceLoader delegate callbacks
/// (which are also dispatched to `.main`). This makes map access truly
/// serialized in code, not just by convention.
private lazy var pinnedSession: URLSession = {
let config = URLSessionConfiguration.default
config.timeoutIntervalForRequest = 30
return URLSession(configuration: config, delegate: PinnedTrustDelegate(), delegateQueue: .main)
}()
init(base: URL) {
self.base = base
}
deinit {
pinnedSession.invalidateAndCancel()
}
/// Maps a media URL (absolute https or /media/... path) to the pinned-media
/// scheme so AVPlayer routes it through this delegate instead of loading
/// https natively. Built as a string because URLComponents.url returns nil
/// for schemes not in Foundation's known-scheme list.
func rewrite(_ url: URL) -> URL {
let absolute: String
if let scheme = url.scheme?.lowercased(), scheme.hasPrefix("http") {
var c = URLComponents(url: url, resolvingAgainstBaseURL: false)!
c.scheme = Self.scheme
return c.url!
} else {
let path = url.absoluteString.hasPrefix("/") ? String(url.absoluteString.dropFirst()) : url.absoluteString
guard let host = base.host, !host.isEmpty else { return url }
let port = base.port.map { ":\($0)" } ?? ""
absolute = "\(Self.scheme)://\(host)\(port)/\(path)"
}
return URL(string: absolute) ?? url
}
/// Maps a pinned-media URL back to the real https URL it proxies.
func resolve(_ url: URL) -> URL {
guard let scheme = url.scheme?.lowercased(), scheme == Self.scheme else { return url }
var c = URLComponents(url: url, resolvingAgainstBaseURL: false)!
c.scheme = "https"
return c.url ?? url
}
/// Creates an AVURLAsset that routes through this loader's delegate. The
/// mapping from the asset's resource loader to the real URL is recorded so
/// `resourceLoader(_:shouldWaitForLoadingOfRequestedResource:)` can resolve
/// it. All mutations happen on the main queue (the delegate queue).
func assetWithPinnedURL(_ pinned: URL) -> AVURLAsset {
let asset = AVURLAsset(url: pinned)
let realURL = resolve(pinned)
// The delegate queue is .main, so this runs on the main thread.
loaderURLs[ObjectIdentifier(asset.resourceLoader)] = realURL
asset.resourceLoader.setDelegate(self, queue: .main)
return asset
}
/// Removes the loader mapping and any in-flight tasks for an asset that is
/// no longer needed. Called by MediaPlayerController when replacing or
/// clearing media items to bound the dictionaries (Finding 11).
func removeLoader(_ asset: AVURLAsset) {
let key = ObjectIdentifier(asset.resourceLoader)
loaderURLs[key] = nil
// Cancel any in-flight tasks. URLSessionDataTask has no isCancelled
// property; calling cancel() on an already-cancelled task is a no-op.
for (_, task) in activeTasks {
task.cancel()
}
activeTasks.removeAll()
}
func resourceLoader(
_ resourceLoader: AVAssetResourceLoader,
shouldWaitForLoadingOfRequestedResource loadingRequest: AVAssetResourceLoadingRequest
) -> Bool {
// All state access is on the main queue (the delegate queue).
guard let dataRequest = loadingRequest.dataRequest else { return false }
guard let url = loaderURLs[ObjectIdentifier(resourceLoader)] else {
logger.log("no resolved URL for resource loader")
loadingRequest.finishLoading(with: URLError(.badURL))
return false
}
var upstream = URLRequest(url: url)
let offset = dataRequest.requestedOffset
let length = Int64(dataRequest.requestedLength)
if length > 0 {
upstream.setValue("bytes=\(offset)-\(offset + length - 1)", forHTTPHeaderField: "Range")
} else {
upstream.setValue("bytes=\(offset)-", forHTTPHeaderField: "Range")
}
logger.log("fetching \(url.absoluteString, privacy: .public) range=\(upstream.value(forHTTPHeaderField: "Range") ?? "-", privacy: .public)")
// Key the task by the loading request's identity directly — no hash
// derivation, so two distinct requests can never collide.
let requestKey = ObjectIdentifier(loadingRequest)
let task = pinnedSession.dataTask(with: upstream) { [weak self] data, response, error in
guard let self else { return }
// Cleanup: remove the task from the active map regardless of outcome.
// Runs on .main (the session's delegateQueue), same domain as all
// other map mutations.
self.activeTasks[requestKey] = nil
if let error {
logger.log("fetch failed \(url.absoluteString, privacy: .public): \(error.localizedDescription, privacy: .public)")
loadingRequest.finishLoading(with: error)
return
}
guard let http = response as? HTTPURLResponse else {
loadingRequest.finishLoading(with: URLError(.badServerResponse))
return
}
// Reject non-200/206 responses (404, 500, etc.) instead of feeding
// error JSON into AVPlayer as media bytes.
guard http.statusCode == 200 || http.statusCode == 206 else {
logger.log("unexpected status \(http.statusCode) for \(url.absoluteString, privacy: .public)")
loadingRequest.finishLoading(with: URLError(.badServerResponse))
return
}
if let info = loadingRequest.contentInformationRequest {
info.contentType = http.mimeType ?? "application/octet-stream"
// Content-length handling (Iteration 05 Item 7):
// - For 206: set full content length ONLY when Content-Range
// provides a known total. Never fall back to the range's
// expectedContentLength (that would tell AVPlayer the total
// resource is only as large as this one range).
// - For 200: expectedContentLength is already the full size.
if http.statusCode == 206 {
if let total = Self.parseTotalLength(http), total >= 0 {
info.contentLength = total
}
// If Content-Range has no known total (e.g. "bytes 0-1023/*"),
// do NOT set contentLength — AVPlayer will handle it as
// unknown-length, which is correct for a range response.
} else {
// 200: expectedContentLength is the full resource size.
if http.expectedContentLength >= 0 {
info.contentLength = Int64(http.expectedContentLength)
}
}
info.isByteRangeAccessSupported = true
}
if let data {
dataRequest.respond(with: data)
}
loadingRequest.finishLoading()
}
activeTasks[requestKey] = task
task.resume()
return true
}
func resourceLoader(
_ resourceLoader: AVAssetResourceLoader,
didCancel loadingRequest: AVAssetResourceLoadingRequest
) {
// Cancel the upstream data task so a seek or item swap doesn't keep
// downloading cancelled ranges. Keyed by ObjectIdentifier directly.
let key = ObjectIdentifier(loadingRequest)
activeTasks[key]?.cancel()
activeTasks[key] = nil
logger.log("resource loader cancelled request, upstream task cancelled")
}
/// Parses the total resource length from an HTTP 206 response's
/// `Content-Range` header (e.g. "bytes 0-1023/4096" → 4096). Returns nil
/// for non-206 responses, missing headers, unknown totals ("*"), or
/// malformed values.
static func parseTotalLength(_ response: HTTPURLResponse) -> Int64? {
if response.statusCode == 206,
let contentRange = response.value(forHTTPHeaderField: "Content-Range") {
// Format: "bytes <start>-<end>/<total>" or "bytes <start>-<end>/*"
let afterSlash = contentRange.split(separator: "/").last?.trimmingCharacters(in: .whitespaces)
if let afterSlash, afterSlash != "*", let total = Int64(afterSlash) {
return total
}
}
return nil
}
}
+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
-125
View File
@@ -1,125 +0,0 @@
import Foundation
import Testing
@testable import Voice
/// Minimal HTTP/1.1 stub built on BSD sockets that answers every request with a
/// successful JSON object body (mirroring the real media server's
/// `{"ok": true, ...}` shape). Used to prove MediaAPI no-response POSTs succeed
/// against an object body instead of throwing decodeFailed.
@MainActor
final class MediaAPIStubServer {
private(set) var url: URL!
nonisolated(unsafe) private var serverFD: Int32 = -1
nonisolated(unsafe) private var acceptThread: Thread?
nonisolated(unsafe) private var running = false
nonisolated(unsafe) private var lastPath: String?
func start() throws {
let fd = socket(AF_INET, SOCK_STREAM, 0)
guard fd >= 0 else { throw MediaAPIError.decodeFailed }
var yes: Int32 = 1
setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &yes, socklen_t(MemoryLayout<Int32>.size))
var addr = sockaddr_in()
addr.sin_family = sa_family_t(AF_INET)
addr.sin_port = in_port_t(0).bigEndian
addr.sin_addr = in_addr(s_addr: inet_addr("127.0.0.1"))
let bindResult = withUnsafePointer(to: &addr) { ptr in
ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sa in
bind(fd, sa, socklen_t(MemoryLayout<sockaddr_in>.size))
}
}
guard bindResult == 0 else { close(fd); throw MediaAPIError.decodeFailed }
guard listen(fd, 8) == 0 else { close(fd); throw MediaAPIError.decodeFailed }
var bound = sockaddr_in()
var len = socklen_t(MemoryLayout<sockaddr_in>.size)
withUnsafeMutablePointer(to: &bound) { ptr in
_ = ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sa in
getsockname(fd, sa, &len)
}
}
let port = Int(UInt16(bigEndian: bound.sin_port))
serverFD = fd
running = true
url = URL(string: "http://127.0.0.1:\(port)")!
let thread = Thread { [weak self] in
self?.acceptLoop()
}
acceptThread = thread
thread.start()
}
func stop() {
running = false
if serverFD >= 0 { close(serverFD) }
serverFD = -1
acceptThread?.cancel()
}
var lastRequestPath: String? { lastPath }
nonisolated private func acceptLoop() {
while running {
var clientAddr = sockaddr_in()
var len = socklen_t(MemoryLayout<sockaddr_in>.size)
let clientFD = withUnsafeMutablePointer(to: &clientAddr) { ptr in
ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sa in
accept(serverFD, sa, &len)
}
}
guard clientFD >= 0 else { continue }
handleClient(clientFD)
}
}
nonisolated private func handleClient(_ clientFD: Int32) {
defer { close(clientFD) }
var buffer = [UInt8](repeating: 0, count: 4096)
let bytesRead = read(clientFD, &buffer, buffer.count)
guard bytesRead > 0 else { return }
guard let request = String(bytes: buffer[0..<bytesRead], encoding: .utf8) else { return }
var path = ""
if let firstLine = request.split(separator: "\r\n").first {
let parts = firstLine.split(separator: " ")
if parts.count >= 2 { path = String(parts[1]) }
}
lastPath = path
let body = #"{"ok": true, "room": "test", "desired": "stopped"}"#
let response = """
HTTP/1.1 200 OK\r
Content-Type: application/json\r
Content-Length: \(body.utf8.count)\r
Connection: close\r
\(body)
"""
_ = response.withCString { cstr in
write(clientFD, cstr, response.utf8.count)
}
}
}
@MainActor
struct MediaAPITests {
@Test("successful JSON object response does not throw decodeFailed")
func successfulObjectResponse() async throws {
let stub = MediaAPIStubServer()
try stub.start()
defer { stub.stop() }
// Bypass the https-only parse path: build the config directly.
let base = stub.url!
let config = ServerConfig(baseURL: base, livekitURL: base)
let api = MediaAPI(config: config)
try await api.control(room: "test", action: "pause")
try await api.report(room: "test", state: "paused", trackID: nil, position: 0, duration: nil)
try await api.play(room: "test", videoID: nil, title: nil, channel: nil, trackID: nil, startPaused: nil)
#expect(stub.lastRequestPath == "/media/api/play")
}
}
-124
View File
@@ -1,124 +0,0 @@
import Foundation
import Testing
@testable import Voice
@MainActor
struct MediaReconcileTests {
private func makeController() -> MediaPlayerController {
let config = ServerConfig(
baseURL: URL(string: "http://127.0.0.1:9")!,
livekitURL: URL(string: "ws://127.0.0.1:9")!
)
let api = MediaAPI(config: config)
return MediaPlayerController(api: api, settings: SettingsStore())
}
private func makeState(
desired: String? = "stopped",
trackID: String? = nil,
audioURL: String? = nil
) -> MediaState {
let track: MediaTrack? = trackID != nil ? MediaTrack(
id: trackID!,
kind: nil,
videoID: nil,
title: nil,
channel: nil,
duration: nil,
status: nil,
error: nil,
audioURL: audioURL,
artURL: nil
) : nil
return MediaState(
room: "default",
track: track,
desired: desired,
reported: nil,
seekHint: nil,
choices: [],
pending: [],
version: 1
)
}
@Test("reconcile updates published desired to playing")
func reconcileDesiredPlaying() {
let controller = makeController()
#expect(controller.desired == "stopped")
controller.reconcile(makeState(desired: "playing"))
#expect(controller.desired == "playing")
}
@Test("reconcile updates published desired to paused")
func reconcileDesiredPaused() {
let controller = makeController()
controller.reconcile(makeState(desired: "playing"))
#expect(controller.desired == "playing")
controller.reconcile(makeState(desired: "paused"))
#expect(controller.desired == "paused")
}
@Test("reconcile updates published desired to stopped")
func reconcileDesiredStopped() {
let controller = makeController()
controller.reconcile(makeState(desired: "playing"))
#expect(controller.desired == "playing")
controller.reconcile(makeState(desired: "stopped"))
#expect(controller.desired == "stopped")
}
@Test("reconcile with nil desired defaults to stopped")
func reconcileNilDesiredDefaultsToStopped() {
let controller = makeController()
controller.reconcile(makeState(desired: "playing"))
#expect(controller.desired == "playing")
controller.reconcile(makeState(desired: nil))
#expect(controller.desired == "stopped")
}
@Test("late audio: desired=playing before audio_url, then audio_url arrives")
func lateAudioReconciliation() {
let controller = makeController()
// Phase 1: server says playing but the track has no audio_url yet
// (still downloading). The source is not wired, so play cannot start.
let stateNoURL = makeState(desired: "playing", trackID: "track-1", audioURL: nil)
controller.reconcile(stateNoURL)
#expect(controller.desired == "playing")
#expect(controller.track?.id == "track-1")
// Player rate should still be zero because no source was wired.
#expect(controller.player.rate.isZero)
// Phase 2: the same track now has an audio_url. The reconcile path
// wires the source (wireSource) and then applyDesiredState retries
// play because desired is still "playing" and rate is still zero.
let stateWithURL = makeState(desired: "playing", trackID: "track-1", audioURL: "/stream/track-1.mp3")
controller.reconcile(stateWithURL)
#expect(controller.desired == "playing")
// The source should now be wired (loadedAudioPath is set internally).
// We can't directly observe loadedAudioPath, but we can verify the
// player has a current item after wireSource ran.
#expect(controller.player.currentItem != nil)
}
@Test("startDefaultPolling restarts after endConversation keeps scope")
func pollingRestartsAfterEndConversation() async {
let controller = makeController()
controller.startDefaultPolling()
await controller.endConversation()
controller.startDefaultPolling()
try? await Task.sleep(for: .milliseconds(50))
}
@Test("togglePlayPause without a loaded track is a no-op")
func toggleWithoutTrack() async {
let controller = makeController()
await controller.togglePlayPause()
#expect(controller.track == nil)
}
}
-126
View File
@@ -1,126 +0,0 @@
import Foundation
import Testing
@testable import Voice
/// Focused tests for PinnedMediaResourceLoader.parseTotalLength, covering
/// 200, 206 with known total, 206 with unknown total (*), and malformed
/// Content-Range headers (Finding 1 / 11).
struct PinnedMediaResourceLoaderTests {
private func makeResponse(status: Int, contentRange: String? = nil, expectedLength: Int64 = -1) -> HTTPURLResponse {
var headers: [String: String] = [:]
if let contentRange {
headers["Content-Range"] = contentRange
}
return HTTPURLResponse(
url: URL(string: "https://example.com/media/test.mp3")!,
statusCode: status,
httpVersion: "HTTP/1.1",
headerFields: headers
)!
}
@Test("206 with known total parses Content-Range total")
func parse206KnownTotal() {
let response = makeResponse(status: 206, contentRange: "bytes 0-1023/4096", expectedLength: 1024)
#expect(PinnedMediaResourceLoader.parseTotalLength(response) == 4096)
}
@Test("206 with unknown total (*) returns nil")
func parse206UnknownTotal() {
let response = makeResponse(status: 206, contentRange: "bytes 0-1023/*", expectedLength: 1024)
#expect(PinnedMediaResourceLoader.parseTotalLength(response) == nil)
}
@Test("200 response returns nil (expectedContentLength is already the full size)")
func parse200ReturnsNil() {
let response = makeResponse(status: 200, expectedLength: 8192)
#expect(PinnedMediaResourceLoader.parseTotalLength(response) == nil)
}
@Test("206 without Content-Range header returns nil")
func parse206NoContentRange() {
let response = makeResponse(status: 206, expectedLength: 512)
#expect(PinnedMediaResourceLoader.parseTotalLength(response) == nil)
}
@Test("malformed Content-Range returns nil")
func parseMalformedContentRange() {
let response = makeResponse(status: 206, contentRange: "garbage", expectedLength: 100)
#expect(PinnedMediaResourceLoader.parseTotalLength(response) == nil)
}
@Test("206 with large total parses correctly")
func parseLargeTotal() {
let response = makeResponse(status: 206, contentRange: "bytes 1048576-2097151/1073741824", expectedLength: 1048576)
#expect(PinnedMediaResourceLoader.parseTotalLength(response) == 1_073_741_824)
}
@Test("streamURL constructs endpoint without downloading")
func streamURLConstruction() {
let config = ServerConfig(
baseURL: URL(string: "https://192.168.86.2:9178")!,
livekitURL: URL(string: "ws://192.168.86.2:7880")!
)
let api = MediaAPI(config: config)
let url = api.streamURL(trackID: "track-123")
// streamURL builds under the /media/api base (MediaAPI.baseURL).
#expect(url.absoluteString == "https://192.168.86.2:9178/media/api/stream/track-123")
}
// MARK: Rewrite / Resolve round-trip (Item 7)
@Test("rewrite maps https URL to pinned-media scheme and resolve inverts it")
func rewriteResolveRoundTrip() {
let loader = PinnedMediaResourceLoader(base: URL(string: "https://192.168.86.2:9178")!)
let original = URL(string: "https://192.168.86.2:9178/media/track-123.mp3")!
let pinned = loader.rewrite(original)
#expect(pinned.scheme == "pinned-media")
#expect(pinned.absoluteString == "pinned-media://192.168.86.2:9178/media/track-123.mp3")
let resolved = loader.resolve(pinned)
#expect(resolved.absoluteString == original.absoluteString)
}
@Test("rewrite maps path-only URL using base host and port")
func rewritePathOnly() {
let loader = PinnedMediaResourceLoader(base: URL(string: "https://192.168.86.2:9178")!)
let pathURL = URL(string: "/media/track-456.mp3")!
let pinned = loader.rewrite(pathURL)
#expect(pinned.scheme == "pinned-media")
#expect(pinned.absoluteString == "pinned-media://192.168.86.2:9178/media/track-456.mp3")
}
@Test("resolve leaves non-pinned URLs unchanged")
func resolveNonPinned() {
let loader = PinnedMediaResourceLoader(base: URL(string: "https://192.168.86.2:9178")!)
let https = URL(string: "https://example.com/foo.mp3")!
#expect(loader.resolve(https) == https)
}
// MARK: Asset lifecycle (Item 7)
@Test("assetWithPinnedURL creates asset and removeLoader clears mapping")
func assetLifecycle() {
let loader = PinnedMediaResourceLoader(base: URL(string: "https://192.168.86.2:9178")!)
let pinned = loader.rewrite(URL(string: "https://192.168.86.2:9178/media/test.mp3")!)
let asset = loader.assetWithPinnedURL(pinned)
// The asset should have a valid URL matching the pinned scheme.
#expect(asset.url.scheme == PinnedMediaResourceLoader.scheme)
// Remove the loader mapping — subsequent calls should not crash.
loader.removeLoader(asset)
}
@Test("removeLoader is idempotent (safe to call twice)")
func removeLoaderIdempotent() {
let loader = PinnedMediaResourceLoader(base: URL(string: "https://192.168.86.2:9178")!)
let pinned = loader.rewrite(URL(string: "https://192.168.86.2:9178/media/test.mp3")!)
let asset = loader.assetWithPinnedURL(pinned)
loader.removeLoader(asset)
// Second call should be a no-op, not a crash.
loader.removeLoader(asset)
}
}
+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))
}
}
+64
View File
@@ -0,0 +1,64 @@
import XCTest
/// Opt-in tests against the actual NAS deployment. No injected replies.
final class VoiceLiveTests: XCTestCase {
private var app: XCUIApplication!
override func record(_ issue: XCTIssue) {
if let app, app.state == .runningForeground {
capture("Failure state")
let hierarchy = XCTAttachment(string: app.debugDescription)
hierarchy.name = "Failure UI hierarchy"
hierarchy.lifetime = .keepAlways
add(hierarchy)
}
super.record(issue)
}
override func tearDownWithError() throws {
app?.terminate()
}
override func setUpWithError() throws {
try XCTSkipUnless(ProcessInfo.processInfo.environment["HOPE_RUN_LIVE_TESTS"] == "1", "Requires the live Hope server")
continueAfterFailure = false
app = XCUIApplication()
app.launchArguments = ["--ui-testing", "--reset-ui-testing", "--live-testing"]
app.launch()
}
func testLiveConversationAndBackgroundTask() {
app.buttons["Start chatting"].tap()
let composer = app.descendants(matching: .any).matching(identifier: "Message Hope").firstMatch
XCTAssertTrue(composer.waitForExistence(timeout: 60), "Agent must join the real LiveKit room")
composer.tap()
composer.typeText("What is six times seven? Reply in one short sentence.")
app.buttons["Send message"].tap()
let reply = app.staticTexts.matching(NSPredicate(format: "label CONTAINS[c] 'forty-two' OR label CONTAINS[c] 'forty two' OR label CONTAINS '42'")).firstMatch
XCTAssertTrue(reply.waitForExistence(timeout: 60), "Qwen must produce a real response")
capture("Live Qwen reply")
composer.tap()
composer.typeText("Compare espresso and filter coffee in a short background task. Use your existing knowledge; no web search needed.")
app.buttons["Send message"].tap()
app.tabBars.buttons["Activity"].tap()
let complete = app.staticTexts["Complete"].firstMatch
XCTAssertTrue(complete.waitForExistence(timeout: 90), "Background task must finish and deliver its result")
capture("Live background result")
app.tabBars.buttons["Talk"].tap()
app.buttons["End conversation"].tap()
XCTAssertTrue(app.buttons["Start chatting"].waitForExistence(timeout: 20))
// A second room must connect cleanly after the first was torn down.
app.buttons["Start chatting"].tap()
XCTAssertTrue(composer.waitForExistence(timeout: 60))
app.buttons["End conversation"].tap()
XCTAssertTrue(app.buttons["Start chatting"].waitForExistence(timeout: 20))
}
private func capture(_ name: String) {
let attachment = XCTAttachment(screenshot: app.screenshot())
attachment.name = name
attachment.lifetime = .keepAlways
add(attachment)
}
}
+148
View File
@@ -0,0 +1,148 @@
import XCTest
/// Real UI interactions with isolated preferences. These cover the client,
/// including offline recovery; they do not claim to verify the live backend.
final class VoiceUITests: XCTestCase {
private var app: XCUIApplication!
override func setUpWithError() throws {
continueAfterFailure = false
app = XCUIApplication()
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))
XCTAssertTrue(app.staticTexts["Your microphone starts after you connect."].exists)
app.buttons["Type"].tap()
XCTAssertTrue(app.buttons["Start chatting"].exists)
XCTAssertTrue(app.staticTexts["Your microphone stays off."].exists)
app.buttons["Start chatting"].tap()
XCTAssertTrue(app.buttons["Try again"].waitForExistence(timeout: 20))
XCTAssertTrue(app.staticTexts["Couldn’t connect"].exists)
app.buttons["Settings"].tap()
XCTAssertTrue(app.textFields["Server URL"].waitForExistence(timeout: 5))
capture("Offline recovery")
}
func testVoiceSelectionPersistsAndURLValidation() {
app.launch()
app.buttons["Settings"].tap()
app.buttons.matching(NSPredicate(format: "label CONTAINS 'Speaking voice'")).firstMatch.tap()
let voice = app.buttons.matching(NSPredicate(format: "label CONTAINS 'Jenny'")).firstMatch
XCTAssertTrue(voice.waitForExistence(timeout: 5))
voice.tap()
XCTAssertTrue(app.buttons.matching(NSPredicate(format: "label CONTAINS 'Speaking voice' AND label CONTAINS 'Jenny'")).firstMatch.exists)
let server = app.textFields["Server URL"]
server.tap()
let original = server.value as? String ?? ""
server.typeText(String(repeating: XCUIKeyboardKey.delete.rawValue, count: original.count) + "http://example.com")
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["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.
}
func testTranscriptClearConfirmationAndActivityDetails() {
app.launchArguments.append("--ui-fixtures")
app.launch()
XCTAssertTrue(app.staticTexts["Help me plan a quiet weekend."].waitForExistence(timeout: 10))
app.buttons["Conversation options"].tap()
app.buttons["Clear conversation"].tap()
app.buttons["Cancel"].tap()
XCTAssertTrue(app.staticTexts["Help me plan a quiet weekend."].exists)
app.buttons["Conversation options"].tap()
app.buttons["Clear conversation"].tap()
app.buttons["Clear conversation"].tap()
XCTAssertTrue(app.buttons["Start chatting"].exists)
XCTAssertFalse(app.staticTexts["Help me plan a quiet weekend."].exists)
app.tabBars.buttons["Activity"].tap()
XCTAssertTrue(app.staticTexts["Needs attention"].waitForExistence(timeout: 5))
XCTAssertTrue(app.staticTexts["Search backend timed out after 30s"].exists)
app.staticTexts["Search backend timed out after 30s"].tap()
XCTAssertTrue(app.staticTexts["Fetching benchmark articles…"].waitForExistence(timeout: 5))
capture("Activity details")
}
func testRestoredTranscriptStaysAtTopUntilLatest() {
app.launchArguments.append("--ui-long-transcript")
app.launch()
let first = app.staticTexts.matching(NSPredicate(format: "label CONTAINS 'Earlier message 1:'")).firstMatch
let last = app.staticTexts.matching(NSPredicate(format: "label CONTAINS 'Earlier message 30:'")).firstMatch
XCTAssertTrue(first.waitForExistence(timeout: 10))
XCTAssertTrue(app.buttons["Latest"].exists)
XCTAssertFalse(last.isHittable)
app.buttons["Latest"].tap()
XCTAssertTrue(last.waitForExistence(timeout: 5))
XCTAssertTrue(last.isHittable)
}
func testLargeTypeNavigation() {
app.launchArguments += ["-UIPreferredContentSizeCategoryName", "UICTContentSizeCategoryAccessibilityXXXL"]
app.launch()
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: 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
attachment.lifetime = .keepAlways
add(attachment)
}
}
+215 -49
View File
@@ -7,34 +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 */; };
3E01A0AF44FD3324FA72C43C /* OrbView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A62BAE22E3A165DCB5DFFEE1 /* OrbView.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 */; };
72F3B4AEC214521015200583 /* MediaReconcileTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09FB71841B3D72CD1D69E47A /* MediaReconcileTests.swift */; };
73DA73811F8AEB2AFE340D10 /* MediaPlayerController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3964F565A6C6A06399EBA4DF /* MediaPlayerController.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 */; };
A86EFB492F40C26D4AA315E9 /* PinnedMediaResourceLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DC3AC57ED58FC517E212F19 /* PinnedMediaResourceLoader.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 */; };
B94FAFA796A9AF6011B03DEC /* PinnedMediaResourceLoaderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A20F8AFF1AAC5A944173549 /* PinnedMediaResourceLoaderTests.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 */; };
C956D0FDFAC43925979E4077 /* MediaAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9191AD60FF96E44F191864E3 /* MediaAPI.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 */; };
DE1F24919E906EE13319C7A3 /* MediaPanelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68FACB1A5EA6FEFD1D2613E9 /* MediaPanelView.swift */; };
EE0C5762EC97B123A1880FFB /* Voices.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E5DF207EDB787F63CB3A64D /* Voices.swift */; };
F26037AD028ED105E127CBD3 /* MediaAPITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10C194656F19E89DFD656A91 /* MediaAPITests.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 */
@@ -46,38 +60,60 @@
remoteGlobalIDString = 57E3A292BDE525A22EB7ADE4;
remoteInfo = Voice_iOS;
};
ED7A5BB58354A8C9A5994257 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 201BFA602F0A78910611E5F5 /* Project object */;
proxyType = 1;
remoteGlobalIDString = 57E3A292BDE525A22EB7ADE4;
remoteInfo = Voice_iOS;
};
/* End PBXContainerItemProxy section */
/* Begin PBXFileReference section */
09FB71841B3D72CD1D69E47A /* MediaReconcileTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaReconcileTests.swift; sourceTree = "<group>"; };
0FC167F6658CFEC9546A7E9F /* AgentMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentMonitor.swift; sourceTree = "<group>"; };
10C194656F19E89DFD656A91 /* MediaAPITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaAPITests.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>"; };
3964F565A6C6A06399EBA4DF /* MediaPlayerController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPlayerController.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>"; };
68FACB1A5EA6FEFD1D2613E9 /* MediaPanelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPanelView.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>"; };
6DC3AC57ED58FC517E212F19 /* PinnedMediaResourceLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PinnedMediaResourceLoader.swift; sourceTree = "<group>"; };
8A20F8AFF1AAC5A944173549 /* PinnedMediaResourceLoaderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PinnedMediaResourceLoaderTests.swift; sourceTree = "<group>"; };
9191AD60FF96E44F191864E3 /* MediaAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaAPI.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>"; };
A62BAE22E3A165DCB5DFFEE1 /* OrbView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OrbView.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 */
@@ -93,14 +129,6 @@
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
0D2E974CBC286D7C886BA814 /* Media */ = {
isa = PBXGroup;
children = (
68FACB1A5EA6FEFD1D2613E9 /* MediaPanelView.swift */,
);
path = Media;
sourceTree = "<group>";
};
0DF0591DF1D767F89099F62C /* Settings */ = {
isa = PBXGroup;
children = (
@@ -124,9 +152,21 @@
path = LiveKit;
sourceTree = "<group>";
};
6735DFBC89D9A939C44D763A /* UITests */ = {
isa = PBXGroup;
children = (
E6B2A30D6F45A8E79CF1C73A /* ChatUITests.swift */,
5F6A697985B36B3A1A3F2AD5 /* VoiceLiveTests.swift */,
8309AC6885ED7EB0047AE211 /* VoiceUITests.swift */,
);
path = UITests;
sourceTree = "<group>";
};
6B2AB34274D9B37E77178C6C /* Networking */ = {
isa = PBXGroup;
children = (
DAC751BFB00B04C73D19C714 /* AuthClient.swift */,
536E3E89F4BB09D631744F3F /* KeychainTokenStore.swift */,
F5683993A1DE677121FFA1F3 /* ServerConfig.swift */,
D9B40076934C28BA4B92D808 /* TLSPinning.swift */,
9681E7410B684D2D38752A70 /* TokenClient.swift */,
@@ -139,6 +179,7 @@
children = (
2A4C3CE2CA6FF828452803D1 /* Voice.app */,
C22975C69240AACD3418091F /* VoiceTests.xctest */,
5759EB570DB87BBC121D946D /* VoiceUITests.xctest */,
);
name = Products;
sourceTree = "<group>";
@@ -147,7 +188,9 @@
isa = PBXGroup;
children = (
3F3FA9F420065D2127B0AEC0 /* ConversationView.swift */,
A62BAE22E3A165DCB5DFFEE1 /* OrbView.swift */,
87DD77AA383E1ECCEEEFB399 /* LightwellView.swift */,
96278769DD369C629F18E475 /* LoginView.swift */,
BD31F76E1880AC76ED31DD4D /* TranscriptListView.swift */,
);
path = Conversation;
sourceTree = "<group>";
@@ -156,35 +199,41 @@
isa = PBXGroup;
children = (
84ADAF12A43A57888CA178A6 /* Conversation */,
0D2E974CBC286D7C886BA814 /* Media */,
0DF0591DF1D767F89099F62C /* Settings */,
F168B25F8B3C6F36B6E88228 /* Tasks */,
);
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 = (
EB99F8CAC2A1FB46873C1E48 /* Resources */,
FCC61E9990B71498171F9D34 /* Sources */,
DC8495F2B27E1770E6620A05 /* Tests */,
6735DFBC89D9A939C44D763A /* UITests */,
6F99423E59017FED59B4D71D /* Products */,
);
sourceTree = "<group>";
};
C12426487989358DE21A5426 /* Media */ = {
isa = PBXGroup;
children = (
9191AD60FF96E44F191864E3 /* MediaAPI.swift */,
3964F565A6C6A06399EBA4DF /* MediaPlayerController.swift */,
6DC3AC57ED58FC517E212F19 /* PinnedMediaResourceLoader.swift */,
);
path = Media;
sourceTree = "<group>";
};
CE49A38E40AAF659A9281B85 /* App */ = {
isa = PBXGroup;
children = (
CDDA0509C2DF2504AA50CBA9 /* AuthStore.swift */,
CA6FA2A93116B237CEA712E6 /* Nearlight.swift */,
6D0D153557BB1C756ED04C61 /* SettingsStore.swift */,
2FED8976B60FBC13BC980FCB /* VoiceApp.swift */,
2E5DF207EDB787F63CB3A64D /* Voices.swift */,
@@ -195,16 +244,26 @@
DC8495F2B27E1770E6620A05 /* Tests */ = {
isa = PBXGroup;
children = (
262B5A712708421C35ED6AC0 /* AuthTests.swift */,
F2623CA741424CA9B1B6BDA0 /* ChatLiveTests.swift */,
B5B373E4A57214C0D289536B /* ChatTests.swift */,
C8F93A6F4B4354976953F69F /* ChatTransportTests.swift */,
BDD0AA8B0E9B45A325D1E5E7 /* ConversationStateTests.swift */,
10C194656F19E89DFD656A91 /* MediaAPITests.swift */,
09FB71841B3D72CD1D69E47A /* MediaReconcileTests.swift */,
8A20F8AFF1AAC5A944173549 /* PinnedMediaResourceLoaderTests.swift */,
20A82F8996DEA166C94A7956 /* ServerReplacementCoordinatorTests.swift */,
EA2918A7B29AB6EB033E707C /* TLSLiveTests.swift */,
FAEF89B2DEEE68F7168A8014 /* TranscriptDedupeTests.swift */,
);
path = Tests;
sourceTree = "<group>";
};
EB99F8CAC2A1FB46873C1E48 /* Resources */ = {
isa = PBXGroup;
children = (
26922A8D6467BD51ED45B63B /* Assets.xcassets */,
);
path = Resources;
sourceTree = "<group>";
};
F168B25F8B3C6F36B6E88228 /* Tasks */ = {
isa = PBXGroup;
children = (
@@ -217,9 +276,9 @@
isa = PBXGroup;
children = (
CE49A38E40AAF659A9281B85 /* App */,
94F63FF6C1F0CD224010E2F5 /* Chat */,
8DA8BEF2EE3E0F4181A94EB9 /* Features */,
2820A418F921E18A2FA7E4FB /* LiveKit */,
C12426487989358DE21A5426 /* Media */,
6B2AB34274D9B37E77178C6C /* Networking */,
);
path = Sources;
@@ -246,11 +305,30 @@
productReference = C22975C69240AACD3418091F /* VoiceTests.xctest */;
productType = "com.apple.product-type.bundle.unit-test";
};
1C8CC2859E99A4AC83FC6374 /* VoiceUITests */ = {
isa = PBXNativeTarget;
buildConfigurationList = 286F46F3E4A225D8ABDCEA48 /* Build configuration list for PBXNativeTarget "VoiceUITests" */;
buildPhases = (
889523F0856C91F5A5E8FD41 /* Sources */,
);
buildRules = (
);
dependencies = (
A506145EF5A1B3978391C33C /* PBXTargetDependency */,
);
name = VoiceUITests;
packageProductDependencies = (
);
productName = VoiceUITests;
productReference = 5759EB570DB87BBC121D946D /* VoiceUITests.xctest */;
productType = "com.apple.product-type.bundle.ui-testing";
};
57E3A292BDE525A22EB7ADE4 /* Voice_iOS */ = {
isa = PBXNativeTarget;
buildConfigurationList = 67CE95EFE28F976D1CF1A59A /* Build configuration list for PBXNativeTarget "Voice_iOS" */;
buildPhases = (
548DD373FE3F2A49C30AA9CB /* Sources */,
AD64D238C9844386DB9B2B44 /* Resources */,
F79CBCE3DCE9D63A26B167D9 /* Frameworks */,
);
buildRules = (
@@ -274,7 +352,11 @@
BuildIndependentTargetsInParallel = YES;
LastUpgradeCheck = 1430;
TargetAttributes = {
1C8CC2859E99A4AC83FC6374 = {
TestTargetID = 57E3A292BDE525A22EB7ADE4;
};
57E3A292BDE525A22EB7ADE4 = {
DevelopmentTeam = YJFYRB92NW;
ProvisioningStyle = Automatic;
};
};
@@ -296,27 +378,46 @@
projectDirPath = "";
projectRoot = "";
targets = (
1385EEF29189F331F2634382 /* VoiceTests_iOS */,
57E3A292BDE525A22EB7ADE4 /* Voice_iOS */,
1385EEF29189F331F2634382 /* VoiceTests_iOS */,
1C8CC2859E99A4AC83FC6374 /* VoiceUITests */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
AD64D238C9844386DB9B2B44 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
7A8CC686530A5920338EDF5A /* Assets.xcassets in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
548DD373FE3F2A49C30AA9CB /* Sources */ = {
isa = PBXSourcesBuildPhase;
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 */,
C956D0FDFAC43925979E4077 /* MediaAPI.swift in Sources */,
DE1F24919E906EE13319C7A3 /* MediaPanelView.swift in Sources */,
73DA73811F8AEB2AFE340D10 /* MediaPlayerController.swift in Sources */,
3E01A0AF44FD3324FA72C43C /* OrbView.swift in Sources */,
A86EFB492F40C26D4AA315E9 /* PinnedMediaResourceLoader.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 */,
98BA1A0F92DB7B98A8BA7814 /* SettingsView.swift in Sources */,
@@ -326,21 +427,34 @@
D18AD347DFF72AB6361165BA /* TokenClient.swift in Sources */,
D2E343C8B8657316A993698E /* TranscriptHandler.swift in Sources */,
89B47754E5C39418BFD839E6 /* TranscriptHistory.swift in Sources */,
9461C0886FA294FDD9742E51 /* TranscriptListView.swift in Sources */,
1ECD63F10A8EAF33FD2A5B62 /* VoiceApp.swift in Sources */,
CDE38FE9679472D770C601E6 /* VoiceSession.swift in Sources */,
EE0C5762EC97B123A1880FFB /* Voices.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
889523F0856C91F5A5E8FD41 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
11BB49F87376061BDAFB9C5D /* ChatUITests.swift in Sources */,
70E4B860147494D9657E8C01 /* VoiceLiveTests.swift in Sources */,
57E6476135CE30495E02D723 /* VoiceUITests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
C735E25A3F84D701AEBFC3F1 /* Sources */ = {
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 */,
F26037AD028ED105E127CBD3 /* MediaAPITests.swift in Sources */,
72F3B4AEC214521015200583 /* MediaReconcileTests.swift in Sources */,
B94FAFA796A9AF6011B03DEC /* PinnedMediaResourceLoaderTests.swift in Sources */,
BCAB602C722D74F6C96C5E36 /* ServerReplacementCoordinatorTests.swift in Sources */,
22EA06AD769759D2223D515F /* TLSLiveTests.swift in Sources */,
1813A1CB0921D04FCAEC92D0 /* TranscriptDedupeTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
@@ -353,6 +467,11 @@
target = 57E3A292BDE525A22EB7ADE4 /* Voice_iOS */;
targetProxy = 1C0045D170F971FC571D0E54 /* PBXContainerItemProxy */;
};
A506145EF5A1B3978391C33C /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 57E3A292BDE525A22EB7ADE4 /* Voice_iOS */;
targetProxy = ED7A5BB58354A8C9A5994257 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin XCBuildConfiguration section */
@@ -438,6 +557,24 @@
};
name = Debug;
};
452ED10E7C45C3EDEDC466B5 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
GENERATE_INFOPLIST_FILE = YES;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@loader_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = dev.shane.voice.uitests;
SDKROOT = iphoneos;
SWIFT_VERSION = 5.9;
TARGETED_DEVICE_FAMILY = "1,2";
TEST_TARGET_NAME = Voice_iOS;
};
name = Release;
};
8CD9F2FF6EF1EB5438545350 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
@@ -464,6 +601,7 @@
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = YJFYRB92NW;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = Resources/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
@@ -486,6 +624,7 @@
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = YJFYRB92NW;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = Resources/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
@@ -557,9 +696,36 @@
};
name = Release;
};
EEDD1CAF56A9063A94459A9B /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
GENERATE_INFOPLIST_FILE = YES;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@loader_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = dev.shane.voice.uitests;
SDKROOT = iphoneos;
SWIFT_VERSION = 5.9;
TARGETED_DEVICE_FAMILY = "1,2";
TEST_TARGET_NAME = Voice_iOS;
};
name = Debug;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
286F46F3E4A225D8ABDCEA48 /* Build configuration list for PBXNativeTarget "VoiceUITests" */ = {
isa = XCConfigurationList;
buildConfigurations = (
EEDD1CAF56A9063A94459A9B /* Debug */,
452ED10E7C45C3EDEDC466B5 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Debug;
};
67CE95EFE28F976D1CF1A59A /* Build configuration list for PBXNativeTarget "Voice_iOS" */ = {
isa = XCConfigurationList;
buildConfigurations = (
@@ -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>
+44
View File
@@ -0,0 +1,44 @@
# NAS deployment — 2026-09-08
Historical deployment record, not a current health assertion. The iOS music player
was removed September 12; the media/AAC details below concern the retained backend,
not a current iOS feature. Reinspect server state before making deployment changes.
The `voice` container on `192.168.86.2` hosts LiveKit, the voice agent,
token service, music API and nginx. The app connects to
`https://192.168.86.2:9178`. Docker uses host networking and an
`unless-stopped` restart policy.
- Image: `hope-voice-api-voice:68f4c46-ios-audio`.
- LLM: **qwen2.5-vl-3b**, through OpenWebUI at `http://192.168.86.2:3005/api`.
- Speech: existing Azure configuration, unchanged.
- Persistent certs, skills and music: `/mnt/user/dev/hope-voice-api` mounts.
- Build source: `/mnt/user/dev/hope-voice-deploy-68f4c46`, a separate clean
archive plus the iOS audio compatibility patch. The pre-existing dirty
backend checkout was not overwritten.
- Local compatibility source: sibling `hope-voice-api/web/media_server.py`;
regression test: `hope-voice-api/tests/test_ios_audio.py`.
The music service retains original WebM files. An explicit `?format=m4a`
request generates and caches AAC audio under `media/files/ios`, serving
byte ranges for iOS playback and seeking. Browser requests remain unchanged.
## Health and rollback
```sh
ssh root@192.168.86.2 'docker inspect --format "{{.State.Status}} {{.State.Health.Status}}" voice'
```
The stopped container `voice-before-ios-audio` retains the baseline deployment.
If rollback is needed, first inspect both containers, then stop the current
`voice`, rename it to a unique recovery name, rename `voice-before-ios-audio`
to `voice`, and start it. This restores the baseline without deleting images,
containers or the music library; it does not include iOS AAC conversion.
`scripts/deploy-backend.sh` records the launch configuration and refuses to
replace an existing `voice` container. Run it on the NAS only after building
the named image and explicitly handling the existing container. It reads
credentials on the NAS and never prints them. The backend compatibility source was
not pushed during that deployment; recheck the sibling repository before acting.
An iOS push does not publish it. Future backend CI deployments must include the
compatibility patch to preserve it.
+19 -23
View File
@@ -1,18 +1,25 @@
# Device testing checklist
Simulator verification covered connect/disconnect, transcripts (text mode), media
reconciliation/UI, tasks panel, and the hardening pass. The items below need a **real
Simulator verification covered connect/disconnect, transcripts (text mode), tasks
panel, and the hardening pass. The items below need a **real
iPhone on the LAN** (`https://192.168.86.2:9178`) because mic input, WebRTC capture,
and audio session behavior don't work in this simulator environment (CoreAudio `-4010`,
0 input channels).
Setup: build with Xcode for a physical device (or `xcodebuild ... -destination`), install,
ensure the server cert pin is current (see README — re-export after any server restart).
Setup: recheck device availability and signing, then build/install with Xcode or
the installed XcodeBuildMCP skill workflow. The phone was paired and the Nearlight
build launched successfully on September 12; that does not prove today's availability
or a still-valid provisioning profile. Verify the server certificate pin after an
actual certificate rotation, not automatically after every container restart.
For each check, record date, source revision, device/iOS version, backend/model,
observed result and supporting logs. Leave unchecked items unqualified; installing
the app or passing a simulator state test is not equivalent to hearing a reply.
## 1. Mic input with real speech
- [ ] Start → agent joins → mic enables **without** `-4010`.
- [ ] Speak: orb/status transitions to **listening**; mic meter moves with voice level.
- [ ] Speak: Lightwell/status transitions to **listening**; mic meter moves with voice level.
- [ ] Stop speaking, wait >2 s while agent is silent → **thinking** chip appears.
- [ ] Agent replies: state goes to **speaking** (remote audio drives it), TTS audible.
- [ ] User transcript finals arrive in the list with correct role/timestamps.
@@ -21,7 +28,7 @@ ensure the server cert pin is current (see README — re-export after any server
- [ ] While the agent is speaking, start talking → conversation continues without a
"barge-in" glitch; state returns to listening/thinking as expected.
- [ ] Text mode: barge-in hint line visible while in text mode.
- [ ] Switching to Type leaves microphone capture off and text messaging usable.
## 3. Wake lock (screen off)
@@ -31,29 +38,18 @@ ensure the server cert pin is current (see README — re-export after any server
side button to lock the device. It only disables the automatic idle timeout.
- [ ] After hang-up, auto-lock returns (idle timer re-enabled).
## 4. Audible media playback
- [ ] Requires a track in an iOS-decodable format (m4a/aac/mp3) — current library tracks
are webm and will fail with `-11828` until the server serves a supported format.
- [ ] Play a library track: audio is audible, now-playing card shows art/title/status.
- [ ] Pause / ±10 s / stop / seek all work; agent mics off while playing
(`media_state` published), mics back on when stopped.
- [ ] End conversation while music was playing before → playback resumes.
## 5. Audio interruption (phone call / Siri)
## 4. Audio interruption (phone call / Siri)
- [ ] Mid-conversation, trigger an interruption (incoming call or "Hey Siri").
- [ ] Mic is muted on interruption begin; conversation/media pause cleanly.
- [ ] On end (with `shouldResume`), mic and media resume automatically.
- [ ] Media panel: AVPlayer pauses on interruption and resumes after.
- [ ] On end (with `shouldResume`), mic and conversation resume automatically.
## 6. Mic permission denied fallback
## 5. Mic permission denied fallback
- [ ] Deny microphone permission in iOS Settings for the app, relaunch.
- [ ] Start → session reaches **ready** despite mic failure (non-fatal); orange error
banner shows the mic error; text mode remains fully usable.
- [ ] Start → session reaches **ready** despite mic failure (non-fatal); a visible
error explains the problem and offers iPhone Settings recovery; Type remains usable.
## 7. General device sanity
## 6. General device sanity
- [ ] Connect / hang-up / reconnect matrix (3×) — no crashes, state resets cleanly.
- [ ] Change server URL in Settings while idle → session recreates without crash.
+135
View File
@@ -0,0 +1,135 @@
# Engineering learnings
Updated 2026-09-19 from repository source, recorded test evidence, and prior debugging.
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
only the parent session does not guarantee transcript/state/task updates redraw.
- Keep one conversation session across tabs. Server replacement must remain
latest-wins and reject late callbacks from a torn-down session.
- Keep Settings presentation outside the replaced session's identity. Otherwise
saving a changed server can destroy the navigation stack and its success feedback.
- Treat normalization as a programmatic edit: trimming a saved URL must not clear
its own confirmation. Clear confirmation on the next genuine user edit instead.
- A restored transcript is reading history, not a request to jump to the bottom.
User scrolling, text selection, and VoiceOver row focus suspend auto-follow;
explicit **Latest** restores it. Test the reading position, not just row existence.
- Gate Send on transport readiness, reconnect state and pending sends. Retain an
unsent draft on failure. Type mode must not enable microphone capture.
- A ready room can still have a microphone failure. Show the actual error and its
recovery action without disabling the usable text path.
These are regression invariants of the current Nearlight implementation, not a
reason to restore the removed music UI or the canceled ChatGPT migration.
## Match evidence to the claim
- A token/TLS check is not an agent-join test; Docker health is not a Qwen-response
test; app installation is not proof of microphone capture or audible output.
- Offline UI fixtures must use isolated preferences and deterministic local failure
endpoints. Never reset production preferences to make a UI test pass.
- Keep live tests opt-in. Record which model/server was used, what was actually
asserted, skips, result bundles, and any cleanup of test-created state.
- Test repeated lifecycle sequences, not just one successful launch. Repeat runs
exposed an intermittent playback timeout that a single green run missed.
- After an interrupted tool call, inspect the existing process/result before
restarting. A lost tool handle does not imply the underlying test stopped.
- When UI snapshots expose no targets, cross-check screenshots and XCTest results;
this occurred despite a rendered app and was not sufficient evidence of a crash.
- Keep failed-run evidence. A later pass does not explain the earlier failure.
Source review and automated large-text checks also do not replace manual VoiceOver
or real-device speech/interruption testing.
## Device connection and signing
Diagnose the layers separately: USB detection → pairing/trust → developer/signing
readiness → install/launch → live voice behavior. An unlocked phone may still be
unpaired. Once USB detects it, investigate pairing rather than repeatedly blaming
the cable. Query fresh device details; do not reuse an old availability report.
The phone was eventually paired and the final Nearlight build installed/launched
on September 12. That supersedes the earlier Finder/pairing blocker. Recheck its
current availability and provisioning before the next run; the recorded personal
provisioning was short-lived. Do not reset privacy settings or erase a device as a
routine diagnostic step.
## Project generation and assets
`project.yml` is the source for `Voice.xcodeproj`; the scheme is `Voice_iOS`, bundle
ID `dev.shane.voice`. Adding an icon file alone is insufficient: the asset catalog
must also be included in the resource build phase. The September 12 follow-up
commits added the Lightwell icon and regenerated the project to include it.
Regenerate deliberately and review generated diffs alongside source changes.
## NAS deployment and historical audio findings
- Inspect actual containers, images, mounts and health before recreating anything.
A user concern about deletion is a reason to check, not proof of deletion.
- Preserve the persistent certificate. A container restart need not rotate it;
update the app pin only after verifying an actual certificate change.
- Stage clean deployment source separately from a dirty remote checkout. Retain
library/configuration mounts and a recoverable previous container.
- Record the deployed image and source patch. Cross-repository changes must be
published separately, with any push-triggered deployment reviewed first.
- Do not print environment files, API keys, or full credential-bearing configuration.
Historical only—the embedded player no longer exists in this app:
- WebM/Opus library downloads needed cached AAC for the iOS playback path tested.
Preserve originals and verify byte ranges/cache behavior separately from conversion.
- AVFoundation's resource-loader content type required a UTI, not an HTTP MIME type.
- Buffering an entire 6 MB response before returning audio reproduced an AVPlayer
timeout. Bounded 256 KB range delivery passed three subsequent live repetitions.
These findings do not establish support for every radio format or indefinite stream.
See [DEPLOYMENT.md](DEPLOYMENT.md) and [VERIFICATION.md](VERIFICATION.md) for recorded
deployment and test history. Do not reintroduce the client player to preserve history.
+425
View File
@@ -0,0 +1,425 @@
# Project status and handoff
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
a ChatGPT subscription/device-token login. Do not revive that migration because
an old saved goal mentions it. Current work is the native iOS UI/UX redesign
and end-to-end qualification.
**2026-09-12: the embedded music player was removed** by user decision — the
feature is gone from the app (Media tab, `Sources/Media/`, `MediaPanelView`,
`MediaAPI`, `MediaPlayerController`, `PinnedMediaResourceLoader`, media tests,
and the `media_state` control publish). The backend media API still exists and
is untouched. The backend AAC compatibility patch (below) is now irrelevant to
the iOS app but remains deployed.
The **Nearlight** native redesign is implemented across Talk, Activity, Settings,
pickers, task details, and connection diagnostics. Current simulator qualification
is green, and GPT-6 Astra's final blocker re-verification returned **PASS**. The
final source also produced a signed generic-iOS build, and once the phone
reconnected the final build was installed and launched on the paired iPhone.
Hardware audio and interruption qualification remains open.
## Completed work
### Native interface and usability
- The app now has native **Talk** and **Activity** tabs sharing one session;
changing tabs never ends a conversation.
- Implemented Astra's **Nearlight** direction: an adaptive plum/rosewood semantic
palette, restrained rounded typography, shared spacing/radius/motion tokens, and
the compact Lightwell aperture as the conversation state signature.
- Rebuilt Talk around a clear pre-connection welcome, Voice/Type choice, explicit
start/end controls, compact active status, microphone meter, persistent Type
draft, and a dock that keeps primary actions reachable.
- Reworked the transcript as a readable shared record with speaker/time metadata,
live partials, copy/selection, explicit destructive confirmation, and measured
follow-latest behavior. Restored history stays at the top; manual reading,
selection, and VoiceOver row focus suspend following until **Latest**.
- Made connection and ready-session microphone failures visible without discarding
the real error. Permission denial exposes an iPhone Settings recovery action;
Type mode remains available.
- Reworked Settings around voice, mode, model, and connection. Server edits are
validated and explicitly saved; normalization confirmation survives session
replacement and clears on the next user edit.
- Improved Activity hierarchy, active-count badge, empty state, state-specific
rows, and task detail presentation.
- Removed forced dark appearance; all redesigned surfaces support light/dark mode,
Dynamic Type, Reduce Motion, increased contrast branches, and 44-point-or-larger
interaction targets where applicable.
### Functional fixes
- Observed nested transcript, conversation-state, and task objects so asynchronous
updates render.
- Preserved latest-wins server replacement while keeping an open Settings
navigation stack and its success feedback intact.
- Preserved Type-mode microphone privacy, pending-message send gating, clean session
teardown, transcript persistence/deduplication, and interruption ordering.
- All media-related client behavior was removed with the music player on 2026-09-12.
### Tests and tooling
- Follow-up commits added the Lightwell app icon (`7ac8a1c`) and regenerated the
project to include its asset-catalog resource phase (`85169d6`). The qualification
results below predate those follow-ups; do not describe them as a fresh full run
of the current HEAD.
- Installed XcodeBuildMCP and its skills; used it for simulator build/run/test work.
- The current combined simulator run passed **50 tests, 0 failures**, with the
opt-in live suite skipped once. Five offline UI scenarios cover recovery,
settings validation/persistence and save feedback, transcript/task flows,
restored-history reading position, and accessibility-size navigation.
- A post-focus-change targeted restored-history UI regression passed independently.
- DEBUG UI fixtures use isolated preferences; ordinary application preferences and
the NAS are not modified by offline UI runs.
- GPT-6 Astra completed the design specification and four verification rounds. Its
final scoped review found B1–B3 resolved, N1/N2 preserved, and no concrete
remaining release blocker. Accepted visual/adaptive/accessibility polish remains
documented as debt, not as exhaustive WCAG or VoiceOver qualification.
## Backend deployment and cross-repository dependency
SSH host: `[email protected]` (Unraid). App URL: `https://192.168.86.2:9178`.
- `voice` image: `hope-voice-api-voice:68f4c46-ios-audio`, host networking,
restart policy `unless-stopped`. Includes LiveKit, voice agent, token service,
media API, and nginx.
- LLM: `qwen2.5-vl-3b` through existing OpenWebUI at
`http://192.168.86.2:3005/api`. Existing Azure speech configuration retained.
- `voice`, `open-webui`, and `local-vl-models` were present and healthy when the
user suspected deletion. Do not recreate healthy services unnecessarily.
- Original library, certificates, and skills were retained under
`/mnt/user/dev/hope-voice-api`; the dirty remote checkout was not overwritten.
- Separate deployment source: `/mnt/user/dev/hope-voice-deploy-68f4c46`.
- Stopped rollback container: `voice-before-ios-audio`.
- Backend AAC conversion preserves original WebM files and serves cached AAC with
byte-range support. Browser/raw requests retain their original behavior.
**Important:** the backend patch and its test are in a different local repository:
`/Users/shane/dev/hope-voice-api/web/media_server.py` and
`/Users/shane/dev/hope-voice-api/tests/test_ios_audio.py`. They were still uncommitted
at the earlier deployment handoff; their current sibling-repository status has not
been rechecked in this documentation update. Committing/pushing the iOS repository
does **not** publish those files.
The deployed image contains the patch, but a future backend CI rebuild can lose it
unless it is committed and deployed through that repository's workflow. Inspect
that workflow before pushing: it may replace the live container.
Launch configuration and rollback details: [DEPLOYMENT.md](DEPLOYMENT.md).
Do not copy credentials or `.env` contents into documentation or commits.
## Verification performed
Project `Voice.xcodeproj`, scheme `Voice_iOS`, iPhone 17 Pro simulator
`AFDA137E-1657-428E-BF01-E5CD7989772C`.
- Current combined run: **50 passed, 0 failed, 1 live skip** in 104.9 seconds.
Result bundle:
`test_sim_2026-09-12T14-03-09-058Z_pid95846_2ca9da7e.xcresult`.
- Post-focus-change restored-history regression: **1 passed, 0 failed**.
Result bundle:
`test_sim_2026-09-12T14-05-21-070Z_pid98187_bd3e531a.xcresult`.
- A fresh isolated simulator launch showed restored history top-anchored with
**Latest** visible; the UI regression then tapped Latest and verified the final
event became visible.
- Astra's targeted isolated runtime check verified a padded changed-address save
stays in Settings, normalizes the field, shows its checkmark and confirmation,
and clears both only after the next user edit. Final verdict: **PASS**.
- iPhone 14 Pro Max `BF93D35E-0853-5470-A125-6EB0DB3F17AB` was paired, signed,
installed, trusted, and launched successfully. The final Nearlight build
(`/tmp/voice-ios-device-derived/Build/Products/Debug-iphoneos/Voice.app`,
`dev.shane.voice`) was reinstalled and launched via `devicectl` after the phone
reconnected. Free provisioning expires after seven days; reinstall to re-sign.
- Historical live NAS runs verified Qwen replies, background task completion, and
reconnect. Backend integration remains 16 passed, 7 skipped, 2 deselected.
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,
barge-in, Siri/phone interruptions, lock/background behavior,
microphone-permission-denied fallback, and network-loss recovery. These require
the user's speech/listening or a real interruption; simulator state tests do not
prove them. Follow [DEVICE_TESTING.md](DEVICE_TESTING.md).
2. **Broader live regression:** exercise slow-network cancellation, network-loss
recovery, repeated server replacement, and voice/model selection against the
live agent.
3. **Manual accessibility/visual qualification:** inspect populated/error screens
in light and dark mode, accessibility text sizes, narrow/landscape layouts, and
a real VoiceOver focus session. Astra accepted the remaining presentation debt
for this personal-app release; that is not proof of exhaustive conformance.
4. **Publish the backend compatibility source safely** in the sibling repository
and ensure its normal deployment preserves mounts, certificates, and Qwen
settings. This is lower priority because the iOS app no longer consumes the
media API, but the deployed container still serves it for the web UI.
## Repository handoff
The iOS work and this report belong to branch `feat/ios-app`, remote `origin`
(`https://git.srmr.xyz/snowspeeder/hope-voice-ios.git`). Keep the Nearlight
implementation, tests, physical-device notes, and this handoff together when
committing. Check Git history and remote status for the final publish result.
+91
View File
@@ -0,0 +1,91 @@
# Verification — 2026-09-12
Documentation reviewed 2026-09-19; no tests rerun for that update. These results
predate the subsequent app-icon and generated-resource-phase commits. See
[ENGINEERING_LEARNINGS.md](ENGINEERING_LEARNINGS.md) for reusable lessons.
## Scope
Qwen 2.5 remains configured; the canceled subscription-login migration was not
revived. The embedded music player was removed by user decision. The current app
is the Astra-designed **Nearlight** native interface with shared Talk and Activity
tabs, adaptive semantic styling, the Lightwell conversation indicator, rebuilt
conversation controls, readable transcripts, actionable failure states, explicit
Settings saves, and revised Activity/detail surfaces.
## Current iOS evidence
Project: `Voice.xcodeproj`; scheme: `Voice_iOS`; simulator: iPhone 17 Pro
`AFDA137E-1657-428E-BF01-E5CD7989772C`.
- Full combined simulator run: **50 passed, 0 failed, 1 live skip** in 104.9
seconds. It includes unit/state tests and five offline UI scenarios.
- The offline UI scenarios cover navigation/offline recovery; persisted voice and
server validation; normalized same-address and changed-address save feedback;
transcript clearing and Activity details; restored-history reading position and
explicit Latest recovery; and accessibility-size navigation.
- A second targeted run after the final VoiceOver eligibility strengthening passed
`testRestoredTranscriptStaysAtTopUntilLatest`: **1 passed, 0 failed**.
- Fresh runtime inspection showed the long restored transcript at its beginning
with the **Latest** affordance visible. The regression tapped Latest and verified
the final row became visible.
- GPT-6 Astra re-read its design specification and prior findings, inspected the
final source, and performed an isolated Settings runtime reproduction. Its
round-four verdict was **PASS**: reading eligibility, ready-session microphone
recovery visibility, and save-confirmation lifetime were resolved; the earlier
selection and pending-Send fixes remained intact.
- Astra's Settings reproduction saved ` https://127.0.0.1:2 ` using isolated
UI-test preferences. Settings stayed open, the field normalized, and the
checkmark plus “Server address saved.” remained visible. The next user edit
cleared both indicators. It did not touch normal preferences or the NAS.
Result bundles under
`~/Library/Developer/XcodeBuildMCP/workspaces/voice-ios-9eb036fdea81/result-bundles/`:
- Full run:
`test_sim_2026-09-12T14-03-09-058Z_pid95846_2ca9da7e.xcresult`
- Targeted restored-history run:
`test_sim_2026-09-12T14-05-21-070Z_pid98187_bd3e531a.xcresult`
## Physical-device evidence
iPhone 14 Pro Max `BF93D35E-0853-5470-A125-6EB0DB3F17AB` was paired, built for,
signed with personal team `YJFYRB92NW`, installed, trusted, and launched through
`devicectl`. This proves device signing/install/launch only, not microphone or
speaker behavior. Free provisioning expires after seven days.
The final source also produced a signed device build and, once the phone
reconnected, `devicectl` installed and launched it successfully on 2026-09-12.
This proves device signing/install/launch of the final binary, not microphone or
speaker behavior.
## Historical live and backend evidence
Earlier live NAS runs verified Qwen replies, background task completion,
disconnect/reconnect, and three consecutive Qwen/task/reconnect iterations. Older
music playback evidence is historical only: the player and its tests no longer
exist in the iOS app.
Backend integration previously passed 16 tests with 7 skipped and 2 deselected.
The AAC compatibility regression passed and the deployed byte-range request
returned HTTP 206. That patch remains in a separate backend repository and is not
published by this iOS branch.
## Not yet qualified
- Audible speaker/headphone output, physical microphone capture, barge-in,
phone/Siri interruption recovery, lock/background behavior, and live
network-loss recovery.
- A real microphone-permission-denied session. The visibility/recovery branches
are source-reviewed, but no permission was changed during final verification.
- A full VoiceOver audio/focus session, exhaustive contrast audit, every Dynamic
Type/layout combination, or strict conformance to every Nearlight design detail.
Astra explicitly accepted the remaining visual/adaptive/presentation debt for
this personal release.
- A fresh build/install and regression run after the subsequent app-icon/resource
commits. The September 12 Nearlight install succeeded; current phone availability
and provisioning have not been rechecked here.
Follow [DEVICE_TESTING.md](DEVICE_TESTING.md) for hardware qualification and
[PROJECT_STATUS.md](PROJECT_STATUS.md) for current priorities and deployment
dependencies.
+28 -8
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:
@@ -14,24 +25,19 @@ targets:
platform: [iOS]
sources:
- Sources
- path: Resources/Assets.xcassets
buildPhase: resources
dependencies:
- package: LiveKit
product: LiveKit
resources:
- path: Resources/Assets.xcassets
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
@@ -41,6 +47,7 @@ targets:
TARGETED_DEVICE_FAMILY: "1"
CURRENT_PROJECT_VERSION: 1
MARKETING_VERSION: "0.1.0"
DEVELOPMENT_TEAM: YJFYRB92NW
CODE_SIGN_STYLE: Automatic
VoiceTests:
type: bundle.unit-test
@@ -54,3 +61,16 @@ targets:
SWIFT_VERSION: "5.9"
GENERATE_INFOPLIST_FILE: YES
PRODUCT_BUNDLE_IDENTIFIER: dev.shane.voice.tests
VoiceUITests:
type: bundle.ui-testing
platform: iOS
sources:
- UITests
dependencies:
- target: Voice_iOS
settings:
base:
SWIFT_VERSION: "5.9"
GENERATE_INFOPLIST_FILE: YES
PRODUCT_BUNDLE_IDENTIFIER: dev.shane.voice.uitests
TEST_TARGET_NAME: Voice_iOS
+34
View File
@@ -0,0 +1,34 @@
#!/usr/bin/env bash
# Run on xNAS after building the clean backend source into the pinned image.
# Credentials stay on the NAS. Existing checkout, library and certs are retained.
set -euo pipefail
deployment_source=/mnt/user/dev/hope-voice-deploy-68f4c46
deployment_data=/mnt/user/dev/hope-voice-api
deployment_image=${HOPE_DEPLOY_IMAGE:-hope-voice-api-voice:68f4c46-ios-audio}
test -f "$deployment_data/.env"
docker image inspect "$deployment_image" >/dev/null
if docker container inspect voice >/dev/null 2>&1; then
echo 'A voice container already exists; inspect it before replacing it.' >&2
exit 1
fi
# Recover the OpenWebUI credential used by this exact backend deployment source.
LLM_API_KEY=$(sed -n 's/.*-e LLM_API_KEY=\([^ ]*\).*/\1/p' "$deployment_source/.gitea/workflows/rebuild.yml")
test -n "$LLM_API_KEY"
export LLM_API_KEY
export LLM_BASE_URL=http://192.168.86.2:3005/api
export LLM_MODEL=qwen2.5-vl-3b
docker run -d --name voice --network host --restart unless-stopped \
--env-file "$deployment_data/.env" \
--env LLM_BASE_URL --env LLM_MODEL --env LLM_API_KEY \
--env LIVEKIT_URL=http://localhost:7880 \
--env VOICE_USERS_FILE=/etc/hope/users.json \
--env VOICE_DEFAULT_USER=shane --env MEDIA_DIR=/data/media \
--env MEDIA_SERVER_PORT=8093 --env WEB_PORT=9178 \
--env YTDLP_COOKIES=/data/media/cookies.txt \
--volume "$deployment_data/certs:/etc/voice/certs" \
--volume "$deployment_data/skills:/skills" \
--volume "$deployment_data/media:/data/media" \
--volume "$deployment_source/livekit.yaml:/etc/livekit.yaml:ro" \
--health-cmd='curl -sfk https://localhost:9178/ -o /dev/null && curl -sf http://localhost:7880/ -o /dev/null && curl -sf http://localhost:8093/media/api/healthz -o /dev/null' \
--health-interval=15s --health-timeout=5s --health-retries=3 --health-start-period=30s \
"$deployment_image"