Archived
Compare commits
25
Commits
main
..
7bd893ae5c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7bd893ae5c | ||
|
|
9c5e9bce5b | ||
|
|
ec510b938b | ||
|
|
7dcd06c911 | ||
|
|
74d2b63614 | ||
|
|
5b41b3924a | ||
|
|
15c6389ffb | ||
|
|
6f2659421c | ||
|
|
bebaf59466 | ||
|
|
9bdaea2408 | ||
|
|
85169d651c | ||
|
|
7ac8a1c2fa | ||
|
|
59d8182b6c | ||
|
|
b7cec80a99 | ||
|
|
5577162594 | ||
|
|
f6722a223f | ||
|
|
afe37ad5f4 | ||
|
|
f043d6b078 | ||
|
|
fa21c81787 | ||
|
|
c76afbd11e | ||
|
|
ea9ec75631 | ||
|
|
c5db94658d | ||
|
|
ed329213de | ||
|
|
3620a2387b | ||
|
|
24f95639dc |
No files matched your search
@@ -4,3 +4,4 @@ xcuserdata/
|
||||
*.xcresult
|
||||
build/
|
||||
.swiftpm/
|
||||
.runtime/
|
||||
@@ -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.
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}()
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -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) } }
|
||||
}
|
||||
@@ -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() {}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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.") }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,343 +1,713 @@
|
||||
import AVFoundation
|
||||
import SwiftUI
|
||||
import Combine
|
||||
|
||||
/// Owns one session across all tabs. Changing tabs never ends a conversation.
|
||||
struct ConversationView: View {
|
||||
@ObservedObject var settings: SettingsStore
|
||||
@State private var sessionID = UUID()
|
||||
@ObservedObject var auth: AuthStore
|
||||
let user: AuthUser
|
||||
let bearerToken: String
|
||||
@State private var session: VoiceSession
|
||||
/// The exact server URL the current session was constructed with. Used to
|
||||
/// avoid redundant session replacement when the applied URL hasn't actually
|
||||
/// changed (e.g. re-applying the same value from Settings).
|
||||
@State private var activeServerURL: String
|
||||
/// Identity guard: prevents a second `.onChange` from starting a concurrent
|
||||
/// session replacement while the first is still disconnecting the old one.
|
||||
@State private var isReplacingSession = false
|
||||
/// Latest accepted server URL that has not yet been applied to a session.
|
||||
/// Coalesced: rapid Applies during an active replacement retain the newest
|
||||
/// value rather than dropping it. A single retained worker loop drains
|
||||
/// arbitrary pending URLs until none remain (Iteration 08 Item 2).
|
||||
@State private var pendingServerURL: String?
|
||||
@State private var composerText = ""
|
||||
@State private var connectError: String?
|
||||
@State private var tick = 0
|
||||
@State private var isReplacingSession = false
|
||||
@State private var selectedTab = 2
|
||||
@StateObject private var chat: ChatStore
|
||||
|
||||
init(settings: SettingsStore) {
|
||||
init(settings: SettingsStore, auth: AuthStore, user: AuthUser, bearerToken: String) {
|
||||
self.auth = auth
|
||||
self.user = user
|
||||
self.bearerToken = bearerToken
|
||||
self.settings = settings
|
||||
let initialSession = Self.makeSession(settings: settings)
|
||||
_session = State(initialValue: initialSession)
|
||||
_activeServerURL = State(initialValue: settings.trimmedServerURL)
|
||||
let url = settings.trimmedServerURL
|
||||
_activeServerURL = State(initialValue: url)
|
||||
_session = State(initialValue: Self.makeSession(url, settings: settings, bearerToken: bearerToken, userID: user.id))
|
||||
let api: ChatTransport = auth.fixture ? OfflineChatAPI() : ChatAPI(config: auth.config, token: bearerToken)
|
||||
_chat = StateObject(wrappedValue: ChatStore(api: api))
|
||||
#if DEBUG
|
||||
if auth.fixture && !ProcessInfo.processInfo.arguments.contains("--ui-chat") { _selectedTab = State(initialValue: 0) }
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Creates a session from the configured server URL. `SettingsStore.init`
|
||||
/// guarantees the stored value is always valid (legacy invalid values are
|
||||
/// sanitized to the documented default), so the failure branch is a safety
|
||||
/// net only.
|
||||
private static func makeSession(settings: SettingsStore) -> VoiceSession {
|
||||
let config = ServerConfig.parse(settings.trimmedServerURL)
|
||||
switch config {
|
||||
case .success(let value):
|
||||
return VoiceSession(config: value, tokenClient: TokenClient(config: value), settings: settings)
|
||||
case .failure:
|
||||
// Defensive: should be unreachable because SettingsStore sanitizes.
|
||||
let fallback = ServerConfig.parse(SettingsStore.defaultServerURL)
|
||||
if case .success(let value) = fallback {
|
||||
settings.serverURL = SettingsStore.defaultServerURL
|
||||
return VoiceSession(config: value, tokenClient: TokenClient(config: value), settings: settings)
|
||||
private static func makeSession(_ url: String, settings: SettingsStore, bearerToken: String, userID: String) -> VoiceSession {
|
||||
guard case .success(let config) = ServerConfig.parse(url) else {
|
||||
preconditionFailure("Stored server URL failed validation; init sanitizes to a valid value.")
|
||||
}
|
||||
fatalError("Unreachable: default server URL is valid")
|
||||
let session = VoiceSession(config: config, tokenClient: TokenClient(config: config, bearerToken: bearerToken), settings: settings, userID: userID)
|
||||
#if DEBUG
|
||||
if userID == "offline-fixture", ProcessInfo.processInfo.arguments.contains("--ui-fixtures") {
|
||||
session.transcriptHandler.receiveDataTranscript(role: .user, text: "Help me plan a quiet weekend.")
|
||||
session.transcriptHandler.receiveDataTranscript(role: .agent, text: "Let’s leave some room to breathe. A walk, a good book, and time for your favorite music would be a lovely start.")
|
||||
session.taskStore.injectSampleEvents()
|
||||
}
|
||||
if ProcessInfo.processInfo.arguments.contains("--ui-long-transcript") {
|
||||
for index in 1...30 {
|
||||
let role: TranscriptEvent.Role = index.isMultiple(of: 2) ? .agent : .user
|
||||
session.transcriptHandler.receiveDataTranscript(
|
||||
role: role,
|
||||
text: "Earlier message \(index): a deliberately long restored transcript row for reading-position verification."
|
||||
)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
return session
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
TabView(selection: $selectedTab) {
|
||||
NativeChatView(store: chat, talk: { id in
|
||||
await session.bindConversation(id)
|
||||
selectedTab = 0
|
||||
}, logout: {
|
||||
await auth.logout { chat.close(); await session.disconnect() }
|
||||
})
|
||||
.tabItem { Label("Chats", systemImage: "bubble.left.and.text.bubble.right") }
|
||||
.tag(2)
|
||||
NavigationStack {
|
||||
VStack(spacing: 0) {
|
||||
statusHeader
|
||||
TranscriptListView(
|
||||
events: session.transcriptHandler.events,
|
||||
onClear: {
|
||||
// Atomically clear the canonical visible collection,
|
||||
// dedupe state, and persistence so Clear is truthful.
|
||||
session.transcriptHandler.clearAll()
|
||||
ConversationContent(settings: settings, session: session, displayName: user.displayName ?? user.username)
|
||||
.toolbar {
|
||||
settingsLink
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
Menu {
|
||||
Button("Sign out", role: .destructive) {
|
||||
Task { await auth.logout { chat.close(); await session.disconnect() } }
|
||||
}
|
||||
)
|
||||
composer
|
||||
} label: {
|
||||
Image(systemName: "person.crop.circle")
|
||||
.frame(minWidth: 44, minHeight: 44)
|
||||
}
|
||||
.accessibilityLabel("Account")
|
||||
}
|
||||
}
|
||||
}
|
||||
.tabItem { Label("Talk", systemImage: "bubble.left.and.bubble.right") }
|
||||
.tag(0)
|
||||
|
||||
ActivityTab(taskStore: session.taskStore, settings: settings, session: session)
|
||||
.tag(1)
|
||||
}
|
||||
.tint(HopeTheme.color(.accent, palette: HopePalette(rawValue: chat.preferences.palette) ?? .opal))
|
||||
.preferredColorScheme(chat.preferences.appearance == "system" ? nil : (chat.preferences.appearance == "dark" ? .dark : .light))
|
||||
.onChange(of: settings.textMode) { _, enabled in
|
||||
Task { await session.applyTextMode(enabled) }
|
||||
}
|
||||
.onChange(of: settings.serverURL) { _, value in
|
||||
if auth.fixture { replaceServer(value) }
|
||||
}
|
||||
.navigationTitle("Hope Voice")
|
||||
.toolbar { toolbarContent }
|
||||
.id(sessionID)
|
||||
.onAppear {
|
||||
if case .failure(let error) = ServerConfig.parse(settings.trimmedServerURL) {
|
||||
connectError = error.localizedDescription
|
||||
}
|
||||
}
|
||||
.onChange(of: settings.textMode) { _, newValue in
|
||||
guard session.isReady else { return }
|
||||
Task { await session.applyTextMode(newValue) }
|
||||
}
|
||||
}
|
||||
.onReceive(session.objectWillChange.receive(on: RunLoop.main)) { _ in
|
||||
tick &+= 1
|
||||
}
|
||||
.onChange(of: settings.serverURL) { _, newValue in
|
||||
// The URL field now uses explicit Apply semantics (SettingsView),
|
||||
// so this fires only when the user has applied a value that passed
|
||||
// validation. Coalesce rapid accepted values: every accepted Apply
|
||||
// writes `pendingServerURL` (latest wins). A single retained worker
|
||||
// loop drains arbitrary pending URLs until none remain, covering
|
||||
// A→B→C semantics where C arrives while B is disconnecting
|
||||
// (Iteration 08 Item 2).
|
||||
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty, case .success = ServerConfig.parse(trimmed) else { return }
|
||||
|
||||
pendingServerURL = trimmed
|
||||
|
||||
// Start the worker if not already active. The loop consumes
|
||||
// pending values until empty, so a single Task handles any number
|
||||
// of rapid Applies without nested tasks.
|
||||
guard !isReplacingSession else { return }
|
||||
isReplacingSession = true
|
||||
Task { @MainActor [settings] in
|
||||
while let url = self.pendingServerURL {
|
||||
self.pendingServerURL = nil
|
||||
guard url != self.activeServerURL else { continue }
|
||||
let oldSession = self.session
|
||||
await oldSession.disconnect()
|
||||
self.activeServerURL = url
|
||||
self.sessionID = UUID()
|
||||
// Construct from the captured validated config, not by
|
||||
// rereading settings.serverURL (which may have changed
|
||||
// during disconnect).
|
||||
switch ServerConfig.parse(url) {
|
||||
case .success(let config):
|
||||
self.session = VoiceSession(config: config, tokenClient: TokenClient(config: config), settings: settings)
|
||||
case .failure:
|
||||
let fallback = ServerConfig.parse(SettingsStore.defaultServerURL)
|
||||
if case .success(let value) = fallback {
|
||||
self.session = VoiceSession(config: value, tokenClient: TokenClient(config: value), settings: settings)
|
||||
} else {
|
||||
fatalError("Unreachable: default server URL is valid")
|
||||
}
|
||||
}
|
||||
}
|
||||
self.isReplacingSession = false
|
||||
auth.disconnectAccount = { [weak session, weak chat] in chat?.close(); await session?.disconnect() }
|
||||
let unauthorized: () async -> Void = { [weak auth, weak session, weak chat] in
|
||||
await auth?.logout { chat?.close(); await session?.disconnect() }
|
||||
}
|
||||
session.onUnauthorized = unauthorized
|
||||
chat.onUnauthorized = unauthorized
|
||||
chat.features.onUnauthorized = unauthorized
|
||||
}
|
||||
.disabled(auth.isBusy || auth.token == nil)
|
||||
}
|
||||
|
||||
private var statusHeader: some View {
|
||||
VStack(spacing: 12) {
|
||||
OrbView(state: orbState)
|
||||
Text(statusText)
|
||||
.font(.headline)
|
||||
if session.state == .ready, session.stateModel.isThinkingChipVisible {
|
||||
Label("Thinking…", systemImage: "ellipsis")
|
||||
.font(.caption)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 4)
|
||||
.background(.orange.opacity(0.2), in: Capsule())
|
||||
}
|
||||
if session.state == .ready, !settings.textMode {
|
||||
micMeter
|
||||
}
|
||||
if session.state == .ready, settings.textMode {
|
||||
Text("Barge in anytime — type to reply")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
if let agent = session.agent {
|
||||
Text("Agent: \(agent.name ?? agent.identity)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
if session.state == .ready {
|
||||
Text("\(Voices.display(for: settings.voiceID) ?? settings.voiceID) · \(session.selectedModel ?? settings.modelID ?? "—")")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
if session.state == .ready {
|
||||
HStack(spacing: 16) {
|
||||
// The mic toggle is disabled in text mode to enforce the
|
||||
// privacy invariant at the UI level as well (Finding 4).
|
||||
Toggle(isOn: Binding(
|
||||
get: { !session.isMuted },
|
||||
set: { newValue in Task { await session.setMuted(!newValue) } }
|
||||
)) {
|
||||
Label(session.isMuted ? "Muted" : "Mic on", systemImage: session.isMuted ? "mic.slash" : "mic")
|
||||
}
|
||||
.toggleStyle(.button)
|
||||
.disabled(settings.textMode)
|
||||
Toggle("Text mode", isOn: textModeBinding)
|
||||
.toggleStyle(.button)
|
||||
}
|
||||
}
|
||||
if session.state == .ready, session.isReconnecting {
|
||||
Label("Connection lost — reconnecting…", systemImage: "arrow.triangle.2.circlepath")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.orange)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
if let micError = session.micError {
|
||||
Label(micError, systemImage: "exclamationmark.triangle")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.orange)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
if let connectError {
|
||||
Label(connectError, systemImage: "exclamationmark.triangle")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
|
||||
private var micMeter: some View {
|
||||
GeometryReader { geo in
|
||||
ZStack(alignment: .leading) {
|
||||
Capsule()
|
||||
.fill(Color(.systemFill))
|
||||
Capsule()
|
||||
.fill(Color.green.gradient)
|
||||
.frame(width: min(geo.size.width, geo.size.width * session.stateModel.micLevel * 2.5))
|
||||
}
|
||||
}
|
||||
.frame(height: 4)
|
||||
}
|
||||
|
||||
private var textModeBinding: Binding<Bool> {
|
||||
$settings.textMode
|
||||
}
|
||||
|
||||
private var composer: some View {
|
||||
HStack(spacing: 8) {
|
||||
switch session.state {
|
||||
case .idle, .failed:
|
||||
Button(action: startSession) {
|
||||
Label("Start", systemImage: "phone.fill")
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
case .connecting, .waitingAgent, .tearingDown:
|
||||
ProgressView()
|
||||
case .ready:
|
||||
TextField(settings.textMode ? "Type a message…" : "Voice mode active",
|
||||
text: $composerText,
|
||||
axis: .vertical)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.lineLimit(1...4)
|
||||
.disabled(!settings.textMode)
|
||||
Button(action: sendComposer) {
|
||||
Image(systemName: "arrow.up.circle.fill")
|
||||
.font(.title2)
|
||||
}
|
||||
.accessibilityLabel("Send message")
|
||||
.disabled(!settings.textMode || composerText.isEmpty)
|
||||
Button(role: .destructive, action: endSession) {
|
||||
Image(systemName: "phone.down.fill")
|
||||
}
|
||||
.accessibilityLabel("End conversation")
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
|
||||
@ToolbarContentBuilder
|
||||
private var toolbarContent: some ToolbarContent {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
NavigationLink {
|
||||
TaskPanelView(taskStore: session.taskStore)
|
||||
} label: {
|
||||
Image(systemName: "tray.full")
|
||||
.overlay(alignment: .topTrailing) {
|
||||
if session.taskStore.activeCount > 0 {
|
||||
Text("\(session.taskStore.activeCount)")
|
||||
.font(.caption2.weight(.bold))
|
||||
.foregroundStyle(.white)
|
||||
.padding(3)
|
||||
.background(Color.red, in: Circle())
|
||||
.offset(x: 6, y: -6)
|
||||
}
|
||||
}
|
||||
}
|
||||
.accessibilityLabel("Background tasks")
|
||||
}
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
NavigationLink {
|
||||
MediaPanelView(playerController: session.mediaController, settings: settings)
|
||||
} label: {
|
||||
Image(systemName: "music.note")
|
||||
}
|
||||
.accessibilityLabel("Media player")
|
||||
}
|
||||
@ToolbarContentBuilder private var settingsLink: some ToolbarContent {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
NavigationLink {
|
||||
SettingsView(settings: settings, session: session)
|
||||
} label: {
|
||||
Image(systemName: "gearshape")
|
||||
Image(systemName: "gearshape").frame(minWidth: 44, minHeight: 44)
|
||||
}
|
||||
.accessibilityLabel("Settings")
|
||||
}
|
||||
}
|
||||
|
||||
private var orbState: OrbView.OrbState {
|
||||
private func replaceServer(_ value: String) {
|
||||
let url = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard case .success = ServerConfig.parse(url) else { return }
|
||||
pendingServerURL = url
|
||||
guard !isReplacingSession else { return }
|
||||
isReplacingSession = true
|
||||
Task { @MainActor in
|
||||
// Coalesce rapid Apply actions; construct from the captured URL.
|
||||
while let next = pendingServerURL {
|
||||
pendingServerURL = nil
|
||||
guard next != activeServerURL else { continue }
|
||||
await session.disconnect()
|
||||
activeServerURL = next
|
||||
session = Self.makeSession(next, settings: settings, bearerToken: bearerToken, userID: user.id)
|
||||
}
|
||||
isReplacingSession = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct ActivityTab: View {
|
||||
@ObservedObject var taskStore: TaskStore
|
||||
@ObservedObject var settings: SettingsStore
|
||||
@ObservedObject var session: VoiceSession
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
TaskPanelView(taskStore: taskStore)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
NavigationLink {
|
||||
SettingsView(settings: settings, session: session)
|
||||
} label: {
|
||||
Image(systemName: "gearshape").frame(minWidth: 44, minHeight: 44)
|
||||
}
|
||||
.accessibilityLabel("Settings")
|
||||
}
|
||||
}
|
||||
}
|
||||
.tabItem { Label("Activity", systemImage: "checklist") }
|
||||
.badge(taskStore.activeCount)
|
||||
}
|
||||
}
|
||||
|
||||
/// Maps session + conversation models onto Lightwell presentation phases.
|
||||
@MainActor
|
||||
private func lightwellPhase(session: VoiceSession, conversation: ConversationStateModel,
|
||||
textMode: Bool) -> LightwellView.Phase {
|
||||
if session.state.isFailed { return .failed }
|
||||
switch session.state {
|
||||
case .idle: return .idle
|
||||
case .connecting, .waitingAgent, .tearingDown: return .connecting
|
||||
case .ready:
|
||||
guard !settings.textMode else { return .idle }
|
||||
switch session.stateModel.phase {
|
||||
case .idle: return .idle
|
||||
case .connecting: return .connecting
|
||||
case .listening: return .listening
|
||||
case .thinking: return .thinking
|
||||
case .speaking: return .speaking
|
||||
}
|
||||
case .waitingAgent: return .waiting
|
||||
case .tearingDown: return .tearingDown
|
||||
case .failed: return .failed
|
||||
}
|
||||
}
|
||||
|
||||
private var statusText: String {
|
||||
switch session.state {
|
||||
case .idle: return "Ready to connect"
|
||||
case .connecting: return "Connecting…"
|
||||
case .waitingAgent: return "Waiting for agent…"
|
||||
case .tearingDown: return "Cleaning up…"
|
||||
case .ready:
|
||||
if settings.textMode { return "Text mode" }
|
||||
switch session.stateModel.phase {
|
||||
case .listening: return "Listening"
|
||||
case .thinking: return "Thinking it through"
|
||||
case .speaking: return "Hope is speaking"
|
||||
default: return "Ready when you are"
|
||||
// Only substitute idle input geometry when otherwise LISTENING.
|
||||
// Assistant output (speaking/thinking) must stay represented in
|
||||
// Type and muted modes per spec.
|
||||
switch conversation.phase {
|
||||
case .speaking: return .speaking(level: conversation.agentLevel)
|
||||
case .thinking: return .thinking
|
||||
default:
|
||||
if textMode || session.isMuted { return .readyNoInput }
|
||||
return .listening(level: conversation.micLevel)
|
||||
}
|
||||
case .failed(let message): return "Failed: \(message)"
|
||||
}
|
||||
}
|
||||
|
||||
private func startSession() {
|
||||
connectError = nil
|
||||
// Starting a conversation preserves the restored last-50 history; only
|
||||
// the explicit Clear button erases it.
|
||||
private struct ConversationContent: View {
|
||||
@ObservedObject var settings: SettingsStore
|
||||
@ObservedObject var session: VoiceSession
|
||||
@ObservedObject private var transcripts: TranscriptHandler
|
||||
@ObservedObject private var conversation: ConversationStateModel
|
||||
let displayName: String
|
||||
@State private var composerText = ""
|
||||
@State private var sendError: String?
|
||||
@State private var isSending = false
|
||||
@FocusState private var composerFocused: Bool
|
||||
@Environment(\.dynamicTypeSize) private var typeSize
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
@Environment(\.colorSchemeContrast) private var contrast
|
||||
|
||||
init(settings: SettingsStore, session: VoiceSession, displayName: String) {
|
||||
self.settings = settings
|
||||
self.session = session
|
||||
self.displayName = displayName
|
||||
transcripts = session.transcriptHandler
|
||||
conversation = session.stateModel
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
if transcripts.events.isEmpty && !session.isReady {
|
||||
welcome
|
||||
if failureNotice != nil { failureBanner }
|
||||
} else {
|
||||
statusStrip
|
||||
if let activity = session.assistantActivity {
|
||||
Label(activity, systemImage: "clock").font(.callout)
|
||||
.padding().accessibilityIdentifier("assistant.progress")
|
||||
}
|
||||
if session.isReconnecting { reconnectBanner }
|
||||
if failureNotice != nil { failureBanner }
|
||||
TranscriptListView(events: transcripts.events, onClear: transcripts.clearAll)
|
||||
}
|
||||
}
|
||||
.background(Nearlight.ground)
|
||||
.navigationTitle("Hope")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.safeAreaInset(edge: .bottom, spacing: 0) { dock }
|
||||
.onChange(of: session.state) { _, state in
|
||||
if state == .ready && settings.textMode { composerFocused = true }
|
||||
}
|
||||
.sensoryFeedback(.success, trigger: session.state == .ready) { old, new in
|
||||
// Success only on the transition INTO readiness, not out.
|
||||
!old && new
|
||||
}
|
||||
.sensoryFeedback(.error, trigger: session.state.isFailed) { old, new in
|
||||
// Error only on entering failure, not leaving it.
|
||||
!old && new
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Welcome (pre-connection, no history)
|
||||
|
||||
private var welcome: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: Nearlight.s16) {
|
||||
VStack(spacing: Nearlight.s8) {
|
||||
Text("A little room for you,")
|
||||
.font(.largeTitle.weight(.regular))
|
||||
Text(greetingName + ".")
|
||||
.font(.system(.largeTitle, design: .serif).italic())
|
||||
.foregroundStyle(Nearlight.rosewood)
|
||||
Text("No perfect words needed. Start anywhere.")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(Nearlight.quiet)
|
||||
}
|
||||
.multilineTextAlignment(.center)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.padding(.top, Nearlight.s24)
|
||||
|
||||
LightwellView(phase: lightwellPhase(session: session, conversation: conversation, textMode: settings.textMode))
|
||||
.padding(.top, Nearlight.s8)
|
||||
Text(statusTitle)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(Nearlight.rosewood)
|
||||
.accessibilityIdentifier("talk.status")
|
||||
|
||||
if !typeSize.isAccessibilitySize {
|
||||
VStack(spacing: Nearlight.s8) {
|
||||
HStack(spacing: Nearlight.s8) {
|
||||
prompt("Talk through my day", draft: "Help me talk through my day.")
|
||||
prompt("Explore an idea", draft: "Help me explore an idea.")
|
||||
}
|
||||
prompt("Make a plan", draft: "Help me make a plan.")
|
||||
}
|
||||
.padding(.top, Nearlight.s8)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.horizontal, Nearlight.s20)
|
||||
.padding(.bottom, Nearlight.s24)
|
||||
}
|
||||
}
|
||||
|
||||
private var greetingName: String {
|
||||
displayName.split(separator: " ").first.map(String.init) ?? displayName
|
||||
}
|
||||
|
||||
private func prompt(_ title: String, draft: String) -> some View {
|
||||
Button {
|
||||
// Prepare an editable draft, never submit a prompt or enable capture implicitly.
|
||||
composerText = draft
|
||||
settings.textMode = true
|
||||
composerFocused = true
|
||||
} label: {
|
||||
Text(title)
|
||||
.font(.footnote)
|
||||
.padding(.horizontal, Nearlight.s16)
|
||||
.frame(minHeight: 44)
|
||||
.background(Nearlight.selectedWash, in: Capsule())
|
||||
.foregroundStyle(Nearlight.rosewood)
|
||||
}
|
||||
.accessibilityHint("Prepares a message you can edit. Connect before sending.")
|
||||
}
|
||||
|
||||
/// Status text 24 below the supporting copy on welcome.
|
||||
private var statusLine: some View {
|
||||
Text(statusTitle)
|
||||
.font(NearlightFont.support(typeSize))
|
||||
.foregroundStyle(Nearlight.quiet)
|
||||
.padding(.top, Nearlight.s24)
|
||||
.accessibilityIdentifier("talk.status")
|
||||
}
|
||||
|
||||
// MARK: Status strip (active conversation)
|
||||
|
||||
private var statusStrip: some View {
|
||||
VStack(spacing: Nearlight.s12) {
|
||||
LightwellView(phase: lightwellPhase(session: session, conversation: conversation, textMode: settings.textMode), compact: true)
|
||||
VStack(alignment: .center, spacing: 2) {
|
||||
Text(statusTitle)
|
||||
.font(NearlightFont.control(typeSize))
|
||||
.foregroundStyle(Nearlight.ink)
|
||||
.accessibilityIdentifier("talk.status")
|
||||
if showStatusDetail {
|
||||
Text(statusDetail)
|
||||
.font(NearlightFont.support(typeSize))
|
||||
.foregroundStyle(Nearlight.quiet)
|
||||
}
|
||||
}
|
||||
.multilineTextAlignment(.center)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.horizontal, Nearlight.s20)
|
||||
.padding(.vertical, Nearlight.s12)
|
||||
.background(Nearlight.ground)
|
||||
}
|
||||
|
||||
/// Subtitle omitted for ordinary listening/thinking/speaking per spec.
|
||||
private var showStatusDetail: Bool {
|
||||
guard session.isReady else { return true }
|
||||
if settings.textMode || session.isMuted { return true }
|
||||
return false
|
||||
}
|
||||
|
||||
private var reconnectBanner: some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Label("Reconnecting…", systemImage: "wifi.exclamationmark")
|
||||
.font(NearlightFont.control(typeSize))
|
||||
Text("Your conversation is still here.")
|
||||
.font(NearlightFont.support(typeSize))
|
||||
.foregroundStyle(Nearlight.quiet)
|
||||
}
|
||||
.padding(Nearlight.s12)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(Nearlight.surface)
|
||||
.clipShape(RoundedRectangle(cornerRadius: Nearlight.radiusControl))
|
||||
.padding(.horizontal, Nearlight.s20)
|
||||
.accessibilityIdentifier("talk.connection.notice")
|
||||
}
|
||||
|
||||
/// Classified failure notice with actionable recovery.
|
||||
private var failureBanner: some View {
|
||||
VStack(alignment: .leading, spacing: Nearlight.s8) {
|
||||
Label(failureTitle, systemImage: "exclamationmark.circle")
|
||||
.font(NearlightFont.control(typeSize))
|
||||
.foregroundStyle(Nearlight.alert)
|
||||
Text(failureBody)
|
||||
.font(NearlightFont.support(typeSize))
|
||||
.foregroundStyle(Nearlight.ink)
|
||||
if micDenied {
|
||||
Button("Open iPhone Settings") {
|
||||
if let url = URL(string: UIApplication.openSettingsURLString) {
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
}
|
||||
.font(NearlightFont.support(typeSize))
|
||||
.frame(minHeight: 44)
|
||||
.accessibilityIdentifier("talk.connection.openSystemSettings")
|
||||
}
|
||||
}
|
||||
.padding(Nearlight.s16)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(Nearlight.errorWash)
|
||||
.clipShape(RoundedRectangle(cornerRadius: Nearlight.radiusControl))
|
||||
.padding(.horizontal, Nearlight.s20)
|
||||
.accessibilityIdentifier("talk.connection.notice")
|
||||
}
|
||||
|
||||
private var micDenied: Bool {
|
||||
guard session.state == .ready, let micError = session.micError else {
|
||||
return false
|
||||
}
|
||||
return AVAudioApplication.shared.recordPermission == .denied
|
||||
|| micError.localizedCaseInsensitiveContains("denied")
|
||||
}
|
||||
|
||||
private var failureTitle: String {
|
||||
if session.micError != nil { return "Microphone unavailable" }
|
||||
return session.everConnected ? "Connection interrupted" : "Couldn’t connect"
|
||||
}
|
||||
|
||||
private var failureBody: String {
|
||||
if let micError = session.micError {
|
||||
return micError
|
||||
}
|
||||
let raw = failureMessage ?? ""
|
||||
// Only the agent-timeout path is a known classified cause; every
|
||||
// other failure keeps its actual readable error per spec.
|
||||
if raw.contains("Agent never joined") {
|
||||
return "Check that Hope is running on your server."
|
||||
}
|
||||
return raw.isEmpty ? "The connection failed. Try again." : raw
|
||||
}
|
||||
|
||||
// MARK: Dock
|
||||
|
||||
private var dock: some View {
|
||||
VStack(spacing: Nearlight.s12) {
|
||||
if let sendError {
|
||||
Label(sendError, systemImage: "exclamationmark.circle")
|
||||
.font(NearlightFont.support(typeSize))
|
||||
.foregroundStyle(Nearlight.alert)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.accessibilityIdentifier("talk.composer.error")
|
||||
}
|
||||
modeSelector
|
||||
if session.isReady {
|
||||
if settings.textMode { typeControls } else { voiceControls }
|
||||
} else if isBusy && !session.state.isFailed {
|
||||
// Connecting/waiting: keep the draft readable and expose the
|
||||
// existing cancellation path per spec.
|
||||
if settings.textMode { typeControls }
|
||||
else { HStack(spacing: Nearlight.s12) { muteButton; micMeter } }
|
||||
endButton
|
||||
} else {
|
||||
if settings.textMode { draftPreview }
|
||||
startControls
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, Nearlight.s20)
|
||||
.padding(.top, Nearlight.s12)
|
||||
.padding(.bottom, Nearlight.s12)
|
||||
.background(Nearlight.surface, in: RoundedRectangle(cornerRadius: Nearlight.radiusDock))
|
||||
.padding(.horizontal, Nearlight.s16)
|
||||
.padding(.bottom, Nearlight.s8)
|
||||
|
||||
}
|
||||
|
||||
@ViewBuilder private var modeSelector: some View {
|
||||
if typeSize.isAccessibilitySize {
|
||||
// Two stacked 52-point selection buttons at accessibility sizes.
|
||||
VStack(spacing: Nearlight.s8) {
|
||||
modeButton("Voice", systemImage: "mic", selected: !settings.textMode, id: "talk.mode.voice") {
|
||||
settings.textMode = false
|
||||
}
|
||||
modeButton("Type", systemImage: "keyboard", selected: settings.textMode, id: "talk.mode.type") {
|
||||
settings.textMode = true
|
||||
}
|
||||
}
|
||||
.disabled(isBusy)
|
||||
} else {
|
||||
Picker("Conversation mode", selection: $settings.textMode) {
|
||||
Label("Voice", systemImage: "mic").tag(false)
|
||||
Label("Type", systemImage: "keyboard").tag(true)
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.disabled(isBusy)
|
||||
}
|
||||
}
|
||||
|
||||
private func modeButton(_ title: String, systemImage: String, selected: Bool,
|
||||
id: String, action: @escaping () -> Void) -> some View {
|
||||
Button(action: action) {
|
||||
HStack(spacing: Nearlight.s8) {
|
||||
Image(systemName: systemImage)
|
||||
Text(title)
|
||||
if selected { Spacer(); Image(systemName: "checkmark") }
|
||||
}
|
||||
.font(NearlightFont.control(typeSize))
|
||||
.foregroundStyle(selected ? Nearlight.rosewood : Nearlight.ink)
|
||||
.frame(maxWidth: .infinity, minHeight: 52)
|
||||
.background(selected && contrast == .increased ? Nearlight.surface : selected ? Nearlight.selectedWash : Nearlight.surface,
|
||||
in: RoundedRectangle(cornerRadius: Nearlight.radiusControl))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Nearlight.radiusControl)
|
||||
.strokeBorder(selected && contrast == .increased ? Nearlight.rosewood : selected ? Nearlight.rosewood.opacity(0.4) : Nearlight.quiet,
|
||||
lineWidth: selected && contrast == .increased ? 2 : 1)
|
||||
)
|
||||
}
|
||||
.accessibilityIdentifier(id)
|
||||
.accessibilityAddTraits(selected ? .isSelected : [])
|
||||
}
|
||||
|
||||
private var draftPreview: some View {
|
||||
TextField("What’s on your mind?", text: $composerText, axis: .vertical)
|
||||
.lineLimit(1...3)
|
||||
.font(.body)
|
||||
.padding(Nearlight.s12)
|
||||
.background(Nearlight.selectedWash, in: RoundedRectangle(cornerRadius: Nearlight.radiusControl))
|
||||
.focused($composerFocused)
|
||||
.accessibilityLabel("Message Hope")
|
||||
}
|
||||
|
||||
private var startControls: some View {
|
||||
VStack(spacing: Nearlight.s8) {
|
||||
Text(settings.textMode ? "Your microphone stays off." : "Your microphone starts after you connect.")
|
||||
.font(NearlightFont.support(typeSize))
|
||||
.foregroundStyle(Nearlight.quiet)
|
||||
Button {
|
||||
sendError = nil
|
||||
Task { await session.connect() }
|
||||
} label: {
|
||||
HStack(spacing: Nearlight.s8) {
|
||||
if isBusy {
|
||||
ProgressView().tint(Nearlight.onRosewood)
|
||||
} else {
|
||||
Image(systemName: settings.textMode ? "keyboard" : "mic")
|
||||
}
|
||||
Text(isBusy ? statusTitle : session.state.isFailed ? "Try again" : "Start chatting")
|
||||
.font(NearlightFont.control(typeSize))
|
||||
}
|
||||
.frame(maxWidth: .infinity, minHeight: 52)
|
||||
.background(isBusy ? Nearlight.rosewood.opacity(0.6) : Nearlight.rosewood,
|
||||
in: RoundedRectangle(cornerRadius: Nearlight.radiusControl))
|
||||
.foregroundStyle(Nearlight.onRosewood)
|
||||
}
|
||||
.disabled(isBusy)
|
||||
.accessibilityIdentifier("start.chatting")
|
||||
}
|
||||
}
|
||||
|
||||
private func endSession() {
|
||||
private var voiceControls: some View {
|
||||
Group {
|
||||
if typeSize.isAccessibilitySize {
|
||||
VStack(spacing: Nearlight.s12) {
|
||||
HStack(spacing: Nearlight.s12) {
|
||||
muteButton
|
||||
micMeter
|
||||
}
|
||||
endButton
|
||||
}
|
||||
} else {
|
||||
HStack(spacing: Nearlight.s12) {
|
||||
muteButton
|
||||
micMeter
|
||||
endButton
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var muteButton: some View {
|
||||
Button {
|
||||
Task { await session.setMuted(!session.isMuted) }
|
||||
} label: {
|
||||
Image(systemName: session.isMuted ? "mic.slash" : "mic")
|
||||
.font(.system(size: 20, weight: .medium))
|
||||
.frame(width: 52, height: 52)
|
||||
.background(session.isMuted ? Nearlight.selectedWash : Nearlight.surface,
|
||||
in: RoundedRectangle(cornerRadius: Nearlight.radiusControl))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Nearlight.radiusControl)
|
||||
.strokeBorder(Nearlight.quiet, lineWidth: 1)
|
||||
)
|
||||
.foregroundStyle(session.isMuted ? Nearlight.rosewood : Nearlight.ink)
|
||||
}
|
||||
.accessibilityLabel(session.isMuted ? "Unmute microphone" : "Mute microphone")
|
||||
.accessibilityIdentifier("talk.microphone.toggle")
|
||||
.sensoryFeedback(.selection, trigger: session.isMuted)
|
||||
}
|
||||
|
||||
/// Five-bar capsule meter, 64×24, leading aligned.
|
||||
private var micMeter: some View {
|
||||
HStack(spacing: 6) {
|
||||
let level = session.isMuted || settings.textMode ? 0.0 : conversation.micLevel
|
||||
let filled = Int(ceil(level * 5))
|
||||
ForEach(0..<5, id: \.self) { index in
|
||||
Capsule()
|
||||
.fill(index < filled ? Nearlight.rosewood : Color.clear)
|
||||
.overlay(index < filled ? nil : Capsule().strokeBorder(Nearlight.quiet, lineWidth: 1))
|
||||
.frame(width: 6, height: [6.0, 12.0, 24.0, 12.0, 6.0][index])
|
||||
}
|
||||
}
|
||||
.frame(width: 64, height: 24, alignment: .leading)
|
||||
.accessibilityElement(children: .ignore)
|
||||
.accessibilityLabel("Microphone level")
|
||||
.accessibilityValue(session.isMuted || settings.textMode ? "Off" : "\(Int(round(conversation.micLevel * 100)))%")
|
||||
}
|
||||
|
||||
private var typeControls: some View {
|
||||
VStack(spacing: Nearlight.s12) {
|
||||
HStack(alignment: .bottom, spacing: Nearlight.s8) {
|
||||
TextField("What’s on your mind?", text: $composerText, axis: .vertical)
|
||||
.lineLimit(1...5)
|
||||
.padding(Nearlight.s12)
|
||||
.background(Nearlight.surface, in: RoundedRectangle(cornerRadius: Nearlight.radiusControl))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Nearlight.radiusControl)
|
||||
.strokeBorder(Nearlight.quiet, lineWidth: 1)
|
||||
)
|
||||
.focused($composerFocused)
|
||||
.disabled(isSending)
|
||||
.accessibilityLabel("Message Hope")
|
||||
Button(action: sendComposer) {
|
||||
if isSending {
|
||||
ProgressView().frame(width: 52, height: 52)
|
||||
} else {
|
||||
Image(systemName: "arrow.up")
|
||||
.font(.system(size: 20, weight: .medium))
|
||||
.frame(width: 52, height: 52)
|
||||
.background(Nearlight.rosewood, in: RoundedRectangle(cornerRadius: Nearlight.radiusControl))
|
||||
.foregroundStyle(Nearlight.onRosewood)
|
||||
}
|
||||
}
|
||||
.disabled(composerText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isSending || session.isReconnecting || !session.isReady)
|
||||
.accessibilityLabel("Send message")
|
||||
}
|
||||
endButton
|
||||
}
|
||||
}
|
||||
|
||||
private var endButton: some View {
|
||||
Button(role: .destructive) {
|
||||
composerFocused = false
|
||||
Task { await session.disconnect() }
|
||||
} label: {
|
||||
Label("End conversation", systemImage: "stop.fill")
|
||||
.font(NearlightFont.control(typeSize))
|
||||
.lineLimit(2...3)
|
||||
.frame(maxWidth: .infinity, minHeight: 52)
|
||||
.background(Nearlight.surface, in: RoundedRectangle(cornerRadius: Nearlight.radiusControl))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Nearlight.radiusControl)
|
||||
.strokeBorder(Nearlight.alert, lineWidth: 1)
|
||||
)
|
||||
.foregroundStyle(Nearlight.alert)
|
||||
}
|
||||
.accessibilityIdentifier("end.conversation")
|
||||
}
|
||||
|
||||
// MARK: Status copy
|
||||
|
||||
private var isBusy: Bool {
|
||||
session.state == .connecting || session.state == .waitingAgent || session.state == .tearingDown
|
||||
}
|
||||
private var failureMessage: String? {
|
||||
if case .failed(let message) = session.state { return message }
|
||||
return nil
|
||||
}
|
||||
private var failureNotice: String? { session.micError ?? failureMessage }
|
||||
|
||||
private var statusTitle: String {
|
||||
switch session.state {
|
||||
case .idle: return "Ready when you are"
|
||||
case .connecting: return "Connecting to your server…"
|
||||
case .waitingAgent: return "Waiting for Hope…"
|
||||
case .tearingDown: return "Ending conversation…"
|
||||
case .failed: return "Connection interrupted"
|
||||
case .ready:
|
||||
// Assistant output outranks input-mode substitution.
|
||||
switch conversation.phase {
|
||||
case .speaking: return "Hope is speaking"
|
||||
case .thinking: return "Thinking…"
|
||||
default:
|
||||
if settings.textMode { return "Ready for your message" }
|
||||
if session.isMuted { return "Microphone off" }
|
||||
return "Listening"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var statusDetail: String {
|
||||
if session.state == .waitingAgent { return "The server is connected. Hope has not joined yet." }
|
||||
if session.state == .connecting { return "Reaching your server." }
|
||||
if !session.isReady { return "" }
|
||||
if settings.textMode { return "Microphone off · Type whenever you’re ready" }
|
||||
if session.isMuted { return "You can still hear Hope." }
|
||||
return ""
|
||||
}
|
||||
|
||||
private func sendComposer() {
|
||||
let text = composerText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !text.isEmpty else { return }
|
||||
composerText = ""
|
||||
guard !text.isEmpty, !isSending else { return }
|
||||
isSending = true
|
||||
sendError = nil
|
||||
Task {
|
||||
do {
|
||||
try await session.sendText(text)
|
||||
composerText = ""
|
||||
} catch {
|
||||
connectError = error.localizedDescription
|
||||
sendError = "Your message wasn’t sent. Try again when connected."
|
||||
}
|
||||
isSending = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
ConversationView(settings: SettingsStore())
|
||||
extension VoiceSession {
|
||||
/// Whether any connection reached ready during this session lifetime.
|
||||
/// Used to title the failure notice per spec.
|
||||
var everConnected: Bool {
|
||||
// Sessions are recreated on replacement; reaching ready is recorded
|
||||
// indirectly through transcript presence. Conservative: treat any
|
||||
// prior transcript activity as evidence of a completed connection.
|
||||
!transcriptHandler.events.isEmpty
|
||||
}
|
||||
}
|
||||
|
||||
#Preview { AuthenticationRoot(settings: SettingsStore()) }
|
||||
@@ -0,0 +1,125 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Hope's square LED presence, matching the web's 21 × 21 dot matrix.
|
||||
/// One Canvas replaces hundreds of subviews. The adjacent status text is the
|
||||
/// single accessible description; individual LEDs are never focusable.
|
||||
struct LightwellView: View {
|
||||
enum Phase: Equatable {
|
||||
case idle, connecting, waiting, thinking, tearingDown, failed, readyNoInput
|
||||
case listening(level: Double)
|
||||
case speaking(level: Double)
|
||||
}
|
||||
|
||||
let phase: Phase
|
||||
var compact = false
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
@Environment(\.colorSchemeContrast) private var contrast
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
@Environment(\.dynamicTypeSize) private var typeSize
|
||||
|
||||
private var side: CGFloat {
|
||||
if compact { return typeSize.isAccessibilitySize ? 80 : 112 }
|
||||
return typeSize.isAccessibilitySize ? 136 : 184
|
||||
}
|
||||
|
||||
private var animated: Bool {
|
||||
guard !reduceMotion, scenePhase == .active else { return false }
|
||||
switch phase {
|
||||
case .connecting, .waiting, .thinking, .listening, .speaking: return true
|
||||
default: return false
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if animated {
|
||||
TimelineView(.animation(minimumInterval: 1.0 / 20.0)) { timeline in
|
||||
matrix(time: timeline.date.timeIntervalSinceReferenceDate)
|
||||
}
|
||||
} else {
|
||||
matrix(time: 0)
|
||||
}
|
||||
}
|
||||
.frame(width: side, height: side)
|
||||
.background(Nearlight.personaSurface, in: RoundedRectangle(cornerRadius: Nearlight.radiusDock))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: Nearlight.radiusDock)
|
||||
.strokeBorder(contrast == .increased ? Nearlight.quiet : Nearlight.separator, lineWidth: 1)
|
||||
}
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
|
||||
private var tint: Color {
|
||||
switch phase {
|
||||
case .failed: return Nearlight.alert
|
||||
case .tearingDown: return Nearlight.quiet
|
||||
default: return Nearlight.rosewood
|
||||
}
|
||||
}
|
||||
|
||||
private func matrix(time: Double) -> some View {
|
||||
Canvas { context, size in
|
||||
let spacing = (min(size.width, size.height) - 28) / 20
|
||||
for row in 0..<21 {
|
||||
for column in 0..<21 {
|
||||
let dot = Self.dot(row: row, column: column, phase: phase,
|
||||
time: time, reduceMotion: reduceMotion)
|
||||
let diameter = max(1.6, spacing * 0.28) * dot.scale
|
||||
let center = CGPoint(x: 14 + CGFloat(column) * spacing,
|
||||
y: 14 + CGFloat(row) * spacing)
|
||||
let rect = CGRect(x: center.x - diameter / 2, y: center.y - diameter / 2,
|
||||
width: diameter, height: diameter)
|
||||
// Increased contrast brightens the whole lattice; state is
|
||||
// encoded in its pattern and adjacent text, never color alone.
|
||||
let opacity = contrast == .increased ? min(1, dot.opacity * 1.25 + 0.16) : dot.opacity
|
||||
context.fill(Path(ellipseIn: rect), with: .color(tint.opacity(opacity)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Dot: Equatable {
|
||||
let opacity: Double
|
||||
let scale: Double
|
||||
}
|
||||
|
||||
/// Deterministic sampling is shared with offline tests. Reduce Motion freezes
|
||||
/// waves and signal-driven geometry, while keeping each state's pattern clear.
|
||||
static func dot(row: Int, column: Int, phase: Phase, time: Double, reduceMotion: Bool) -> Dot {
|
||||
let x = Double(column - 10) / 10.5
|
||||
let y = Double(row - 10) / 10.5
|
||||
let radius = sqrt(x * x + y * y)
|
||||
let t = reduceMotion ? 0 : time
|
||||
let baseline = max(0.08, 0.20 - radius * 0.075)
|
||||
let offset = sin(Double(row) * 12.9898 + Double(column) * 78.233)
|
||||
var brightness = baseline
|
||||
var scale = 1.0
|
||||
switch phase {
|
||||
case .idle, .tearingDown:
|
||||
break
|
||||
case .readyNoInput:
|
||||
brightness += 0.28 * exp(-radius * radius * 3)
|
||||
case .failed:
|
||||
// A stable diagonal cross rather than an animated alarm.
|
||||
if abs(row - column) <= 1 || abs(row + column - 20) <= 1 { brightness = 0.65 }
|
||||
case .connecting, .waiting:
|
||||
let ring = exp(-pow((radius - 0.53) * 7, 2))
|
||||
let sweep = reduceMotion ? 0.75 : 0.55 + 0.45 * cos(atan2(y, x) - t * 2)
|
||||
brightness += ring * sweep * 0.65
|
||||
case .thinking:
|
||||
let bandCenter = reduceMotion ? 0 : sin(t * 1.8) * 0.5
|
||||
let band = exp(-pow((y - bandCenter) * 4, 2)) * exp(-x * x * 2)
|
||||
brightness += band * 0.65
|
||||
case .listening(let raw), .speaking(let raw):
|
||||
let level = reduceMotion ? 0.35 : (raw.isFinite ? min(max(raw, 0), 1) : 0)
|
||||
let speaking: Bool
|
||||
if case .speaking = phase { speaking = true } else { speaking = false }
|
||||
let signal = min(1, level * 1.3)
|
||||
let bloom = exp(-radius * radius * (3.0 - signal * 1.5))
|
||||
let ripple = reduceMotion ? 1 : 0.85 + 0.15 * sin(t * 4 + offset)
|
||||
brightness += bloom * (0.25 + signal * 0.7) * ripple * (speaking ? 1 : 0.65)
|
||||
scale += max(0, 1 - radius) * (speaking ? 1.4 : 0.85) * signal
|
||||
}
|
||||
return Dot(opacity: min(max(brightness, 0.02), 1), scale: scale)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import SwiftUI
|
||||
|
||||
struct LoginView: View {
|
||||
@ObservedObject var auth: AuthStore
|
||||
@ObservedObject var settings: SettingsStore
|
||||
@State private var username = ""
|
||||
@State private var password = ""
|
||||
@State private var server = ""
|
||||
@State private var serverError: String?
|
||||
@State private var savedServer: String?
|
||||
@State private var passwordVisible = false
|
||||
@Environment(\.dynamicTypeSize) private var typeSize
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section {
|
||||
VStack(alignment: .leading, spacing: Nearlight.s12) {
|
||||
Text("Sign in to Hope")
|
||||
.font(NearlightFont.welcome(typeSize))
|
||||
.foregroundStyle(Nearlight.ink)
|
||||
.accessibilityAddTraits(.isHeader)
|
||||
Text("Use the account provided by your server administrator. Hermes is managed by your server.")
|
||||
.font(.body)
|
||||
.foregroundStyle(Nearlight.quiet)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
.padding(.vertical, Nearlight.s16)
|
||||
.listRowBackground(Nearlight.ground)
|
||||
.listRowSeparator(.hidden)
|
||||
}
|
||||
Section("Account") {
|
||||
TextField("Username", text: $username).textContentType(.username)
|
||||
.textInputAutocapitalization(.never).autocorrectionDisabled()
|
||||
HStack {
|
||||
Group {
|
||||
if passwordVisible { TextField("Password", text: $password) }
|
||||
else { SecureField("Password", text: $password) }
|
||||
}
|
||||
.textContentType(.password)
|
||||
Button(passwordVisible ? "Hide" : "Show") { passwordVisible.toggle() }
|
||||
.accessibilityLabel(passwordVisible ? "Hide password" : "Show password")
|
||||
}
|
||||
Button("Sign in") {
|
||||
let suppliedPassword = password
|
||||
Task {
|
||||
await auth.login(username: username, password: suppliedPassword)
|
||||
if auth.user != nil { password = "" }
|
||||
}
|
||||
}
|
||||
.font(NearlightFont.control(typeSize))
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(Nearlight.rosewood)
|
||||
.foregroundStyle(Nearlight.onRosewood)
|
||||
.frame(minHeight: 44)
|
||||
.padding(.vertical, Nearlight.s4)
|
||||
.disabled(auth.isBusy || username.isEmpty || password.isEmpty)
|
||||
if auth.isBusy { ProgressView("Signing in…").tint(Nearlight.rosewood) }
|
||||
if let error = auth.error {
|
||||
Text(error).foregroundStyle(Nearlight.alert)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
.listRowBackground(Nearlight.surface)
|
||||
Section("Server") {
|
||||
TextField("HTTPS server address", text: $server).keyboardType(.URL)
|
||||
.textInputAutocapitalization(.never).autocorrectionDisabled()
|
||||
Button("Save server address") {
|
||||
switch ServerConfig.parse(server) {
|
||||
case .success(let config):
|
||||
server = config.baseURL.absoluteString
|
||||
settings.serverURL = config.baseURL.absoluteString
|
||||
serverError = nil
|
||||
savedServer = config.baseURL.absoluteString
|
||||
case .failure(let error): serverError = error.localizedDescription; savedServer = nil
|
||||
}
|
||||
}.disabled(auth.isBusy || server.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || server == settings.serverURL)
|
||||
if let serverError {
|
||||
Label(serverError, systemImage: "exclamationmark.circle").foregroundStyle(Nearlight.alert)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
} else if savedServer != nil {
|
||||
Label("Server address saved. Sign in uses this server.", systemImage: "checkmark.circle").font(.footnote).foregroundStyle(Nearlight.quiet)
|
||||
}
|
||||
Text("Connections use HTTPS and secure WebSockets. A private server certificate must be trusted by this iPhone for conversations.")
|
||||
.font(.footnote).foregroundStyle(Nearlight.quiet)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
if auth.config.baseURL.host == TLSPinning.host, auth.config.baseURL.port == 9178 {
|
||||
Link("Install Hope server certificate in Safari", destination: auth.config.baseURL.appendingPathComponent("hope-server.mobileconfig"))
|
||||
Text("Install Hope LAN Server Trust in Settings → General → VPN & Device Management. Then enable 192.168.86.2 in Settings → General → About → Certificate Trust Settings. Only trust this certificate for your own server.")
|
||||
.font(.footnote).foregroundStyle(Nearlight.quiet)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
.listRowBackground(Nearlight.surface)
|
||||
}
|
||||
.foregroundStyle(Nearlight.ink)
|
||||
.tint(Nearlight.rosewood)
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(Nearlight.ground)
|
||||
.navigationTitle("Hope")
|
||||
.toolbarBackground(Nearlight.ground, for: .navigationBar)
|
||||
.onAppear { server = settings.serverURL }
|
||||
.onChange(of: server) { _, _ in serverError = nil; savedServer = nil }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct AuthenticationRoot: View {
|
||||
@ObservedObject var settings: SettingsStore
|
||||
@State private var auth: AuthStore
|
||||
@State private var replacement: Task<Void, Never>?
|
||||
init(settings: SettingsStore) {
|
||||
self.settings = settings
|
||||
_auth = State(initialValue: Self.makeAuth(settings))
|
||||
}
|
||||
private static func makeAuth(_ settings: SettingsStore) -> AuthStore {
|
||||
guard case .success(let config) = ServerConfig.parse(settings.serverURL) else { preconditionFailure("Invalid configured origin") }
|
||||
#if DEBUG
|
||||
if ProcessInfo.processInfo.arguments.contains("--ui-testing"), !ProcessInfo.processInfo.arguments.contains("--live-testing") {
|
||||
return AuthStore(config: config, tokens: MemoryTokenStore(), fixture: true)
|
||||
}
|
||||
#endif
|
||||
return AuthStore(config: config)
|
||||
}
|
||||
var body: some View {
|
||||
RootContent(settings: settings, auth: auth)
|
||||
.task(id: auth.config.baseURL) { await auth.restore() }
|
||||
.onChange(of: settings.serverURL) { _, _ in
|
||||
guard !auth.fixture else { return }
|
||||
guard replacement == nil else { return }
|
||||
replacement = Task { @MainActor in
|
||||
await auth.logout { await auth.disconnectAccount?() }
|
||||
guard !Task.isCancelled else { return }
|
||||
// Read latest origin after teardown; intermediate edits never
|
||||
// restore credentials or connect an intermediate account.
|
||||
auth = Self.makeAuth(settings)
|
||||
replacement = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
private struct RootContent: View {
|
||||
@ObservedObject var settings: SettingsStore
|
||||
@ObservedObject var auth: AuthStore
|
||||
var body: some View {
|
||||
if let user = auth.user {
|
||||
ConversationView(settings: settings, auth: auth, user: user, bearerToken: auth.token ?? "")
|
||||
.id(auth.config.baseURL.absoluteString + ":" + user.id)
|
||||
|
||||
} else {
|
||||
LoginView(auth: auth, settings: settings)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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 }
|
||||
if let urlError {
|
||||
Text(urlError)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.red)
|
||||
.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
|
||||
}
|
||||
Button("Apply server URL") { applyServerURL() }
|
||||
.disabled(!canApplyServerURL || urlText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
.overlay(alignment: .trailing) {
|
||||
if savedURLConfirmation != nil {
|
||||
Image(systemName: "checkmark.circle")
|
||||
.foregroundStyle(Nearlight.quiet)
|
||||
.padding(.trailing, 8)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
}
|
||||
if let urlError {
|
||||
Label(urlError, systemImage: "exclamationmark.circle")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(Nearlight.alert)
|
||||
.accessibilityIdentifier("settings.server.error")
|
||||
} else if savedURLConfirmation != nil {
|
||||
Text("Server address saved.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(Nearlight.quiet)
|
||||
.accessibilityIdentifier("settings.server.saved")
|
||||
}
|
||||
Button("Change server") { requestServerURLChange() }
|
||||
.disabled(!canApplyServerURL || urlText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || urlText == settings.serverURL)
|
||||
if !canApplyServerURL {
|
||||
Text(session.state == .tearingDown
|
||||
? "Waiting for failure cleanup to finish…"
|
||||
: "End the conversation before changing servers.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.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() }
|
||||
|
||||
@@ -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 {
|
||||
ScrollView {
|
||||
VStack(spacing: 12) {
|
||||
Group {
|
||||
if taskStore.sortedTasks.isEmpty {
|
||||
emptyState
|
||||
} else {
|
||||
taskList
|
||||
}
|
||||
}
|
||||
.navigationTitle("Activity")
|
||||
.background(Nearlight.ground)
|
||||
}
|
||||
|
||||
private var taskList: some View {
|
||||
ScrollView {
|
||||
LazyVStack(alignment: .leading, spacing: 0) {
|
||||
ForEach(taskStore.sortedTasks) { task in
|
||||
TaskCardView(task: task)
|
||||
NavigationLink {
|
||||
TaskDetailView(taskStore: taskStore, taskID: task.id)
|
||||
} label: {
|
||||
TaskRowView(task: task)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
Divider()
|
||||
.overlay(Nearlight.separator)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, Nearlight.s20)
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.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)
|
||||
}
|
||||
if !task.steps.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(task.createdAt, style: .time)
|
||||
.font(NearlightFont.metadata(typeSize))
|
||||
.foregroundStyle(Nearlight.quiet)
|
||||
.monospacedDigit()
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.foregroundStyle(Nearlight.quiet)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
.padding(.vertical, Nearlight.s16)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
|
||||
private var statusTitle: String {
|
||||
switch task.status {
|
||||
case .pending: return "Queued"
|
||||
case .running: return "In progress"
|
||||
case .completed: return "Complete"
|
||||
case .failed: return "Needs attention"
|
||||
}
|
||||
}
|
||||
|
||||
private var latestStep: String? {
|
||||
if let error = task.error { return error }
|
||||
if let result = task.result { return result }
|
||||
return task.steps.last?.content
|
||||
}
|
||||
|
||||
private var statusSymbol: String {
|
||||
switch task.status {
|
||||
case .pending: return "circle.dotted"
|
||||
case .running: return "arrow.triangle.2.circlepath"
|
||||
case .completed: return "checkmark.circle"
|
||||
case .failed: return "exclamationmark.circle"
|
||||
}
|
||||
}
|
||||
|
||||
private var statusColor: Color {
|
||||
switch task.status {
|
||||
case .pending: return Nearlight.quiet
|
||||
case .running: return Nearlight.rosewood
|
||||
case .completed: return Nearlight.quiet
|
||||
case .failed: return Nearlight.alert
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Native push detail for one task: title, status, steps, result/error.
|
||||
/// Looks the task up live in the store so removal shows the missing state.
|
||||
struct TaskDetailView: View {
|
||||
@ObservedObject var taskStore: TaskStore
|
||||
let taskID: String
|
||||
@Environment(\.dynamicTypeSize) private var typeSize
|
||||
|
||||
private var task: TaskItem? {
|
||||
taskStore.tasks[taskID]
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if let task {
|
||||
TaskDetailContent(task: task)
|
||||
} else {
|
||||
Text("This task is no longer available.")
|
||||
.font(.body)
|
||||
.foregroundStyle(Nearlight.quiet)
|
||||
.padding(Nearlight.s20)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
.background(Nearlight.ground)
|
||||
.navigationTitle("Task")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
}
|
||||
|
||||
private struct TaskDetailContent: View {
|
||||
let task: TaskItem
|
||||
@Environment(\.dynamicTypeSize) private var typeSize
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: Nearlight.s24) {
|
||||
VStack(alignment: .leading, spacing: Nearlight.s12) {
|
||||
Text(task.description)
|
||||
.font(NearlightFont.heading(typeSize))
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
Label(statusTitle, systemImage: statusSymbol)
|
||||
.font(NearlightFont.support(typeSize))
|
||||
.foregroundStyle(statusColor)
|
||||
Text(task.createdAt, style: .time)
|
||||
.font(NearlightFont.metadata(typeSize))
|
||||
.foregroundStyle(Nearlight.quiet)
|
||||
.monospacedDigit()
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text("Steps")
|
||||
.font(NearlightFont.heading(typeSize))
|
||||
.padding(.bottom, Nearlight.s8)
|
||||
if task.steps.isEmpty {
|
||||
Text("No steps reported yet.")
|
||||
.font(.body)
|
||||
.foregroundStyle(Nearlight.quiet)
|
||||
} else {
|
||||
ForEach(task.steps) { step in
|
||||
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)
|
||||
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 {
|
||||
block(result, color: .green, background: Color.green.opacity(0.12))
|
||||
Text(result)
|
||||
.font(.body)
|
||||
.foregroundStyle(Nearlight.ink)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
if let error = task.error {
|
||||
block(error, color: .red, background: Color.red.opacity(0.12))
|
||||
Text(error)
|
||||
.font(.body)
|
||||
.foregroundStyle(Nearlight.alert)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 14))
|
||||
.padding(.horizontal, Nearlight.s20)
|
||||
.padding(.top, Nearlight.s24)
|
||||
.padding(.bottom, Nearlight.s24)
|
||||
}
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
@@ -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)")
|
||||
}
|
||||
} }
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
@@ -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 [email protected] '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
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
@@ -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
|
||||
@@ -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"
|
||||
Reference in new issue
Block a user